ottimo punto sembra funzionare tutto, devo solo aggiungere l'aggiunta di un cliente volante, di un modello volante e gestire i file allegati
This commit is contained in:
@@ -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<ServicesCubit>().initServiceForm(
|
||||
serviceId: serviceId,
|
||||
);
|
||||
}
|
||||
return ServiceFormScreen();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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<CustomerState> {
|
||||
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
|
||||
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
|
||||
|
||||
// Variabile per gestire il debounce della ricerca
|
||||
Timer? _searchDebounce;
|
||||
@@ -16,10 +18,12 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
CustomerCubit() : super(const CustomerState());
|
||||
|
||||
// --- LETTURA ---
|
||||
Future<void> loadCustomers(String companyId) async {
|
||||
Future<void> 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<CustomerState> {
|
||||
}
|
||||
|
||||
// --- 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),
|
||||
);
|
||||
|
||||
@@ -16,9 +16,7 @@ class _CustomerSearchSheetState extends State<CustomerSearchSheet> {
|
||||
@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<CustomersCubit>().loadCustomers(query: '');
|
||||
context.read<CustomerCubit>().loadCustomers();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -28,10 +26,7 @@ class _CustomerSearchSheetState extends State<CustomerSearchSheet> {
|
||||
}
|
||||
|
||||
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<CustomersCubit>().searchCustomers(query);
|
||||
context.read<CustomerCubit>().searchCustomers(query);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -26,14 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
void _loadInitialCustomers() {
|
||||
final companyId = context.read<SessionBloc>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomerCubit>().loadCustomers(companyId);
|
||||
context.read<CustomerCubit>().loadCustomers();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearch(String query) {
|
||||
final companyId = context.read<SessionBloc>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomerCubit>().searchCustomers(companyId, query);
|
||||
context.read<CustomerCubit>().searchCustomers( query);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ServicesState> {
|
||||
final ServicesRepository _repository = GetIt.I<ServicesRepository>();
|
||||
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
|
||||
|
||||
ServicesCubit() : super(const ServicesState());
|
||||
ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial));
|
||||
|
||||
// --- CARICAMENTO E PAGINAZIONE ---
|
||||
|
||||
Future<void> 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<ServicesState> {
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
status: ServicesStatus.ready,
|
||||
allServices: refresh
|
||||
? newServices
|
||||
: [...state.allServices, ...newServices],
|
||||
@@ -66,7 +67,7 @@ class ServicesCubit extends Cubit<ServicesState> {
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage: "Errore nel caricamento servizi: $e",
|
||||
),
|
||||
);
|
||||
@@ -95,9 +96,28 @@ class ServicesCubit extends Cubit<ServicesState> {
|
||||
// --- 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<ServicesState> {
|
||||
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<ServicesState> {
|
||||
Future<void> 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ServiceModel> 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<ServiceModel>? 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<Object?> get props => [
|
||||
status,
|
||||
allServices,
|
||||
currentService,
|
||||
isLoading,
|
||||
isSaving,
|
||||
errorMessage,
|
||||
query,
|
||||
dateRange,
|
||||
|
||||
@@ -5,6 +5,27 @@ import '../models/service_model.dart';
|
||||
class ServicesRepository {
|
||||
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 ---
|
||||
Future<List<ServiceModel>> 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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FinServiceModel>? finServices,
|
||||
List<EntertainmentServiceModel>? 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<String, dynamic> 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!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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(),
|
||||
],
|
||||
return BlocListener<ServicesCubit, ServicesState>(
|
||||
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<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) {
|
||||
return BlocBuilder<ServicesCubit, ServicesState>(
|
||||
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<ServicesCubit>().saveCurrentService();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<List<EntertainmentServiceModel>>(
|
||||
context: context,
|
||||
builder: (context) => EntertainmentServiceDialog(
|
||||
initialServices: service.entertainmentServices,
|
||||
currentStoreId: service.storeId,
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
context
|
||||
.read<ServicesCubit>()
|
||||
.updateEntertainmentServices(result);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -21,6 +21,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
super.initState();
|
||||
// Agganciamo il listener per la paginazione (Scroll Infinito)
|
||||
_scrollController.addListener(_onScroll);
|
||||
// Carichiamo i servizi iniziali
|
||||
context.read<ServicesCubit>().loadServices();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
@@ -61,7 +63,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
body: BlocBuilder<ServicesCubit, ServicesState>(
|
||||
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<ServicesScreen> {
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.pushNamed('service-form', extra: service),
|
||||
onTap: () => context.pushNamed(
|
||||
'service-form',
|
||||
queryParameters: {'serviceId': service.id},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,11 +54,12 @@ void startNewService(BuildContext context) {
|
||||
onTap: () {
|
||||
// 1. Inizializza il form nel Cubit
|
||||
context.read<ServicesCubit>().initServiceForm(
|
||||
ServiceModel(
|
||||
existingService: ServiceModel(
|
||||
storeId: currentStoreId,
|
||||
employeeId: member.id,
|
||||
number: '',
|
||||
createdAt: DateTime.now(),
|
||||
companyId: session.company!.id,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -95,17 +95,52 @@ class _FluxAppState extends State<FluxApp> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ThemeBloc, ThemeState>(
|
||||
return BlocBuilder<SessionBloc, SessionState>(
|
||||
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<ThemeBloc, ThemeState>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user