diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 8af21d0..6ac8f4b 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -7,6 +7,8 @@ import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/home/ui/home_screen.dart'; import 'package:flux/features/master_data/products/ui/products_screen.dart'; import 'package:flux/features/master_data/store/ui/create_store_screen.dart'; +import 'package:flux/features/services/models/service_model.dart'; +import 'package:flux/features/services/ui/service_form_screen.dart'; import 'package:go_router/go_router.dart'; import 'dart:async'; @@ -74,6 +76,15 @@ class AppRouter { name: 'products', builder: (context, state) => const ProductsScreen(), ), + GoRoute( + path: '/service-form', + name: 'service-form', + builder: (context, state) { + // Recuperiamo il ServiceModel se passato come extra + final service = state.extra as ServiceModel?; + return ServiceFormScreen(initialService: service); + }, + ), ], ); } diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 10f5e05..d092197 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -3,6 +3,8 @@ 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/master_data/master_data_hub_content.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; +import 'package:flux/features/services/ui/services_screen.dart'; import 'dashboard_content.dart'; // Importiamo il contenuto della dashboard class HomeScreen extends StatefulWidget { @@ -16,6 +18,16 @@ class _HomeScreenState extends State { int _selectedIndex = 0; bool _extendRailway = false; + @override + void initState() { + super.initState(); + // Caricamento "silenzioso" all'avvio dell'app + // Usiamo WidgetsBinding per assicurarci che il contesto sia pronto + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadServices(); + }); + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -63,7 +75,7 @@ class _HomeScreenState extends State { ), BottomNavigationBarItem( icon: Icon(Icons.receipt_long), - label: 'Operazioni', + label: 'Servizi', ), BottomNavigationBarItem( icon: Icon(Icons.folder_shared), @@ -111,7 +123,7 @@ class _HomeScreenState extends State { NavigationRailDestination( icon: Icon(Icons.receipt_long_outlined), selectedIcon: Icon(Icons.receipt_long), - label: Text('Operazioni'), + label: Text('Servizi'), ), NavigationRailDestination( icon: Icon(Icons.folder_shared_outlined), @@ -148,17 +160,18 @@ class _HomeScreenState extends State { // Switch tra le sottopagine Widget _buildPageContent(int index, bool isLargeScreen) { - switch (index) { - case 0: - return DashboardContent( + return IndexedStack( + index: index, + children: [ + DashboardContent( isLargeScreen: isLargeScreen, onTabRequested: (idx) => setState(() => _selectedIndex = 2), - ); - case 1: - return const Center(child: Text('Operazioni')); - case 2: + ), + + ServicesScreen(), + // L'unico punto di ingresso per tutte le anagrafiche - return MasterDataHubContent( + MasterDataHubContent( // Qui gestiamo la navigazione "interna" all'hub onOpenPage: (widget) { Navigator.push( @@ -166,9 +179,8 @@ class _HomeScreenState extends State { MaterialPageRoute(builder: (context) => widget), ); }, - ); - default: - return DashboardContent(isLargeScreen: isLargeScreen); - } + ), + ], + ); } } diff --git a/lib/features/master_data/providers/blocs/provider_cubit.dart b/lib/features/master_data/providers/blocs/provider_cubit.dart new file mode 100644 index 0000000..07ea2d7 --- /dev/null +++ b/lib/features/master_data/providers/blocs/provider_cubit.dart @@ -0,0 +1,113 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/master_data/providers/data/provider_repository.dart'; +import 'package:get_it/get_it.dart'; +import '../models/provider_model.dart'; + +class ProvidersState extends Equatable { + final List allProviders; // Tutti i provider della company + final List + associatedIds; // ID dei provider attivi nello store selezionato + final bool isLoading; + final String? errorMessage; + + const ProvidersState({ + this.allProviders = const [], + this.associatedIds = const [], + this.isLoading = false, + this.errorMessage, + }); + + ProvidersState copyWith({ + List? allProviders, + List? associatedIds, + bool? isLoading, + String? errorMessage, + }) { + return ProvidersState( + allProviders: allProviders ?? this.allProviders, + associatedIds: associatedIds ?? this.associatedIds, + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + ); + } + + @override + List get props => [ + allProviders, + associatedIds, + isLoading, + errorMessage, + ]; +} + +class ProvidersCubit extends Cubit { + final ProviderRepository _repository = GetIt.I(); + + ProvidersCubit() : super(const ProvidersState()); + + // Carica i provider della company e quelli associati a uno store specifico + Future loadProviders(String companyId, String? storeId) async { + emit(state.copyWith(isLoading: true)); + try { + final all = await _repository.fetchAllCompanyProviders(companyId); + List associated = []; + + if (storeId != null) { + associated = await _repository.fetchAssociatedProviderIds(storeId); + } + + emit( + state.copyWith( + allProviders: all, + associatedIds: associated, + isLoading: false, + ), + ); + } catch (e) { + emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + } + } + + // Aggiunge o rimuove l'associazione con lo store + Future toggleProviderAssociation({ + required String providerId, + required String storeId, + required bool isCurrentlyAssociated, + }) async { + try { + if (isCurrentlyAssociated) { + await _repository.disassociateProviderFromStore( + providerId: providerId, + storeId: storeId, + ); + // Aggiorniamo lo stato locale rimuovendo l'ID + final newIds = List.from(state.associatedIds) + ..remove(providerId); + emit(state.copyWith(associatedIds: newIds)); + } else { + await _repository.associateProviderToStore( + providerId: providerId, + storeId: storeId, + ); + // Aggiorniamo lo stato locale aggiungendo l'ID + final newIds = List.from(state.associatedIds)..add(providerId); + emit(state.copyWith(associatedIds: newIds)); + } + } catch (e) { + emit(state.copyWith(errorMessage: "Errore durante l'aggiornamento: $e")); + } + } + + // Salvataggio/Update anagrafica (nuovo o modifica) + Future saveProvider(ProviderModel provider) async { + emit(state.copyWith(isLoading: true)); + try { + await _repository.saveProvider(provider); + // Ricarichiamo la lista per vedere le modifiche + await loadProviders(provider.companyId, null); + } catch (e) { + emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + } + } +} diff --git a/lib/features/master_data/providers/data/provider_repository.dart b/lib/features/master_data/providers/data/provider_repository.dart new file mode 100644 index 0000000..46bb48a --- /dev/null +++ b/lib/features/master_data/providers/data/provider_repository.dart @@ -0,0 +1,93 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../models/provider_model.dart'; + +class ProviderRepository { + final _supabase = Supabase.instance.client; + + // --- ASSOCIAZIONE PROVIDER <-> STORE --- + + // Aggiunge un provider a un negozio (Attiva mandato) + Future associateProviderToStore({ + required String providerId, + required String storeId, + }) async { + try { + await _supabase.from('providers_in_stores').insert({ + 'provider_id': providerId, + 'store_id': storeId, + }); + } catch (e) { + throw Exception('Errore durante l\'associazione provider: $e'); + } + } + + // Rimuove un provider da un negozio (Disattiva mandato) + Future disassociateProviderFromStore({ + required String providerId, + required String storeId, + }) async { + try { + await _supabase + .from('providers_in_stores') + .delete() + .eq('provider_id', providerId) + .eq('store_id', storeId); + } catch (e) { + throw Exception('Errore durante la disassociazione provider: $e'); + } + } + + // Recupera tutti i provider di una company (per la lista generale) + Future> fetchAllCompanyProviders(String companyId) async { + try { + final response = await _supabase + .from('provider') + .select() + .eq('company_id', companyId); + + return (response as List).map((m) => ProviderModel.fromMap(m)).toList(); + } catch (e) { + throw Exception('Errore fetch provider: $e'); + } + } + + // Recupera gli ID dei provider associati a uno store (utile per le checkbox) + Future> fetchAssociatedProviderIds(String storeId) async { + try { + final response = await _supabase + .from('providers_in_stores') + .select('provider_id') + .eq('store_id', storeId); + + return (response as List) + .map((item) => item['provider_id'].toString()) + .toList(); + } catch (e) { + throw Exception('Errore recupero ID associati: $e'); + } + } + + // --- FUNZIONI STANDARD --- + + // Questa la userai nel Form Servizi: carica solo i provider abilitati per lo store + Future> fetchActiveProvidersForStore( + String storeId, + ) async { + try { + final response = await _supabase + .from('provider') + .select('*, providers_in_stores!inner(store_id)') + .eq('providers_in_stores.store_id', storeId) + .eq('is_active', true); + + return (response as List).map((m) => ProviderModel.fromMap(m)).toList(); + } catch (e) { + throw Exception('Errore fetch provider attivi: $e'); + } + } + + // Salva o aggiorna l'anagrafica del Provider + Future saveProvider(ProviderModel provider) async { + await _supabase.from('provider').upsert(provider.toMap()); + } +} diff --git a/lib/features/master_data/providers/models/provider_model.dart b/lib/features/master_data/providers/models/provider_model.dart new file mode 100644 index 0000000..55cb4f8 --- /dev/null +++ b/lib/features/master_data/providers/models/provider_model.dart @@ -0,0 +1,96 @@ +import 'package:equatable/equatable.dart'; + +class ProviderModel extends Equatable { + final String id; + final String nome; + final bool telefoniaFissa; + final bool telefoniaMobile; + final bool energia; + final bool assicurazioni; + final bool intrattenimento; + final bool altro; + final bool isActive; + final String companyId; + + const ProviderModel({ + required this.id, + required this.nome, + required this.telefoniaFissa, + required this.telefoniaMobile, + required this.energia, + required this.assicurazioni, + required this.intrattenimento, + required this.altro, + required this.isActive, + required this.companyId, + }); + + factory ProviderModel.fromMap(Map map) { + return ProviderModel( + id: map['id'], + nome: map['nome'], + telefoniaFissa: map['telefonia_fissa'] ?? false, + telefoniaMobile: map['telefonia_mobile'] ?? false, + energia: map['energia'] ?? false, + assicurazioni: map['assicurazioni'] ?? false, + intrattenimento: map['intrattenimento'] ?? false, + altro: map['altro'] ?? false, + isActive: map['is_active'] ?? true, + companyId: map['company_id'], + ); + } + + Map toMap() { + return { + 'nome': nome, + 'telefonia_fissa': telefoniaFissa, + 'telefonia_mobile': telefoniaMobile, + 'energia': energia, + 'assicurazioni': assicurazioni, + 'intrattenimento': intrattenimento, + 'altro': altro, + 'is_active': isActive, + 'company_id': companyId, + }; + } + + @override + List get props => [ + id, + nome, + telefoniaFissa, + telefoniaMobile, + energia, + assicurazioni, + intrattenimento, + altro, + isActive, + companyId, + ]; + + ProviderModel copyWith({ + String? id, + String? nome, + bool? telefoniaFissa, + bool? telefoniaMobile, + bool? energia, + bool? assicurazioni, + bool? intrattenimento, + bool? altro, + bool? isActive, + String? companyId, + }) { + return ProviderModel( + id: id ?? this.id, + nome: nome ?? this.nome, + telefoniaFissa: telefoniaFissa ?? this.telefoniaFissa, + telefoniaMobile: telefoniaMobile ?? this.telefoniaMobile, + energia: energia ?? this.energia, + assicurazioni: assicurazioni ?? this.assicurazioni, + intrattenimento: intrattenimento ?? this.intrattenimento, + altro: altro ?? this.altro, + isActive: isActive ?? this.isActive, + companyId: companyId ?? this.companyId, + ); + } +} diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart index 81b4034..66cc1a4 100644 --- a/lib/features/services/blocs/services_cubit.dart +++ b/lib/features/services/blocs/services_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/features/services/data/services_repository.dart'; import 'package:flux/features/services/models/service_model.dart'; import 'package:get_it/get_it.dart'; @@ -54,11 +55,14 @@ class ServicesState extends Equatable { class ServicesCubit extends Cubit { final ServicesRepository _repository = GetIt.I(); + final SessionBloc _sessionBloc; - ServicesCubit() : super(const ServicesState()); + ServicesCubit(this._sessionBloc) : super(const ServicesState()); // Carica tutto il pacchetto Future loadServices({bool refresh = false}) async { + // Se non è un refresh e abbiamo già dati, non disturbare Supabase + if (!refresh && state.allServices.isNotEmpty) return; if (state.isLoading) return; // Se facciamo refresh, resettiamo tutto @@ -74,6 +78,7 @@ class ServicesCubit extends Cubit { try { final newServices = await _repository.fetchServices( + companyId: _sessionBloc.state.company!.id, offset: currentOffset, searchTerm: state.query, dateRange: state.dateRange, diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart index 157f2df..7da3966 100644 --- a/lib/features/services/data/services_repository.dart +++ b/lib/features/services/data/services_repository.dart @@ -1,94 +1,106 @@ import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/service_model.dart'; -// Importa gli altri modelli se sono in file separati class ServicesRepository { final _supabase = Supabase.instance.client; - // --- RECUPERO TUTTI I SERVIZI --- + // --- RECUPERO PAGINATO CON FILTRI E JOIN --- Future> fetchServices({ + required String companyId, required int offset, int limit = 50, String? searchTerm, DateTimeRange? dateRange, }) async { try { - var query = _supabase.from('service').select(''' - *, - energy_service(*), - fin_service(*), - entertainment_service(*) - '''); + // Nota: 'customer(name, surname)' serve per il display name nella card + var query = _supabase + .from('service') + .select(''' + *, + customer(name, surname), + energy_service(*), + fin_service(*), + entertainment_service(*) + ''') + .eq('company_id', companyId); - // Filtro per range di date + // Filtro Range Date if (dateRange != null) { query = query .gte('created_at', dateRange.start.toIso8601String()) .lte('created_at', dateRange.end.toIso8601String()); } - // Ordinamento e Paginazione + if (searchTerm != null && searchTerm.isNotEmpty) { + // Filtra sui campi della tabella principale O su quelli della tabella joinata + query = query.or( + 'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%,customer.surname.ilike.%$searchTerm%', + ); + } + final response = await query .order('created_at', ascending: false) .range(offset, offset + limit - 1); - final List services = (response as List) + return (response as List) .map((map) => ServiceModel.fromMap(map)) .toList(); - - // Filtro testuale lato client per semplicità (o potresti farlo in SQL se preferisci) - if (searchTerm != null && searchTerm.isNotEmpty) { - return services.where((s) { - // Qui cercheremo per numero pratica o note (il nome cliente lo vedremo poi con le Join) - return s.number.toLowerCase().contains(searchTerm.toLowerCase()) || - s.note.toLowerCase().contains(searchTerm.toLowerCase()); - }).toList(); - } - - return services; } catch (e) { - throw Exception('Errore fetch: $e'); + throw Exception('Errore nel caricamento servizi: $e'); } } - // --- SALVATAGGIO COMPLETO (A CASCATA) --- + // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) --- Future saveFullService(ServiceModel service) async { try { - // 1. Inserimento Padre + // 1. Inseriamo il record principale + // Se service.id è null, Supabase fa INSERT. Se c'è, fa UPDATE (grazie all'upsert o gestione manuale) final serviceData = await _supabase .from('service') - .insert(service.toMap()) + .upsert(service.toMap()) .select() .single(); final String newId = serviceData['id']; - // 2. Inserimento Energy (se presenti) + // 2. Pulizia vecchi record figli (necessaria se è una MODIFICA) + // Se stiamo modificando, cancelliamo i vecchi per reinserire i nuovi (più semplice) + if (service.id != null) { + await _supabase.from('energy_service').delete().eq('service_id', newId); + await _supabase.from('fin_service').delete().eq('service_id', newId); + await _supabase + .from('entertainment_service') + .delete() + .eq('service_id', newId); + } + + // 3. Inserimento EnergyServices if (service.energyServices.isNotEmpty) { - final List> energyToInsert = []; + final List> toInsert = []; for (var item in service.energyServices) { - energyToInsert.add(item.copyWith(serviceId: newId).toMap()); + toInsert.add(item.copyWith(serviceId: newId).toMap()); } - await _supabase.from('energy_service').insert(energyToInsert); + await _supabase.from('energy_service').insert(toInsert); } - // 3. Inserimento Finanziamenti (se presenti) + // 4. Inserimento FinServices if (service.finServices.isNotEmpty) { - final List> finToInsert = []; + final List> toInsert = []; for (var item in service.finServices) { - finToInsert.add(item.copyWith(serviceId: newId).toMap()); + toInsert.add(item.copyWith(serviceId: newId).toMap()); } - await _supabase.from('fin_service').insert(finToInsert); + await _supabase.from('fin_service').insert(toInsert); } - // 4. Inserimento Entertainment (se presenti) + // 5. Inserimento EntertainmentServices if (service.entertainmentServices.isNotEmpty) { - final List> entToInsert = []; + final List> toInsert = []; for (var item in service.entertainmentServices) { - entToInsert.add(item.copyWith(serviceId: newId).toMap()); + toInsert.add(item.copyWith(serviceId: newId).toMap()); } - await _supabase.from('entertainment_service').insert(entToInsert); + await _supabase.from('entertainment_service').insert(toInsert); } } catch (e) { throw Exception('Errore durante il salvataggio: $e'); @@ -96,8 +108,6 @@ class ServicesRepository { } // --- ELIMINAZIONE --- - // Grazie ai "ON DELETE CASCADE" che hai messo nell'SQL, - // cancellando il padre Supabase pialla automaticamente i figli. Top! Future deleteService(String id) async { try { await _supabase.from('service').delete().eq('id', id); diff --git a/lib/features/services/models/service_model.dart b/lib/features/services/models/service_model.dart index f403d3f..5bbfc10 100644 --- a/lib/features/services/models/service_model.dart +++ b/lib/features/services/models/service_model.dart @@ -13,6 +13,7 @@ class ServiceModel extends Equatable { final bool isBozza; final String note; final bool resultOk; + final String? customerDisplayName; // Telefonia final int al; @@ -44,6 +45,7 @@ class ServiceModel extends Equatable { this.energyServices = const [], this.finServices = const [], this.entertainmentServices = const [], + this.customerDisplayName, }); ServiceModel copyWith({ @@ -64,6 +66,7 @@ class ServiceModel extends Equatable { List? energyServices, List? finServices, List? entertainmentServices, + String? customerDisplayName, }) { return ServiceModel( id: id ?? this.id, @@ -84,6 +87,7 @@ class ServiceModel extends Equatable { finServices: finServices ?? this.finServices, entertainmentServices: entertainmentServices ?? this.entertainmentServices, + customerDisplayName: customerDisplayName ?? this.customerDisplayName, ); } @@ -106,6 +110,7 @@ class ServiceModel extends Equatable { energyServices, finServices, entertainmentServices, + customerDisplayName, ]; factory ServiceModel.fromMap(Map map) { @@ -141,6 +146,9 @@ class ServiceModel extends Equatable { ?.map((x) => EntertainmentServiceModel.fromMap(x)) .toList() ?? const [], + customerDisplayName: map['customer'] != null + ? "${map['customer']['name']} ${map['customer']['surname']}" + : "Cliente sconosciuto", ); } diff --git a/lib/features/services/ui/service_form_screen.dart b/lib/features/services/ui/service_form_screen.dart new file mode 100644 index 0000000..d63599c --- /dev/null +++ b/lib/features/services/ui/service_form_screen.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; +import 'package:flux/features/services/models/energy_service_model.dart'; +import 'package:flux/features/services/models/service_model.dart'; + +class ServiceFormScreen extends StatefulWidget { + final ServiceModel? initialService; // Se nullo, è un nuovo inserimento + + const ServiceFormScreen({super.key, this.initialService}); + + @override + State createState() => _ServiceFormScreenState(); +} + +class _ServiceFormScreenState extends State { + late ServiceModel currentService; + + @override + void initState() { + super.initState(); + // Se passiamo un servizio esistente lo carichiamo, altrimenti ne creiamo uno "vuoto" + currentService = + widget.initialService ?? + ServiceModel( + storeId: 'ID_NEGOZIO_QUI', // Poi lo prenderai dal profilo utente + number: '', + energyServices: const [], + finServices: const [], + entertainmentServices: const [], + ); + } + + // Metodo generico per aggiungere un servizio energia + void _addEnergy() { + setState(() { + final newList = + List.from(currentService.energyServices)..add( + EnergyServiceModel( + type: EnergyType.luce, // Default + expiration: DateTime.now().add(const Duration(days: 365)), + providerId: '', // Lo sceglierà l'utente + ), + ); + currentService = currentService.copyWith(energyServices: newList); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + widget.initialService == null ? "Nuova Pratica" : "Modifica", + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // --- SEZIONE DATI GENERALI --- + TextField( + decoration: const InputDecoration(labelText: "Numero Pratica"), + onChanged: (v) => + currentService = currentService.copyWith(number: v), + ), + + const Divider(height: 32), + + // --- SEZIONE ENERGY --- + _SectionHeader( + title: "Energia (Luce/Gas)", + onAdd: _addEnergy, + icon: Icons.electric_bolt, + ), + ...currentService.energyServices.asMap().entries.map((entry) { + int idx = entry.key; + var item = entry.value; + return Card( + child: ListTile( + title: Text( + "${item.type.name.toUpperCase()} - Scadenza: ${item.expiration.year}", + ), + trailing: IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () { + setState(() { + final newList = List.from( + currentService.energyServices, + )..removeAt(idx); + currentService = currentService.copyWith( + energyServices: newList, + ); + }); + }, + ), + ), + ); + }), + + const SizedBox(height: 40), + + // --- BOTTONE SALVA --- + ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + onPressed: () { + context.read().addService(currentService); + Navigator.pop(context); + }, + child: const Text("SALVA TUTTO"), + ), + ], + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String title; + final VoidCallback onAdd; + final IconData icon; + + const _SectionHeader({ + required this.title, + required this.onAdd, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, color: Colors.orange), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const Spacer(), + IconButton( + onPressed: onAdd, + icon: const Icon(Icons.add_circle, color: Colors.green, size: 30), + ), + ], + ); + } +} diff --git a/lib/features/services/ui/services_screen.dart b/lib/features/services/ui/services_screen.dart new file mode 100644 index 0000000..aea5315 --- /dev/null +++ b/lib/features/services/ui/services_screen.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; +import 'package:flux/features/services/models/service_model.dart'; +import 'package:go_router/go_router.dart'; +// Importa i tuoi modelli e cubit + +class ServicesScreen extends StatefulWidget { + const ServicesScreen({super.key}); + + @override + State createState() => _ServicesScreenState(); +} + +class _ServicesScreenState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + // Agganciamo il listener per la paginazione (Scroll Infinito) + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (_isBottom) { + context.read().loadServices(); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + // Carica quando mancano 200px alla fine + return currentScroll >= (maxScroll * 0.9); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Gestione Servizi"), + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + // Qui potrai implementare una barra di ricerca + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + // 1. Stato di caricamento iniziale + if (state.isLoading && state.allServices.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + // 2. Lista vuota + if (state.allServices.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Nessuna pratica trovata."), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () => context.read().loadServices( + refresh: true, + ), + child: const Text("Riprova"), + ), + ], + ), + ); + } + + // 3. La Lista (con Pull-to-refresh) + return RefreshIndicator( + onRefresh: () => + context.read().loadServices(refresh: true), + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB + itemCount: state.hasReachedMax + ? state.allServices.length + : state.allServices.length + 1, + itemBuilder: (context, index) { + if (index >= state.allServices.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + final service = state.allServices[index]; + return _buildServiceCard(context, service); + }, + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.pushNamed('service-form'), // GoRouter + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildServiceCard(BuildContext context, ServiceModel service) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.all(12), + title: Row( + children: [ + Expanded( + child: Text( + service.customerDisplayName ?? "Cliente sconosciuto", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + if (service.isBozza) + const Chip( + label: Text( + "BOZZA", + style: TextStyle(fontSize: 10, color: Colors.white), + ), + backgroundColor: Colors.orange, + visualDensity: VisualDensity.compact, + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + "Pratica: ${service.number} • ${service.createdAt?.day}/${service.createdAt?.month}/${service.createdAt?.year}", + ), + const SizedBox(height: 8), + // I nostri mini-chip per i servizi attivati + Wrap( + spacing: 6, + children: [ + if (service.al > 0 || service.mnp > 0) + _miniBadge("📞 Tel", Colors.blue), + if (service.energyServices.isNotEmpty) + _miniBadge("⚡ Energy", Colors.green), + if (service.finServices.isNotEmpty) + _miniBadge("💰 Fin", Colors.purple), + if (service.entertainmentServices.isNotEmpty) + _miniBadge("📺 Ent", Colors.red), + ], + ), + ], + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.pushNamed('service-form', extra: service), + ), + ); + } + + Widget _miniBadge(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Text( + text, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index fa4d3c4..147acc4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -16,6 +18,8 @@ import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; import 'package:flux/features/master_data/staff/data/staff_repository.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; import 'package:flux/features/master_data/store/data/store_repository.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; +import 'package:flux/features/services/data/services_repository.dart'; import 'package:flux/features/settings/settings.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -59,6 +63,7 @@ Future setupLocator() async { getIt.registerLazySingleton(() => CustomerRepository()); getIt.registerLazySingleton(() => ProductRepository()); getIt.registerLazySingleton(() => StaffRepository()); + getIt.registerLazySingleton(() => ServicesRepository()); } class FluxApp extends StatefulWidget { @@ -95,6 +100,9 @@ class _FluxAppState extends State { create: (_) => StaffCubit(context.read())..loadAllStaff(), ), + BlocProvider( + create: (_) => ServicesCubit(context.read()), + ), ], child: BlocBuilder( builder: (context, state) {