feat-insert-service #5

Merged
brontomark merged 11 commits from feat-insert-service into main 2026-04-20 16:52:20 +02:00
16 changed files with 665 additions and 96 deletions
Showing only changes of commit e9f3327f31 - Show all commits

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.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/core/blocs/session/session_bloc.dart';
import 'package:flux/features/auth/ui/auth_screen.dart'; import 'package:flux/features/auth/ui/auth_screen.dart';
import 'package:flux/features/company/ui/create_company_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/home/ui/home_screen.dart';
import 'package:flux/features/master_data/products/ui/products_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/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: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 'package:go_router/go_router.dart';
import 'dart:async'; import 'dart:async';
@@ -79,6 +83,13 @@ class AppRouter {
path: '/service-form', path: '/service-form',
name: 'service-form', name: 'service-form',
builder: (context, state) { 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<ServicesCubit>().initServiceForm(
serviceId: serviceId,
);
}
return ServiceFormScreen(); return ServiceFormScreen();
}, },
), ),

View File

@@ -1,6 +1,7 @@
import 'dart:async'; // Serve per il Timer del debounce import 'dart:async'; // Serve per il Timer del debounce
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.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/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
@@ -9,6 +10,7 @@ part 'customer_state.dart';
class CustomerCubit extends Cubit<CustomerState> { class CustomerCubit extends Cubit<CustomerState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>(); final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
// Variabile per gestire il debounce della ricerca // Variabile per gestire il debounce della ricerca
Timer? _searchDebounce; Timer? _searchDebounce;
@@ -16,10 +18,12 @@ class CustomerCubit extends Cubit<CustomerState> {
CustomerCubit() : super(const CustomerState()); CustomerCubit() : super(const CustomerState());
// --- LETTURA --- // --- LETTURA ---
Future<void> loadCustomers(String companyId) async { Future<void> loadCustomers() async {
emit(state.copyWith(status: CustomerStatus.loading)); emit(state.copyWith(status: CustomerStatus.loading));
try { try {
final customers = await _repository.getCustomers(companyId); final customers = await _repository.getCustomers(
_sessionBloc.state.company!.id,
);
emit( emit(
state.copyWith(status: CustomerStatus.success, customers: customers), state.copyWith(status: CustomerStatus.success, customers: customers),
); );
@@ -92,21 +96,24 @@ class CustomerCubit extends Cubit<CustomerState> {
} }
// --- RICERCA CON DEBOUNCE --- // --- 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 // 1. Se c'è già una ricerca in attesa (l'utente sta digitando veloce), la annulliamo
if (_searchDebounce?.isActive ?? false) _searchDebounce!.cancel(); if (_searchDebounce?.isActive ?? false) _searchDebounce!.cancel();
// 2. Facciamo partire un timer di 400 millisecondi // 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 // Se cancella tutto e la query è vuota, ricarichiamo la lista base
if (query.trim().isEmpty) { if (query.trim().isEmpty) {
await loadCustomers(companyId); await loadCustomers();
return; return;
} }
// Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive // Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive
try { try {
final results = await _repository.searchCustomers(companyId, query); final results = await _repository.searchCustomers(
_sessionBloc.state.company!.id,
query,
);
emit( emit(
state.copyWith(status: CustomerStatus.success, customers: results), state.copyWith(status: CustomerStatus.success, customers: results),
); );

View File

@@ -16,9 +16,7 @@ class _CustomerSearchSheetState extends State<CustomerSearchSheet> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Opzionale ma consigliato: carica i clienti recenti appena si apre la modale, context.read<CustomerCubit>().loadCustomers();
// così l'utente non vede una schermata vuota prima di cercare.
// context.read<CustomersCubit>().loadCustomers(query: '');
} }
@override @override
@@ -28,10 +26,7 @@ class _CustomerSearchSheetState extends State<CustomerSearchSheet> {
} }
void _onSearchChanged(String query) { void _onSearchChanged(String query) {
// Comunichiamo al Cubit dei clienti di fare la query su Supabase context.read<CustomerCubit>().searchCustomers(query);
// (Consiglio Pro: nel Cubit, metti un "debounce" di 300ms su questa chiamata
// per non bombardare Supabase a ogni singola lettera digitata!)
// context.read<CustomersCubit>().searchCustomers(query);
} }
@override @override

View File

@@ -26,14 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
void _loadInitialCustomers() { void _loadInitialCustomers() {
final companyId = context.read<SessionBloc>().state.company?.id; final companyId = context.read<SessionBloc>().state.company?.id;
if (companyId != null) { if (companyId != null) {
context.read<CustomerCubit>().loadCustomers(companyId); context.read<CustomerCubit>().loadCustomers();
} }
} }
void _onSearch(String query) { void _onSearch(String query) {
final companyId = context.read<SessionBloc>().state.company?.id; final companyId = context.read<SessionBloc>().state.company?.id;
if (companyId != null) { if (companyId != null) {
context.read<CustomerCubit>().searchCustomers(companyId, query); context.read<CustomerCubit>().searchCustomers( query);
} }
} }

View File

@@ -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/fin_service_model.dart';
import 'package:flux/features/services/models/service_model.dart'; import 'package:flux/features/services/models/service_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:collection/collection.dart';
part 'services_state.dart'; part 'services_state.dart';
class ServicesCubit extends Cubit<ServicesState> { class ServicesCubit extends Cubit<ServicesState> {
final ServicesRepository _repository = GetIt.I<ServicesRepository>(); final ServicesRepository _repository = GetIt.I<ServicesRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>(); final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
ServicesCubit() : super(const ServicesState()); ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial));
// --- CARICAMENTO E PAGINAZIONE --- // --- CARICAMENTO E PAGINAZIONE ---
Future<void> loadServices({bool refresh = false}) async { Future<void> loadServices({bool refresh = false}) async {
// Se stiamo già caricando, evitiamo chiamate doppie // 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 // Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo
if (!refresh && state.hasReachedMax) return; if (!refresh && state.hasReachedMax) return;
emit( emit(
state.copyWith( state.copyWith(
isLoading: true, status: ServicesStatus.loading,
errorMessage: null, errorMessage: null,
// Se è un refresh, svuotiamo la lista attuale per mostrare lo shimmer/loading // Se è un refresh, svuotiamo la lista attuale per mostrare lo shimmer/loading
allServices: refresh ? [] : state.allServices, allServices: refresh ? [] : state.allServices,
@@ -56,7 +57,7 @@ class ServicesCubit extends Cubit<ServicesState> {
emit( emit(
state.copyWith( state.copyWith(
isLoading: false, status: ServicesStatus.ready,
allServices: refresh allServices: refresh
? newServices ? newServices
: [...state.allServices, ...newServices], : [...state.allServices, ...newServices],
@@ -66,7 +67,7 @@ class ServicesCubit extends Cubit<ServicesState> {
} catch (e) { } catch (e) {
emit( emit(
state.copyWith( state.copyWith(
isLoading: false, status: ServicesStatus.failure,
errorMessage: "Errore nel caricamento servizi: $e", errorMessage: "Errore nel caricamento servizi: $e",
), ),
); );
@@ -95,9 +96,28 @@ class ServicesCubit extends Cubit<ServicesState> {
// --- GESTIONE BOZZA (DRAFT) --- // --- GESTIONE BOZZA (DRAFT) ---
/// Inizializza un nuovo servizio o ne carica uno esistente per la modifica /// 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) { 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 { } else {
// Crea un template vuoto con lo store di default (se disponibile) // Crea un template vuoto con lo store di default (se disponibile)
emit( emit(
@@ -106,7 +126,9 @@ class ServicesCubit extends Cubit<ServicesState> {
storeId: _sessionBloc.state.selectedStore?.id ?? '', storeId: _sessionBloc.state.selectedStore?.id ?? '',
number: '', // Sarà compilato dall'utente number: '', // Sarà compilato dall'utente
createdAt: DateTime.now(), createdAt: DateTime.now(),
companyId: _sessionBloc.state.company!.id,
), ),
status: ServicesStatus.ready,
), ),
); );
} }
@@ -180,16 +202,22 @@ class ServicesCubit extends Cubit<ServicesState> {
Future<void> saveCurrentService() async { Future<void> saveCurrentService() async {
if (state.currentService == null) return; if (state.currentService == null) return;
emit(state.copyWith(isSaving: true, errorMessage: null)); emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null));
try { try {
// Usiamo il repository corazzato che abbiamo scritto prima // Usiamo il repository corazzato che abbiamo scritto prima
await _repository.saveFullService(state.currentService!); await _repository.saveFullService(state.currentService!);
// Reset della bozza e ricaricamento lista
emit(state.copyWith(isSaving: false, currentService: null));
await loadServices(refresh: true); await loadServices(refresh: true);
// Reset della bozza e ricaricamento lista
emit(state.copyWith(status: ServicesStatus.saved, currentService: null));
} catch (e) { } catch (e) {
emit(state.copyWith(isSaving: false, errorMessage: e.toString())); emit(
state.copyWith(
status: ServicesStatus.failure,
errorMessage: e.toString(),
),
);
} }
} }
} }

View File

@@ -1,20 +1,20 @@
part of 'services_cubit.dart'; part of 'services_cubit.dart';
enum ServicesStatus { initial, loading, ready, saving, saved, success, failure }
class ServicesState extends Equatable { class ServicesState extends Equatable {
final ServicesStatus status;
final List<ServiceModel> allServices; final List<ServiceModel> allServices;
final ServiceModel? currentService; // La bozza che stiamo editando 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? errorMessage;
final String query; final String query;
final DateTimeRange? dateRange; final DateTimeRange? dateRange;
final bool hasReachedMax; final bool hasReachedMax;
const ServicesState({ const ServicesState({
required this.status,
this.allServices = const [], this.allServices = const [],
this.currentService, this.currentService,
this.isLoading = false,
this.isSaving = false,
this.errorMessage, this.errorMessage,
this.query = '', this.query = '',
this.dateRange, this.dateRange,
@@ -22,20 +22,18 @@ class ServicesState extends Equatable {
}); });
ServicesState copyWith({ ServicesState copyWith({
ServicesStatus? status,
List<ServiceModel>? allServices, List<ServiceModel>? allServices,
ServiceModel? currentService, ServiceModel? currentService,
bool? isLoading,
bool? isSaving,
String? errorMessage, String? errorMessage,
String? query, String? query,
DateTimeRange? dateRange, DateTimeRange? dateRange,
bool? hasReachedMax, bool? hasReachedMax,
}) { }) {
return ServicesState( return ServicesState(
status: status ?? this.status,
allServices: allServices ?? this.allServices, allServices: allServices ?? this.allServices,
currentService: currentService ?? this.currentService, currentService: currentService ?? this.currentService,
isLoading: isLoading ?? this.isLoading,
isSaving: isSaving ?? this.isSaving,
errorMessage: errorMessage, errorMessage: errorMessage,
query: query ?? this.query, query: query ?? this.query,
dateRange: dateRange ?? this.dateRange, dateRange: dateRange ?? this.dateRange,
@@ -45,10 +43,9 @@ class ServicesState extends Equatable {
@override @override
List<Object?> get props => [ List<Object?> get props => [
status,
allServices, allServices,
currentService, currentService,
isLoading,
isSaving,
errorMessage, errorMessage,
query, query,
dateRange, dateRange,

View File

@@ -5,6 +5,27 @@ import '../models/service_model.dart';
class ServicesRepository { class ServicesRepository {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
Future<ServiceModel> 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 --- // --- RECUPERO PAGINATO CON FILTRI E JOIN ---
Future<List<ServiceModel>> fetchServices({ Future<List<ServiceModel>> fetchServices({
required String companyId, required String companyId,
@@ -19,7 +40,7 @@ class ServicesRepository {
.from('service') .from('service')
.select(''' .select('''
*, *,
customer(name, surname), customer(nome),
energy_service(*), energy_service(*),
fin_service(*), fin_service(*),
entertainment_service(*) entertainment_service(*)
@@ -36,7 +57,7 @@ class ServicesRepository {
if (searchTerm != null && searchTerm.isNotEmpty) { if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata // Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or( 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'); throw Exception('Errore durante l\'eliminazione: $e');
} }
} }
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
Future<List<String>> 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<String, int> 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
}
}
} }

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; 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/energy_service_model.dart';
import 'package:flux/features/services/models/entertainment_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/fin_service_model.dart';
@@ -14,6 +15,7 @@ class ServiceModel extends Equatable {
final String note; final String note;
final bool resultOk; final bool resultOk;
final String? customerDisplayName; final String? customerDisplayName;
final String companyId;
// Telefonia // Telefonia
final int al; final int al;
@@ -46,6 +48,7 @@ class ServiceModel extends Equatable {
this.finServices = const [], this.finServices = const [],
this.entertainmentServices = const [], this.entertainmentServices = const [],
this.customerDisplayName, this.customerDisplayName,
required this.companyId,
}); });
ServiceModel copyWith({ ServiceModel copyWith({
@@ -67,6 +70,7 @@ class ServiceModel extends Equatable {
List<FinServiceModel>? finServices, List<FinServiceModel>? finServices,
List<EntertainmentServiceModel>? entertainmentServices, List<EntertainmentServiceModel>? entertainmentServices,
String? customerDisplayName, String? customerDisplayName,
String? companyId,
}) { }) {
return ServiceModel( return ServiceModel(
id: id ?? this.id, id: id ?? this.id,
@@ -88,6 +92,7 @@ class ServiceModel extends Equatable {
entertainmentServices: entertainmentServices:
entertainmentServices ?? this.entertainmentServices, entertainmentServices ?? this.entertainmentServices,
customerDisplayName: customerDisplayName ?? this.customerDisplayName, customerDisplayName: customerDisplayName ?? this.customerDisplayName,
companyId: companyId ?? this.companyId,
); );
} }
@@ -111,6 +116,7 @@ class ServiceModel extends Equatable {
finServices, finServices,
entertainmentServices, entertainmentServices,
customerDisplayName, customerDisplayName,
companyId,
]; ];
factory ServiceModel.fromMap(Map<String, dynamic> map) { factory ServiceModel.fromMap(Map<String, dynamic> map) {
@@ -151,9 +157,9 @@ class ServiceModel extends Equatable {
// Display name del cliente con fallback // Display name del cliente con fallback
customerDisplayName: map['customer'] != null customerDisplayName: map['customer'] != null
? "${map['customer']['name'] ?? ''} ${map['customer']['surname'] ?? ''}" ? "${map['customer']['nome'] ?? ''}".myFormat()
.trim()
: "Cliente non assegnato", : "Cliente non assegnato",
companyId: map['company_id'] as String,
); );
} }
@@ -172,6 +178,7 @@ class ServiceModel extends Equatable {
'nip': nip, 'nip': nip,
'unica': unica, 'unica': unica,
'telepass': telepass, 'telepass': telepass,
'company_id': companyId,
// Le liste non le mettiamo qui perché vanno in tabelle diverse! // Le liste non le mettiamo qui perché vanno in tabelle diverse!
}; };
} }

View File

@@ -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<EntertainmentServiceModel> initialServices;
final String currentStoreId;
const EntertainmentServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
});
@override
State<EntertainmentServiceDialog> createState() =>
_EntertainmentServiceDialogState();
}
class _EntertainmentServiceDialogState
extends State<EntertainmentServiceDialog> {
late List<EntertainmentServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Carichiamo i provider attivi per lo store corrente
context.read<ProvidersCubit>().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<ProvidersCubit, ProvidersState>(
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<EntertainmentServiceModel> services;
final List<ProviderModel> 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<void> _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<ProvidersCubit, ProvidersState>(
builder: (context, state) {
final filtered = state.activeProviders
.where((p) => p.intrattenimento)
.toList();
return DropdownButtonFormField<String>(
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<List<String>>(
future: GetIt.I<ServicesRepository>().fetchTopEntertainmentTypes(
GetIt.I<SessionBloc>().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<int?>(
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"),
),
],
),
],
);
}
}

View File

@@ -10,45 +10,67 @@ class ServiceFormScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return BlocListener<ServicesCubit, ServicesState>(
appBar: AppBar( listener: (context, state) {
title: const Text("Nuova Pratica"), if (state.status == ServicesStatus.saved) {
actions: [ ScaffoldMessenger.of(context).showSnackBar(
_SaveButton(), // Tasto salva intelligente const SnackBar(
], content: Text("Pratica salvata con successo!"),
), backgroundColor: Colors.green,
body: BlocBuilder<ServicesCubit, ServicesState>(
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(),
],
), ),
); );
}, 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<ServicesCubit, ServicesState>(
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) { Widget build(BuildContext context) {
return BlocBuilder<ServicesCubit, ServicesState>( return BlocBuilder<ServicesCubit, ServicesState>(
builder: (context, state) { builder: (context, state) {
if (state.isSaving) { if (state.status == ServicesStatus.saving) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: SizedBox( child: SizedBox(
@@ -78,7 +100,6 @@ class _SaveButton extends StatelessWidget {
icon: const Icon(Icons.save), icon: const Icon(Icons.save),
tooltip: "Salva Pratica", tooltip: "Salva Pratica",
onPressed: () { onPressed: () {
// TODO: Aggiungere una validazione prima di salvare!
context.read<ServicesCubit>().saveCurrentService(); context.read<ServicesCubit>().saveCurrentService();
}, },
); );

View File

@@ -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/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/services/blocs/services_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/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/fin_service_model.dart';
import 'package:flux/features/services/models/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/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/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/finance_service_dialog.dart';
import 'package:flux/features/services/ui/service_form_screen/int_dialogs.dart'; // Assicurati di importare il modello 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( ActionCard(
label: "Contenuti", label: "Intratten.",
count: service.entertainmentServices.length, count: service.entertainmentServices.length,
icon: Icons.tv, icon: Icons.movie_filter_outlined,
color: Colors.redAccent, color: Colors.purple,
onTap: () { onTap: () async {
// TODO: Aprire la Dialog Contenuti complessa final result =
await showDialog<List<EntertainmentServiceModel>>(
context: context,
builder: (context) => EntertainmentServiceDialog(
initialServices: service.entertainmentServices,
currentStoreId: service.storeId,
),
);
if (result != null && context.mounted) {
context
.read<ServicesCubit>()
.updateEntertainmentServices(result);
}
}, },
), ),
], ],

View File

@@ -21,6 +21,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
super.initState(); super.initState();
// Agganciamo il listener per la paginazione (Scroll Infinito) // Agganciamo il listener per la paginazione (Scroll Infinito)
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
// Carichiamo i servizi iniziali
context.read<ServicesCubit>().loadServices();
} }
void _onScroll() { void _onScroll() {
@@ -61,7 +63,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
body: BlocBuilder<ServicesCubit, ServicesState>( body: BlocBuilder<ServicesCubit, ServicesState>(
builder: (context, state) { builder: (context, state) {
// 1. Stato di caricamento iniziale // 1. Stato di caricamento iniziale
if (state.isLoading && state.allServices.isEmpty) { if (state.status == ServicesStatus.loading &&
state.allServices.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -172,7 +175,10 @@ class _ServicesScreenState extends State<ServicesScreen> {
], ],
), ),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => context.pushNamed('service-form', extra: service), onTap: () => context.pushNamed(
'service-form',
queryParameters: {'serviceId': service.id},
),
), ),
); );
} }

View File

@@ -54,11 +54,12 @@ void startNewService(BuildContext context) {
onTap: () { onTap: () {
// 1. Inizializza il form nel Cubit // 1. Inizializza il form nel Cubit
context.read<ServicesCubit>().initServiceForm( context.read<ServicesCubit>().initServiceForm(
ServiceModel( existingService: ServiceModel(
storeId: currentStoreId, storeId: currentStoreId,
employeeId: member.id, employeeId: member.id,
number: '', number: '',
createdAt: DateTime.now(), createdAt: DateTime.now(),
companyId: session.company!.id,
), ),
); );

View File

@@ -95,17 +95,52 @@ class _FluxAppState extends State<FluxApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<ThemeBloc, ThemeState>( return BlocBuilder<SessionBloc, SessionState>(
builder: (context, state) { builder: (context, state) {
return MaterialApp.router( if (state.status == SessionStatus.unknown) {
title: 'FLUX Gestionale', return _buildLoadingScreen();
debugShowCheckedModeBanner: false, }
theme: fluxLightTheme, return BlocBuilder<ThemeBloc, ThemeState>(
darkTheme: fluxDarkTheme, builder: (context, state) {
themeMode: state.currentTheme.themeMode, return MaterialApp.router(
routerConfig: _router, // Usa l'istanza mantenuta nello stato 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,
),
),
],
),
),
),
);
}
} }

View File

@@ -90,7 +90,7 @@ packages:
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
collection: collection:
dependency: transitive dependency: "direct main"
description: description:
name: collection name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"

View File

@@ -7,6 +7,7 @@ environment:
sdk: ^3.11.3 sdk: ^3.11.3
dependencies: dependencies:
collection: ^1.19.1
equatable: ^2.0.8 equatable: ^2.0.8
file_picker: ^11.0.2 file_picker: ^11.0.2
flutter: flutter: