diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 96df281..5200cda 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -1,4 +1,5 @@ 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/auth/ui/auth_screen.dart'; import 'package:flux/features/company/ui/create_company_screen.dart'; @@ -7,7 +8,10 @@ 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/blocs/services_cubit.dart'; +import 'package:flux/features/services/data/services_repository.dart'; import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'dart:async'; @@ -79,6 +83,13 @@ class AppRouter { path: '/service-form', name: 'service-form', builder: (context, state) { + // Recuperiamo il serviceId dai parametri della query (es: /service-form?serviceId=123) + final serviceId = state.uri.queryParameters['serviceId']; + if (serviceId != null) { + context.read().initServiceForm( + serviceId: serviceId, + ); + } return ServiceFormScreen(); }, ), diff --git a/lib/features/customers/blocs/customer_cubit.dart b/lib/features/customers/blocs/customer_cubit.dart index 73a9120..e9902b5 100644 --- a/lib/features/customers/blocs/customer_cubit.dart +++ b/lib/features/customers/blocs/customer_cubit.dart @@ -1,6 +1,7 @@ import 'dart:async'; // Serve per il Timer del debounce import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flux/core/blocs/session/session_bloc.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'; @@ -9,6 +10,7 @@ part 'customer_state.dart'; class CustomerCubit extends Cubit { final CustomerRepository _repository = GetIt.I(); + final SessionBloc _sessionBloc = GetIt.I(); // Variabile per gestire il debounce della ricerca Timer? _searchDebounce; @@ -16,10 +18,12 @@ class CustomerCubit extends Cubit { CustomerCubit() : super(const CustomerState()); // --- LETTURA --- - Future loadCustomers(String companyId) async { + Future loadCustomers() async { emit(state.copyWith(status: CustomerStatus.loading)); try { - final customers = await _repository.getCustomers(companyId); + final customers = await _repository.getCustomers( + _sessionBloc.state.company!.id, + ); emit( state.copyWith(status: CustomerStatus.success, customers: customers), ); @@ -92,21 +96,24 @@ class CustomerCubit extends Cubit { } // --- RICERCA CON DEBOUNCE --- - void searchCustomers(String companyId, String query) { + void searchCustomers(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 { + _searchDebounce = Timer(const Duration(milliseconds: 300), () async { // Se cancella tutto e la query è vuota, ricarichiamo la lista base if (query.trim().isEmpty) { - await loadCustomers(companyId); + await loadCustomers(); return; } // Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive try { - final results = await _repository.searchCustomers(companyId, query); + final results = await _repository.searchCustomers( + _sessionBloc.state.company!.id, + query, + ); emit( state.copyWith(status: CustomerStatus.success, customers: results), ); diff --git a/lib/features/customers/ui/customer_search_sheet.dart b/lib/features/customers/ui/customer_search_sheet.dart index c3f6d06..bb5245c 100644 --- a/lib/features/customers/ui/customer_search_sheet.dart +++ b/lib/features/customers/ui/customer_search_sheet.dart @@ -16,9 +16,7 @@ class _CustomerSearchSheetState extends State { @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: ''); + context.read().loadCustomers(); } @override @@ -28,10 +26,7 @@ class _CustomerSearchSheetState extends State { } 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); + context.read().searchCustomers(query); } @override diff --git a/lib/features/customers/ui/customers_content.dart b/lib/features/customers/ui/customers_content.dart index 1348e65..fcacbc9 100644 --- a/lib/features/customers/ui/customers_content.dart +++ b/lib/features/customers/ui/customers_content.dart @@ -26,14 +26,14 @@ class _CustomersContentState extends State { void _loadInitialCustomers() { final companyId = context.read().state.company?.id; if (companyId != null) { - context.read().loadCustomers(companyId); + context.read().loadCustomers(); } } void _onSearch(String query) { final companyId = context.read().state.company?.id; if (companyId != null) { - context.read().searchCustomers(companyId, query); + context.read().searchCustomers( query); } } diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart index 681b2ea..2c75cb9 100644 --- a/lib/features/services/blocs/services_cubit.dart +++ b/lib/features/services/blocs/services_cubit.dart @@ -8,26 +8,27 @@ import 'package:flux/features/services/models/entertainment_service_model.dart'; import 'package:flux/features/services/models/fin_service_model.dart'; import 'package:flux/features/services/models/service_model.dart'; import 'package:get_it/get_it.dart'; +import 'package:collection/collection.dart'; part 'services_state.dart'; class ServicesCubit extends Cubit { final ServicesRepository _repository = GetIt.I(); final SessionBloc _sessionBloc = GetIt.I(); - ServicesCubit() : super(const ServicesState()); + ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial)); // --- CARICAMENTO E PAGINAZIONE --- Future loadServices({bool refresh = false}) async { // Se stiamo già caricando, evitiamo chiamate doppie - if (state.isLoading) return; + if (state.status == ServicesStatus.loading) return; // Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo if (!refresh && state.hasReachedMax) return; emit( state.copyWith( - isLoading: true, + status: ServicesStatus.loading, errorMessage: null, // Se è un refresh, svuotiamo la lista attuale per mostrare lo shimmer/loading allServices: refresh ? [] : state.allServices, @@ -56,7 +57,7 @@ class ServicesCubit extends Cubit { emit( state.copyWith( - isLoading: false, + status: ServicesStatus.ready, allServices: refresh ? newServices : [...state.allServices, ...newServices], @@ -66,7 +67,7 @@ class ServicesCubit extends Cubit { } catch (e) { emit( state.copyWith( - isLoading: false, + status: ServicesStatus.failure, errorMessage: "Errore nel caricamento servizi: $e", ), ); @@ -95,9 +96,28 @@ class ServicesCubit extends Cubit { // --- GESTIONE BOZZA (DRAFT) --- /// Inizializza un nuovo servizio o ne carica uno esistente per la modifica - void initServiceForm(ServiceModel? existingService) { + void initServiceForm({ + ServiceModel? existingService, + String? serviceId, + }) async { if (existingService != null) { - emit(state.copyWith(currentService: existingService)); + emit( + state.copyWith( + currentService: existingService, + status: ServicesStatus.ready, + ), + ); + } else if (serviceId != null) { + ServiceModel? serviceModel = state.allServices.firstWhereOrNull( + (s) => s.id == serviceId, + ); + serviceModel ??= await _repository.fetchServiceById(serviceId); + emit( + state.copyWith( + currentService: serviceModel, + status: ServicesStatus.ready, + ), + ); } else { // Crea un template vuoto con lo store di default (se disponibile) emit( @@ -106,7 +126,9 @@ class ServicesCubit extends Cubit { storeId: _sessionBloc.state.selectedStore?.id ?? '', number: '', // Sarà compilato dall'utente createdAt: DateTime.now(), + companyId: _sessionBloc.state.company!.id, ), + status: ServicesStatus.ready, ), ); } @@ -180,16 +202,22 @@ class ServicesCubit extends Cubit { Future saveCurrentService() async { if (state.currentService == null) return; - emit(state.copyWith(isSaving: true, errorMessage: null)); + emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null)); try { // Usiamo il repository corazzato che abbiamo scritto prima await _repository.saveFullService(state.currentService!); - // Reset della bozza e ricaricamento lista - emit(state.copyWith(isSaving: false, currentService: null)); await loadServices(refresh: true); + // Reset della bozza e ricaricamento lista + emit(state.copyWith(status: ServicesStatus.saved, currentService: null)); } catch (e) { - emit(state.copyWith(isSaving: false, errorMessage: e.toString())); + emit( + state.copyWith( + status: ServicesStatus.failure, + + errorMessage: e.toString(), + ), + ); } } } diff --git a/lib/features/services/blocs/services_state.dart b/lib/features/services/blocs/services_state.dart index 222c114..00439fd 100644 --- a/lib/features/services/blocs/services_state.dart +++ b/lib/features/services/blocs/services_state.dart @@ -1,20 +1,20 @@ part of 'services_cubit.dart'; +enum ServicesStatus { initial, loading, ready, saving, saved, success, failure } + class ServicesState extends Equatable { + final ServicesStatus status; final List allServices; final ServiceModel? currentService; // La bozza che stiamo editando - final bool isLoading; - final bool isSaving; // Per mostrare il caricamento solo sul tasto salva final String? errorMessage; final String query; final DateTimeRange? dateRange; final bool hasReachedMax; const ServicesState({ + required this.status, this.allServices = const [], this.currentService, - this.isLoading = false, - this.isSaving = false, this.errorMessage, this.query = '', this.dateRange, @@ -22,20 +22,18 @@ class ServicesState extends Equatable { }); ServicesState copyWith({ + ServicesStatus? status, List? allServices, ServiceModel? currentService, - bool? isLoading, - bool? isSaving, String? errorMessage, String? query, DateTimeRange? dateRange, bool? hasReachedMax, }) { return ServicesState( + status: status ?? this.status, allServices: allServices ?? this.allServices, currentService: currentService ?? this.currentService, - isLoading: isLoading ?? this.isLoading, - isSaving: isSaving ?? this.isSaving, errorMessage: errorMessage, query: query ?? this.query, dateRange: dateRange ?? this.dateRange, @@ -45,10 +43,9 @@ class ServicesState extends Equatable { @override List get props => [ + status, allServices, currentService, - isLoading, - isSaving, errorMessage, query, dateRange, diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart index 02952e7..da967e0 100644 --- a/lib/features/services/data/services_repository.dart +++ b/lib/features/services/data/services_repository.dart @@ -5,6 +5,27 @@ import '../models/service_model.dart'; class ServicesRepository { final _supabase = Supabase.instance.client; + // --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO --- + Future fetchServiceById(String id) async { + try { + final response = await _supabase + .from('service') + .select(''' + *, + customer(nome), + energy_service(*), + fin_service(*), + entertainment_service(*) + ''') + .eq('id', id) + .single(); + + return ServiceModel.fromMap(response); + } catch (e) { + throw Exception('Errore nel caricamento del servizio: $e'); + } + } + // --- RECUPERO PAGINATO CON FILTRI E JOIN --- Future> fetchServices({ required String companyId, @@ -19,7 +40,7 @@ class ServicesRepository { .from('service') .select(''' *, - customer(name, surname), + customer(nome), energy_service(*), fin_service(*), entertainment_service(*) @@ -36,7 +57,7 @@ class ServicesRepository { 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%', + 'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.nome.ilike.%$searchTerm%', ); } @@ -134,4 +155,36 @@ class ServicesRepository { throw Exception('Errore durante l\'eliminazione: $e'); } } + + // --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE --- + Future> fetchTopEntertainmentTypes(String companyId) async { + try { + // Cerchiamo i tipi più frequenti associati ai servizi di questa company + // Nota: dobbiamo passare attraverso la tabella 'service' per filtrare per company_id + final response = await _supabase + .from('entertainment_service') + .select('type, service!inner(store!inner(company_id))') + .eq('service.store.company_id', companyId) + .limit(100); // Prendiamo un campione + + // Logica rapida per contare le occorrenze e prendere i primi 5 + final Map counts = {}; + for (var item in (response as List)) { + final type = item['type'] as String; + counts[type] = (counts[type] ?? 0) + 1; + } + + var sortedKeys = counts.keys.toList() + ..sort((a, b) => counts[b]!.compareTo(counts[a]!)); + + return sortedKeys.take(5).toList(); + } catch (e) { + return [ + "Netflix", + "DAZN", + "Disney+", + "Sky", + ]; // Fallback se non c'è ancora storia + } + } } diff --git a/lib/features/services/models/service_model.dart b/lib/features/services/models/service_model.dart index 6a14e46..c980d98 100644 --- a/lib/features/services/models/service_model.dart +++ b/lib/features/services/models/service_model.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/features/services/models/energy_service_model.dart'; import 'package:flux/features/services/models/entertainment_service_model.dart'; import 'package:flux/features/services/models/fin_service_model.dart'; @@ -14,6 +15,7 @@ class ServiceModel extends Equatable { final String note; final bool resultOk; final String? customerDisplayName; + final String companyId; // Telefonia final int al; @@ -46,6 +48,7 @@ class ServiceModel extends Equatable { this.finServices = const [], this.entertainmentServices = const [], this.customerDisplayName, + required this.companyId, }); ServiceModel copyWith({ @@ -67,6 +70,7 @@ class ServiceModel extends Equatable { List? finServices, List? entertainmentServices, String? customerDisplayName, + String? companyId, }) { return ServiceModel( id: id ?? this.id, @@ -88,6 +92,7 @@ class ServiceModel extends Equatable { entertainmentServices: entertainmentServices ?? this.entertainmentServices, customerDisplayName: customerDisplayName ?? this.customerDisplayName, + companyId: companyId ?? this.companyId, ); } @@ -111,6 +116,7 @@ class ServiceModel extends Equatable { finServices, entertainmentServices, customerDisplayName, + companyId, ]; factory ServiceModel.fromMap(Map map) { @@ -151,9 +157,9 @@ class ServiceModel extends Equatable { // Display name del cliente con fallback customerDisplayName: map['customer'] != null - ? "${map['customer']['name'] ?? ''} ${map['customer']['surname'] ?? ''}" - .trim() + ? "${map['customer']['nome'] ?? ''}".myFormat() : "Cliente non assegnato", + companyId: map['company_id'] as String, ); } @@ -172,6 +178,7 @@ class ServiceModel extends Equatable { 'nip': nip, 'unica': unica, 'telepass': telepass, + 'company_id': companyId, // Le liste non le mettiamo qui perché vanno in tabelle diverse! }; } diff --git a/lib/features/services/ui/service_form_screen/entertainment_service_card.dart b/lib/features/services/ui/service_form_screen/entertainment_service_card.dart new file mode 100644 index 0000000..5d05381 --- /dev/null +++ b/lib/features/services/ui/service_form_screen/entertainment_service_card.dart @@ -0,0 +1,392 @@ +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/master_data/providers/blocs/provider_cubit.dart'; +import 'package:flux/features/master_data/providers/models/provider_model.dart'; +import 'package:flux/features/services/data/services_repository.dart'; +import 'package:flux/features/services/models/entertainment_service_model.dart'; +import 'package:get_it/get_it.dart'; + +class EntertainmentServiceDialog extends StatefulWidget { + final List initialServices; + final String currentStoreId; + + const EntertainmentServiceDialog({ + super.key, + required this.initialServices, + required this.currentStoreId, + }); + + @override + State createState() => + _EntertainmentServiceDialogState(); +} + +class _EntertainmentServiceDialogState + extends State { + late List _tempList; + bool _isAddingNew = false; + + @override + void initState() { + super.initState(); + _tempList = List.from(widget.initialServices); + // Carichiamo i provider attivi per lo store corrente + context.read().loadActiveProvidersForStore( + widget.currentStoreId, + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Row( + children: [ + Icon( + Icons.movie_filter_outlined, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text(_isAddingNew ? "Nuovo Servizio" : "Servizi Intrattenimento"), + ], + ), + content: AnimatedSize( + duration: const Duration(milliseconds: 300), + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + child: _isAddingNew + ? _EntertainmentForm( + // Il form che abbiamo creato prima + onSave: (newService) => setState(() { + _tempList.add(newService); + _isAddingNew = false; + }), + onCancel: () => setState(() => _isAddingNew = false), + ) + : BlocBuilder( + builder: (context, state) { + // Passiamo allProviders per garantire la visione dello storico + return _EntertainmentList( + services: _tempList, + allProviders: state.allProviders, + onDelete: (index) => + setState(() => _tempList.removeAt(index)), + onAddTap: () => setState(() => _isAddingNew = true), + ); + }, + ), + ), + ), + actions: !_isAddingNew + ? [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Annulla"), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, _tempList), + child: const Text("Conferma Tutti"), + ), + ] + : null, // I pulsanti del form sono interni al form stesso + ); + } +} + +class _EntertainmentList extends StatelessWidget { + final List services; + final List allProviders; + final Function(int) onDelete; + final VoidCallback onAddTap; + + const _EntertainmentList({ + required this.services, + required this.allProviders, + required this.onDelete, + required this.onAddTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (services.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 32.0), + child: Text( + "Nessun servizio intrattenimento.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ) + else + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: services.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final s = services[index]; + + final providerName = allProviders + .firstWhere( + (p) => p.id == s.providerId, + orElse: () => ProviderModel( + id: '', + nome: 'Fornitore Storico', + companyId: '', + isActive: false, + energia: false, + telefoniaFissa: false, + telefoniaMobile: false, + assicurazioni: false, + altro: false, + intrattenimento: false, + ), + ) + .nome; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: Colors.purple.shade100, + child: const Icon( + Icons.movie_creation_outlined, + color: Colors.purple, + ), + ), + title: Text( + "${s.type} • $providerName", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + s.constrained + ? "Vincolo fino al: ${s.constrainExpiration.day}/${s.constrainExpiration.month}/${s.constrainExpiration.year}" + : "Senza vincoli", + style: TextStyle( + color: s.constrained + ? Colors.red.shade700 + : Colors.green.shade700, + ), + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red), + onPressed: () => onDelete(index), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: onAddTap, + icon: const Icon(Icons.add), + label: const Text("Aggiungi Servizio"), + ), + ], + ); + } +} + +// ---ENTERTAINMENT FORM (MODALE)--- + +class _EntertainmentForm extends StatefulWidget { + final Function(EntertainmentServiceModel) onSave; + final VoidCallback onCancel; + + const _EntertainmentForm({required this.onSave, required this.onCancel}); + + @override + State<_EntertainmentForm> createState() => _EntertainmentFormState(); +} + +class _EntertainmentFormState extends State<_EntertainmentForm> { + String? _selectedProviderId; + final TextEditingController _typeController = TextEditingController(); + bool _isConstrained = false; + DateTime _expirationDate = DateTime.now().add( + const Duration(days: 365), + ); // Default 12 mesi + + // Preset rapidi per il vincolo (es: 12, 24 mesi) + int? _selectedPresetMonths; + + void _applyPreset(int months) { + setState(() { + _selectedPresetMonths = months; + _isConstrained = true; + final now = DateTime.now(); + _expirationDate = DateTime(now.year, now.month + months, now.day); + }); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _expirationDate, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 10)), + ); + if (picked != null) { + setState(() { + _expirationDate = picked; + _selectedPresetMonths = null; + _isConstrained = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 1. GESTORE (Filtro intrattenimento) + BlocBuilder( + builder: (context, state) { + final filtered = state.activeProviders + .where((p) => p.intrattenimento) + .toList(); + return DropdownButtonFormField( + decoration: const InputDecoration( + labelText: "Fornitore (es: Sky, TIM)", + border: OutlineInputBorder(), + ), + items: filtered + .map( + (p) => DropdownMenuItem(value: p.id, child: Text(p.nome)), + ) + .toList(), + onChanged: (val) => setState(() => _selectedProviderId = val), + ); + }, + ), + const SizedBox(height: 16), + + // 2. TIPO SERVIZIO (TextField con suggerimenti rapidi sotto) + TextFormField( + controller: _typeController, + decoration: const InputDecoration( + labelText: "Servizio", + hintText: "es: Netflix, DAZN, Disney+", + border: OutlineInputBorder(), + ), + onChanged: (val) => setState(() {}), + ), + const SizedBox(height: 8), + // Suggerimenti rapidi (Chip) + FutureBuilder>( + future: GetIt.I().fetchTopEntertainmentTypes( + GetIt.I().state.company!.id, + ), + builder: (context, snapshot) { + final suggestions = snapshot.data ?? ["Netflix", "DAZN", "Sky"]; + return Wrap( + spacing: 8, + children: suggestions.map((s) { + return ActionChip( + label: Text(s, style: const TextStyle(fontSize: 12)), + onPressed: () => setState(() => _typeController.text = s), + ); + }).toList(), + ); + }, + ), + const SizedBox(height: 16), + + // 3. VINCOLO CONTRATTUALE + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Vincolo di permanenza", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Switch( + value: _isConstrained, + onChanged: (val) => setState(() { + _isConstrained = val; + if (!val) _selectedPresetMonths = null; + }), + ), + ], + ), + + if (_isConstrained) ...[ + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 12, label: Text("12m")), + ButtonSegment(value: 24, label: Text("24m")), + ButtonSegment( + value: null, + label: Icon(Icons.calendar_month, size: 20), + ), + ], + selected: {_selectedPresetMonths}, + onSelectionChanged: (val) { + if (val.first == null) { + _pickDate(); + } else { + _applyPreset(val.first!); + } + }, + ), + const SizedBox(height: 12), + // Box data scadenza vincolo + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.event_busy, size: 18, color: Colors.redAccent), + const SizedBox(width: 8), + Text( + "Scadenza vincolo: ${_expirationDate.day.toString().padLeft(2, '0')}/${_expirationDate.month.toString().padLeft(2, '0')}/${_expirationDate.year}", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ], + + const SizedBox(height: 24), + + // PULSANTI + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: widget.onCancel, + child: const Text("Annulla"), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: + (_selectedProviderId == null || _typeController.text.isEmpty) + ? null + : () => widget.onSave( + EntertainmentServiceModel( + providerId: _selectedProviderId!, + type: _typeController.text, + constrained: _isConstrained, + constrainExpiration: _expirationDate, + ), + ), + child: const Text("Aggiungi"), + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/services/ui/service_form_screen/service_form_screen.dart b/lib/features/services/ui/service_form_screen/service_form_screen.dart index afae7f8..217bcde 100644 --- a/lib/features/services/ui/service_form_screen/service_form_screen.dart +++ b/lib/features/services/ui/service_form_screen/service_form_screen.dart @@ -10,45 +10,67 @@ class ServiceFormScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Nuova Pratica"), - actions: [ - _SaveButton(), // Tasto salva intelligente - ], - ), - body: BlocBuilder( - builder: (context, state) { - final service = state.currentService; - - // Se la bozza non è ancora inizializzata, mostriamo un loader - if (service == null) { - return const Center(child: CircularProgressIndicator()); - } - - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // SEZIONE 1: CLIENTE - CustomerSection(service: service), - const SizedBox(height: 24), - - // SEZIONE 2: INFO GENERALI (Da fare) - GeneralInfoSection(service: service), - const SizedBox(height: 24), - - // SEZIONE 3: I MODULI (Da fare) - ServicesGrid(service: service), - const SizedBox(height: 32), - - // SEZIONE 4: ALLEGATI (Da fare) - // const _AttachmentsSection(), - ], + return BlocListener( + listener: (context, state) { + if (state.status == ServicesStatus.saved) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Pratica salvata con successo!"), + backgroundColor: Colors.green, ), ); - }, + Navigator.pop(context); // Torna alla lista di pratiche + } else if (state.status == ServicesStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Si è verificato un errore ${state.errorMessage ?? ''}", + ), + backgroundColor: Colors.red, + ), + ); + } + }, + child: Scaffold( + appBar: AppBar( + title: const Text("Nuova Pratica"), + actions: [ + _SaveButton(), // Tasto salva intelligente + ], + ), + body: BlocBuilder( + builder: (context, state) { + final service = state.currentService; + + // Se la bozza non è ancora inizializzata, mostriamo un loader + if (service == null) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // SEZIONE 1: CLIENTE + CustomerSection(service: service), + const SizedBox(height: 24), + + // SEZIONE 2: INFO GENERALI + GeneralInfoSection(service: service), + const SizedBox(height: 24), + + // SEZIONE 3: I MODULI + ServicesGrid(service: service), + const SizedBox(height: 32), + + // TODO SEZIONE 4: ALLEGATI (Da fare) + // const _AttachmentsSection(), + ], + ), + ); + }, + ), ), ); } @@ -61,7 +83,7 @@ class _SaveButton extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - if (state.isSaving) { + if (state.status == ServicesStatus.saving) { return const Padding( padding: EdgeInsets.all(16.0), child: SizedBox( @@ -78,7 +100,6 @@ class _SaveButton extends StatelessWidget { icon: const Icon(Icons.save), tooltip: "Salva Pratica", onPressed: () { - // TODO: Aggiungere una validazione prima di salvare! context.read().saveCurrentService(); }, ); diff --git a/lib/features/services/ui/service_form_screen/services_grid.dart b/lib/features/services/ui/service_form_screen/services_grid.dart index 66c06f1..28e282a 100644 --- a/lib/features/services/ui/service_form_screen/services_grid.dart +++ b/lib/features/services/ui/service_form_screen/services_grid.dart @@ -3,10 +3,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/features/master_data/products/blocs/product_cubit.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/entertainment_service_model.dart'; import 'package:flux/features/services/models/fin_service_model.dart'; import 'package:flux/features/services/models/service_model.dart'; import 'package:flux/features/services/ui/service_form_screen/action_card.dart'; import 'package:flux/features/services/ui/service_form_screen/energy_service_dialog.dart'; +import 'package:flux/features/services/ui/service_form_screen/entertainment_service_card.dart'; import 'package:flux/features/services/ui/service_form_screen/finance_service_dialog.dart'; import 'package:flux/features/services/ui/service_form_screen/int_dialogs.dart'; // Assicurati di importare il modello @@ -162,12 +164,25 @@ class ServicesGrid extends StatelessWidget { }, ), ActionCard( - label: "Contenuti", + label: "Intratten.", count: service.entertainmentServices.length, - icon: Icons.tv, - color: Colors.redAccent, - onTap: () { - // TODO: Aprire la Dialog Contenuti complessa + icon: Icons.movie_filter_outlined, + color: Colors.purple, + onTap: () async { + final result = + await showDialog>( + context: context, + builder: (context) => EntertainmentServiceDialog( + initialServices: service.entertainmentServices, + currentStoreId: service.storeId, + ), + ); + + if (result != null && context.mounted) { + context + .read() + .updateEntertainmentServices(result); + } }, ), ], diff --git a/lib/features/services/ui/services_screen.dart b/lib/features/services/ui/services_screen.dart index cdee646..4f85e26 100644 --- a/lib/features/services/ui/services_screen.dart +++ b/lib/features/services/ui/services_screen.dart @@ -21,6 +21,8 @@ class _ServicesScreenState extends State { super.initState(); // Agganciamo il listener per la paginazione (Scroll Infinito) _scrollController.addListener(_onScroll); + // Carichiamo i servizi iniziali + context.read().loadServices(); } void _onScroll() { @@ -61,7 +63,8 @@ class _ServicesScreenState extends State { body: BlocBuilder( builder: (context, state) { // 1. Stato di caricamento iniziale - if (state.isLoading && state.allServices.isEmpty) { + if (state.status == ServicesStatus.loading && + state.allServices.isEmpty) { return const Center(child: CircularProgressIndicator()); } @@ -172,7 +175,10 @@ class _ServicesScreenState extends State { ], ), trailing: const Icon(Icons.chevron_right), - onTap: () => context.pushNamed('service-form', extra: service), + onTap: () => context.pushNamed( + 'service-form', + queryParameters: {'serviceId': service.id}, + ), ), ); } diff --git a/lib/features/services/utils/service_actions.dart b/lib/features/services/utils/service_actions.dart index a5c6e6e..4a51388 100644 --- a/lib/features/services/utils/service_actions.dart +++ b/lib/features/services/utils/service_actions.dart @@ -54,11 +54,12 @@ void startNewService(BuildContext context) { onTap: () { // 1. Inizializza il form nel Cubit context.read().initServiceForm( - ServiceModel( + existingService: ServiceModel( storeId: currentStoreId, employeeId: member.id, number: '', createdAt: DateTime.now(), + companyId: session.company!.id, ), ); diff --git a/lib/main.dart b/lib/main.dart index 0fc03ea..9b0ff8b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -95,17 +95,52 @@ class _FluxAppState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { - return MaterialApp.router( - title: 'FLUX Gestionale', - debugShowCheckedModeBanner: false, - theme: fluxLightTheme, - darkTheme: fluxDarkTheme, - themeMode: state.currentTheme.themeMode, - routerConfig: _router, // Usa l'istanza mantenuta nello stato + if (state.status == SessionStatus.unknown) { + return _buildLoadingScreen(); + } + return BlocBuilder( + builder: (context, state) { + return MaterialApp.router( + title: 'FLUX Gestionale', + debugShowCheckedModeBanner: false, + theme: fluxLightTheme, + darkTheme: fluxDarkTheme, + themeMode: state.currentTheme.themeMode, + routerConfig: _router, // Usa l'istanza mantenuta nello stato + ); + }, ); }, ); } + + // Una semplice schermata di caricamento coerente con il brand + Widget _buildLoadingScreen() { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Qui puoi mettere il tuo logo + const Icon(Icons.bolt, size: 64, color: Colors.blue), + const SizedBox(height: 24), + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text( + "Inizializzazione sessione...", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/pubspec.lock b/pubspec.lock index e59aba5..646d1b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -90,7 +90,7 @@ packages: source: hosted version: "1.0.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" diff --git a/pubspec.yaml b/pubspec.yaml index 7c35a07..53fca7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: ^3.11.3 dependencies: + collection: ^1.19.1 equatable: ^2.0.8 file_picker: ^11.0.2 flutter: