Reviewed-on: http://catelliub.zapto.org:3000/brontomark/flux/pulls/2
Co-authored-by: Mark M2 Macbook <marco@catelli.it>
Co-committed-by: Mark M2 Macbook <marco@catelli.it>
This commit is contained in:
2026-04-16 11:50:29 +02:00
committed by brontomark
parent 753b5489b6
commit 5229571fa1
14 changed files with 1313 additions and 14 deletions

View File

@@ -7,6 +7,8 @@ import 'package:flux/features/customers/ui/customer_detail_screen.dart';
import 'package:flux/features/home/ui/home_screen.dart';
import 'package:flux/features/master_data/products/ui/products_screen.dart';
import 'package:flux/features/master_data/store/ui/create_store_screen.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:flux/features/services/ui/service_form_screen.dart';
import 'package:go_router/go_router.dart';
import 'dart:async';
@@ -74,6 +76,15 @@ class AppRouter {
name: 'products',
builder: (context, state) => const ProductsScreen(),
),
GoRoute(
path: '/service-form',
name: 'service-form',
builder: (context, state) {
// Recuperiamo il ServiceModel se passato come extra
final service = state.extra as ServiceModel?;
return ServiceFormScreen(initialService: service);
},
),
],
);
}

View File

@@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/master_data/master_data_hub_content.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/ui/services_screen.dart';
import 'dashboard_content.dart'; // Importiamo il contenuto della dashboard
class HomeScreen extends StatefulWidget {
@@ -16,6 +18,16 @@ class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 0;
bool _extendRailway = false;
@override
void initState() {
super.initState();
// Caricamento "silenzioso" all'avvio dell'app
// Usiamo WidgetsBinding per assicurarci che il contesto sia pronto
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ServicesCubit>().loadServices();
});
}
@override
Widget build(BuildContext context) {
return BlocBuilder<SessionBloc, SessionState>(
@@ -63,7 +75,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
BottomNavigationBarItem(
icon: Icon(Icons.receipt_long),
label: 'Operazioni',
label: 'Servizi',
),
BottomNavigationBarItem(
icon: Icon(Icons.folder_shared),
@@ -111,7 +123,7 @@ class _HomeScreenState extends State<HomeScreen> {
NavigationRailDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: Text('Operazioni'),
label: Text('Servizi'),
),
NavigationRailDestination(
icon: Icon(Icons.folder_shared_outlined),
@@ -148,17 +160,18 @@ class _HomeScreenState extends State<HomeScreen> {
// Switch tra le sottopagine
Widget _buildPageContent(int index, bool isLargeScreen) {
switch (index) {
case 0:
return DashboardContent(
return IndexedStack(
index: index,
children: [
DashboardContent(
isLargeScreen: isLargeScreen,
onTabRequested: (idx) => setState(() => _selectedIndex = 2),
);
case 1:
return const Center(child: Text('Operazioni'));
case 2:
),
ServicesScreen(),
// L'unico punto di ingresso per tutte le anagrafiche
return MasterDataHubContent(
MasterDataHubContent(
// Qui gestiamo la navigazione "interna" all'hub
onOpenPage: (widget) {
Navigator.push(
@@ -166,9 +179,8 @@ class _HomeScreenState extends State<HomeScreen> {
MaterialPageRoute(builder: (context) => widget),
);
},
);
default:
return DashboardContent(isLargeScreen: isLargeScreen);
}
),
],
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/providers/data/provider_repository.dart';
import 'package:get_it/get_it.dart';
import '../models/provider_model.dart';
class ProvidersState extends Equatable {
final List<ProviderModel> allProviders; // Tutti i provider della company
final List<String>
associatedIds; // ID dei provider attivi nello store selezionato
final bool isLoading;
final String? errorMessage;
const ProvidersState({
this.allProviders = const [],
this.associatedIds = const [],
this.isLoading = false,
this.errorMessage,
});
ProvidersState copyWith({
List<ProviderModel>? allProviders,
List<String>? associatedIds,
bool? isLoading,
String? errorMessage,
}) {
return ProvidersState(
allProviders: allProviders ?? this.allProviders,
associatedIds: associatedIds ?? this.associatedIds,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [
allProviders,
associatedIds,
isLoading,
errorMessage,
];
}
class ProvidersCubit extends Cubit<ProvidersState> {
final ProviderRepository _repository = GetIt.I<ProviderRepository>();
ProvidersCubit() : super(const ProvidersState());
// Carica i provider della company e quelli associati a uno store specifico
Future<void> loadProviders(String companyId, String? storeId) async {
emit(state.copyWith(isLoading: true));
try {
final all = await _repository.fetchAllCompanyProviders(companyId);
List<String> associated = [];
if (storeId != null) {
associated = await _repository.fetchAssociatedProviderIds(storeId);
}
emit(
state.copyWith(
allProviders: all,
associatedIds: associated,
isLoading: false,
),
);
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}
// Aggiunge o rimuove l'associazione con lo store
Future<void> toggleProviderAssociation({
required String providerId,
required String storeId,
required bool isCurrentlyAssociated,
}) async {
try {
if (isCurrentlyAssociated) {
await _repository.disassociateProviderFromStore(
providerId: providerId,
storeId: storeId,
);
// Aggiorniamo lo stato locale rimuovendo l'ID
final newIds = List<String>.from(state.associatedIds)
..remove(providerId);
emit(state.copyWith(associatedIds: newIds));
} else {
await _repository.associateProviderToStore(
providerId: providerId,
storeId: storeId,
);
// Aggiorniamo lo stato locale aggiungendo l'ID
final newIds = List<String>.from(state.associatedIds)..add(providerId);
emit(state.copyWith(associatedIds: newIds));
}
} catch (e) {
emit(state.copyWith(errorMessage: "Errore durante l'aggiornamento: $e"));
}
}
// Salvataggio/Update anagrafica (nuovo o modifica)
Future<void> saveProvider(ProviderModel provider) async {
emit(state.copyWith(isLoading: true));
try {
await _repository.saveProvider(provider);
// Ricarichiamo la lista per vedere le modifiche
await loadProviders(provider.companyId, null);
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}
}

View File

@@ -0,0 +1,93 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/provider_model.dart';
class ProviderRepository {
final _supabase = Supabase.instance.client;
// --- ASSOCIAZIONE PROVIDER <-> STORE ---
// Aggiunge un provider a un negozio (Attiva mandato)
Future<void> associateProviderToStore({
required String providerId,
required String storeId,
}) async {
try {
await _supabase.from('providers_in_stores').insert({
'provider_id': providerId,
'store_id': storeId,
});
} catch (e) {
throw Exception('Errore durante l\'associazione provider: $e');
}
}
// Rimuove un provider da un negozio (Disattiva mandato)
Future<void> disassociateProviderFromStore({
required String providerId,
required String storeId,
}) async {
try {
await _supabase
.from('providers_in_stores')
.delete()
.eq('provider_id', providerId)
.eq('store_id', storeId);
} catch (e) {
throw Exception('Errore durante la disassociazione provider: $e');
}
}
// Recupera tutti i provider di una company (per la lista generale)
Future<List<ProviderModel>> fetchAllCompanyProviders(String companyId) async {
try {
final response = await _supabase
.from('provider')
.select()
.eq('company_id', companyId);
return (response as List).map((m) => ProviderModel.fromMap(m)).toList();
} catch (e) {
throw Exception('Errore fetch provider: $e');
}
}
// Recupera gli ID dei provider associati a uno store (utile per le checkbox)
Future<List<String>> fetchAssociatedProviderIds(String storeId) async {
try {
final response = await _supabase
.from('providers_in_stores')
.select('provider_id')
.eq('store_id', storeId);
return (response as List)
.map((item) => item['provider_id'].toString())
.toList();
} catch (e) {
throw Exception('Errore recupero ID associati: $e');
}
}
// --- FUNZIONI STANDARD ---
// Questa la userai nel Form Servizi: carica solo i provider abilitati per lo store
Future<List<ProviderModel>> fetchActiveProvidersForStore(
String storeId,
) async {
try {
final response = await _supabase
.from('provider')
.select('*, providers_in_stores!inner(store_id)')
.eq('providers_in_stores.store_id', storeId)
.eq('is_active', true);
return (response as List).map((m) => ProviderModel.fromMap(m)).toList();
} catch (e) {
throw Exception('Errore fetch provider attivi: $e');
}
}
// Salva o aggiorna l'anagrafica del Provider
Future<void> saveProvider(ProviderModel provider) async {
await _supabase.from('provider').upsert(provider.toMap());
}
}

View File

@@ -0,0 +1,96 @@
import 'package:equatable/equatable.dart';
class ProviderModel extends Equatable {
final String id;
final String nome;
final bool telefoniaFissa;
final bool telefoniaMobile;
final bool energia;
final bool assicurazioni;
final bool intrattenimento;
final bool altro;
final bool isActive;
final String companyId;
const ProviderModel({
required this.id,
required this.nome,
required this.telefoniaFissa,
required this.telefoniaMobile,
required this.energia,
required this.assicurazioni,
required this.intrattenimento,
required this.altro,
required this.isActive,
required this.companyId,
});
factory ProviderModel.fromMap(Map<String, dynamic> map) {
return ProviderModel(
id: map['id'],
nome: map['nome'],
telefoniaFissa: map['telefonia_fissa'] ?? false,
telefoniaMobile: map['telefonia_mobile'] ?? false,
energia: map['energia'] ?? false,
assicurazioni: map['assicurazioni'] ?? false,
intrattenimento: map['intrattenimento'] ?? false,
altro: map['altro'] ?? false,
isActive: map['is_active'] ?? true,
companyId: map['company_id'],
);
}
Map<String, dynamic> toMap() {
return {
'nome': nome,
'telefonia_fissa': telefoniaFissa,
'telefonia_mobile': telefoniaMobile,
'energia': energia,
'assicurazioni': assicurazioni,
'intrattenimento': intrattenimento,
'altro': altro,
'is_active': isActive,
'company_id': companyId,
};
}
@override
List<Object?> get props => [
id,
nome,
telefoniaFissa,
telefoniaMobile,
energia,
assicurazioni,
intrattenimento,
altro,
isActive,
companyId,
];
ProviderModel copyWith({
String? id,
String? nome,
bool? telefoniaFissa,
bool? telefoniaMobile,
bool? energia,
bool? assicurazioni,
bool? intrattenimento,
bool? altro,
bool? isActive,
String? companyId,
}) {
return ProviderModel(
id: id ?? this.id,
nome: nome ?? this.nome,
telefoniaFissa: telefoniaFissa ?? this.telefoniaFissa,
telefoniaMobile: telefoniaMobile ?? this.telefoniaMobile,
energia: energia ?? this.energia,
assicurazioni: assicurazioni ?? this.assicurazioni,
intrattenimento: intrattenimento ?? this.intrattenimento,
altro: altro ?? this.altro,
isActive: isActive ?? this.isActive,
companyId: companyId ?? this.companyId,
);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/features/services/data/services_repository.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:get_it/get_it.dart';
class ServicesState extends Equatable {
final List<ServiceModel> allServices;
final bool isLoading;
final bool hasReachedMax; // Per lo scroll infinito
final String? errorMessage;
// Parametri di ricerca
final String query;
final DateTimeRange? dateRange;
const ServicesState({
this.allServices = const [],
this.isLoading = false,
this.hasReachedMax = false,
this.errorMessage,
this.query = '',
this.dateRange,
});
ServicesState copyWith({
List<ServiceModel>? allServices,
bool? isLoading,
String? errorMessage,
bool? hasReachedMax,
String? query,
DateTimeRange? dateRange,
}) {
return ServicesState(
allServices: allServices ?? this.allServices,
isLoading: isLoading ?? this.isLoading,
errorMessage:
errorMessage, // Se non lo passiamo, torna null (pulisce l'errore)
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
query: query ?? this.query,
dateRange: dateRange ?? this.dateRange,
);
}
@override
List<Object?> get props => [
allServices,
isLoading,
hasReachedMax,
errorMessage,
query,
dateRange,
];
}
class ServicesCubit extends Cubit<ServicesState> {
final ServicesRepository _repository = GetIt.I<ServicesRepository>();
final SessionBloc _sessionBloc;
ServicesCubit(this._sessionBloc) : super(const ServicesState());
// Carica tutto il pacchetto
Future<void> loadServices({bool refresh = false}) async {
// Se non è un refresh e abbiamo già dati, non disturbare Supabase
if (!refresh && state.allServices.isNotEmpty) return;
if (state.isLoading) return;
// Se facciamo refresh, resettiamo tutto
final currentOffset = refresh ? 0 : state.allServices.length;
emit(
state.copyWith(
isLoading: true,
allServices: refresh ? [] : state.allServices,
hasReachedMax: refresh ? false : state.hasReachedMax,
),
);
try {
final newServices = await _repository.fetchServices(
companyId: _sessionBloc.state.company!.id,
offset: currentOffset,
searchTerm: state.query,
dateRange: state.dateRange,
);
emit(
state.copyWith(
isLoading: false,
allServices: List.from(state.allServices)..addAll(newServices),
hasReachedMax:
newServices.length <
50, // Se ne arrivano meno di 50, siamo alla fine
),
);
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}
void updateFilters({String? query, DateTimeRange? range}) {
emit(state.copyWith(query: query, dateRange: range));
loadServices(refresh: true); // Applica i filtri e riparte da zero
}
// Salva e ricarica
Future<void> addService(ServiceModel service) async {
emit(state.copyWith(isLoading: true));
try {
await _repository.saveFullService(service);
await loadServices(); // Ricarichiamo la lista aggiornata
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/service_model.dart';
class ServicesRepository {
final _supabase = Supabase.instance.client;
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
Future<List<ServiceModel>> fetchServices({
required String companyId,
required int offset,
int limit = 50,
String? searchTerm,
DateTimeRange? dateRange,
}) async {
try {
// Nota: 'customer(name, surname)' serve per il display name nella card
var query = _supabase
.from('service')
.select('''
*,
customer(name, surname),
energy_service(*),
fin_service(*),
entertainment_service(*)
''')
.eq('company_id', companyId);
// Filtro Range Date
if (dateRange != null) {
query = query
.gte('created_at', dateRange.start.toIso8601String())
.lte('created_at', dateRange.end.toIso8601String());
}
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or(
'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%,customer.surname.ilike.%$searchTerm%',
);
}
final response = await query
.order('created_at', ascending: false)
.range(offset, offset + limit - 1);
return (response as List)
.map((map) => ServiceModel.fromMap(map))
.toList();
} catch (e) {
throw Exception('Errore nel caricamento servizi: $e');
}
}
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<void> saveFullService(ServiceModel service) async {
try {
// 1. Inseriamo il record principale
// Se service.id è null, Supabase fa INSERT. Se c'è, fa UPDATE (grazie all'upsert o gestione manuale)
final serviceData = await _supabase
.from('service')
.upsert(service.toMap())
.select()
.single();
final String newId = serviceData['id'];
// 2. Pulizia vecchi record figli (necessaria se è una MODIFICA)
// Se stiamo modificando, cancelliamo i vecchi per reinserire i nuovi (più semplice)
if (service.id != null) {
await _supabase.from('energy_service').delete().eq('service_id', newId);
await _supabase.from('fin_service').delete().eq('service_id', newId);
await _supabase
.from('entertainment_service')
.delete()
.eq('service_id', newId);
}
// 3. Inserimento EnergyServices
if (service.energyServices.isNotEmpty) {
final List<Map<String, dynamic>> toInsert = [];
for (var item in service.energyServices) {
toInsert.add(item.copyWith(serviceId: newId).toMap());
}
await _supabase.from('energy_service').insert(toInsert);
}
// 4. Inserimento FinServices
if (service.finServices.isNotEmpty) {
final List<Map<String, dynamic>> toInsert = [];
for (var item in service.finServices) {
toInsert.add(item.copyWith(serviceId: newId).toMap());
}
await _supabase.from('fin_service').insert(toInsert);
}
// 5. Inserimento EntertainmentServices
if (service.entertainmentServices.isNotEmpty) {
final List<Map<String, dynamic>> toInsert = [];
for (var item in service.entertainmentServices) {
toInsert.add(item.copyWith(serviceId: newId).toMap());
}
await _supabase.from('entertainment_service').insert(toInsert);
}
} catch (e) {
throw Exception('Errore durante il salvataggio: $e');
}
}
// --- ELIMINAZIONE ---
Future<void> deleteService(String id) async {
try {
await _supabase.from('service').delete().eq('id', id);
} catch (e) {
throw Exception('Errore durante l\'eliminazione: $e');
}
}
}

View File

@@ -0,0 +1,72 @@
import 'package:equatable/equatable.dart';
enum EnergyType { luce, gas } // Mappa il tuo public.energy_type
class EnergyServiceModel extends Equatable {
final String? id;
final DateTime? createdAt;
final EnergyType type;
final DateTime expiration;
final String providerId;
final String? serviceId;
const EnergyServiceModel({
this.id,
this.createdAt,
required this.type,
required this.expiration,
required this.providerId,
this.serviceId,
});
EnergyServiceModel copyWith({
String? id,
DateTime? createdAt,
EnergyType? type,
DateTime? expiration,
String? providerId,
String? serviceId,
}) {
return EnergyServiceModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
type: type ?? this.type,
expiration: expiration ?? this.expiration,
providerId: providerId ?? this.providerId,
serviceId: serviceId ?? this.serviceId,
);
}
@override
List<Object?> get props => [
id,
createdAt,
type,
expiration,
providerId,
serviceId,
];
factory EnergyServiceModel.fromMap(Map<String, dynamic> map) {
return EnergyServiceModel(
id: map['id'],
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
type: map['type'] == 'gas' ? EnergyType.gas : EnergyType.luce,
expiration: DateTime.parse(map['expiration']),
providerId: map['provider_id'],
serviceId: map['service_id'],
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'type': type.name, // .name trasforma l'enum in 'luce' o 'gas'
'expiration': expiration.toIso8601String(),
'provider_id': providerId,
'service_id': serviceId,
};
}
}

View File

@@ -0,0 +1,77 @@
import 'package:equatable/equatable.dart';
class EntertainmentServiceModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String type; // es. Sky, DAZN, ecc.
final bool constrained; // Vincolato?
final DateTime constrainExpiration;
final String? serviceId;
final String? providerId;
const EntertainmentServiceModel({
this.id,
this.createdAt,
required this.type,
required this.constrained,
required this.constrainExpiration,
this.serviceId,
this.providerId,
});
EntertainmentServiceModel copyWith({
String? id,
DateTime? createdAt,
String? type,
bool? constrained,
DateTime? constrainExpiration,
String? serviceId,
String? providerId,
}) {
return EntertainmentServiceModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
type: type ?? this.type,
constrained: constrained ?? this.constrained,
constrainExpiration: constrainExpiration ?? this.constrainExpiration,
serviceId: serviceId ?? this.serviceId,
providerId: providerId ?? this.providerId,
);
}
@override
List<Object?> get props => [
id,
createdAt,
type,
constrained,
constrainExpiration,
serviceId,
providerId,
];
factory EntertainmentServiceModel.fromMap(Map<String, dynamic> map) {
return EntertainmentServiceModel(
id: map['id'],
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
type: map['type'],
constrained: map['constrained'] ?? false,
constrainExpiration: DateTime.parse(map['constrain_expiration']),
serviceId: map['service_id'],
providerId: map['provider_id'],
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'type': type,
'constrained': constrained,
'constrain_expiration': constrainExpiration.toIso8601String(),
'service_id': serviceId,
'provider_id': providerId,
};
}
}

View File

@@ -0,0 +1,63 @@
import 'package:equatable/equatable.dart';
class FinServiceModel extends Equatable {
final String? id;
final DateTime? createdAt;
final DateTime expiration;
final String? serviceId;
final String? modelId; // FK verso model (es. iPhone, Samsung, ecc.)
final String? providerId;
const FinServiceModel({
this.id,
this.createdAt,
required this.expiration,
this.serviceId,
this.modelId,
this.providerId,
});
FinServiceModel copyWith({
String? id,
DateTime? createdAt,
DateTime? expiration,
String? serviceId,
String? modelId,
String? providerId,
}) {
return FinServiceModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
expiration: expiration ?? this.expiration,
serviceId: serviceId ?? this.serviceId,
modelId: modelId ?? this.modelId,
providerId: providerId ?? this.providerId,
);
}
@override
List<Object?> get props => [id, createdAt, expiration, serviceId, modelId];
factory FinServiceModel.fromMap(Map<String, dynamic> map) {
return FinServiceModel(
id: map['id'],
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
expiration: DateTime.parse(map['expiration']),
serviceId: map['service_id'],
modelId: map['model_id'],
providerId: map['provider_id'],
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'expiration': expiration.toIso8601String(),
'service_id': serviceId,
'model_id': modelId,
'provider_id': providerId,
};
}
}

View File

@@ -0,0 +1,173 @@
import 'package:equatable/equatable.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';
class ServiceModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String storeId;
final String? employeeId;
final String? customerId;
final String number;
final bool isBozza;
final String note;
final bool resultOk;
final String? customerDisplayName;
// Telefonia
final int al;
final int mnp;
final int nip;
final int unica;
final int telepass;
// Moduli (Liste)
final List<EnergyServiceModel> energyServices;
final List<FinServiceModel> finServices;
final List<EntertainmentServiceModel> entertainmentServices;
const ServiceModel({
this.id,
this.createdAt,
required this.storeId,
this.employeeId,
this.customerId,
required this.number,
this.isBozza = true,
this.note = '',
this.resultOk = true,
this.al = 0,
this.mnp = 0,
this.nip = 0,
this.unica = 0,
this.telepass = 0,
this.energyServices = const [],
this.finServices = const [],
this.entertainmentServices = const [],
this.customerDisplayName,
});
ServiceModel copyWith({
String? id,
DateTime? createdAt,
String? storeId,
String? employeeId,
String? customerId,
String? number,
bool? isBozza,
String? note,
bool? resultOk,
int? al,
int? mnp,
int? nip,
int? unica,
int? telepass,
List<EnergyServiceModel>? energyServices,
List<FinServiceModel>? finServices,
List<EntertainmentServiceModel>? entertainmentServices,
String? customerDisplayName,
}) {
return ServiceModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
storeId: storeId ?? this.storeId,
employeeId: employeeId ?? this.employeeId,
customerId: customerId ?? this.customerId,
number: number ?? this.number,
isBozza: isBozza ?? this.isBozza,
note: note ?? this.note,
resultOk: resultOk ?? this.resultOk,
al: al ?? this.al,
mnp: mnp ?? this.mnp,
nip: nip ?? this.nip,
unica: unica ?? this.unica,
telepass: telepass ?? this.telepass,
energyServices: energyServices ?? this.energyServices,
finServices: finServices ?? this.finServices,
entertainmentServices:
entertainmentServices ?? this.entertainmentServices,
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
);
}
@override
List<Object?> get props => [
id,
createdAt,
storeId,
employeeId,
customerId,
number,
isBozza,
note,
resultOk,
al,
mnp,
nip,
unica,
telepass,
energyServices,
finServices,
entertainmentServices,
customerDisplayName,
];
factory ServiceModel.fromMap(Map<String, dynamic> map) {
return ServiceModel(
id: map['id'],
createdAt: DateTime.parse(map['created_at']),
storeId: map['store_id'],
employeeId: map['employee_id'],
customerId: map['customer_id'],
number: map['number'] ?? '',
isBozza: map['bozza'] ?? true,
note: map['note'] ?? '',
resultOk: map['result_ok'] ?? true,
al: map['al'] ?? 0,
mnp: map['mnp'] ?? 0,
nip: map['nip'] ?? 0,
unica: map['unica'] ?? 0,
telepass: map['telepass'] ?? 0,
// Mappaggio delle liste collegate (se incluse nella query)
energyServices:
(map['energy_service'] as List?)
?.map((x) => EnergyServiceModel.fromMap(x))
.toList() ??
const [],
finServices:
(map['fin_service'] as List?)
?.map((x) => FinServiceModel.fromMap(x))
.toList() ??
const [],
entertainmentServices:
(map['entertainment_service'] as List?)
?.map((x) => EntertainmentServiceModel.fromMap(x))
.toList() ??
const [],
customerDisplayName: map['customer'] != null
? "${map['customer']['name']} ${map['customer']['surname']}"
: "Cliente sconosciuto",
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'store_id': storeId,
'employee_id': employeeId,
'customer_id': customerId,
'number': number,
'bozza': isBozza,
'note': note,
'result_ok': resultOk,
'al': al,
'mnp': mnp,
'nip': nip,
'unica': unica,
'telepass': telepass,
// Le liste non le mettiamo qui perché vanno in tabelle diverse!
};
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/energy_service_model.dart';
import 'package:flux/features/services/models/service_model.dart';
class ServiceFormScreen extends StatefulWidget {
final ServiceModel? initialService; // Se nullo, è un nuovo inserimento
const ServiceFormScreen({super.key, this.initialService});
@override
State<ServiceFormScreen> createState() => _ServiceFormScreenState();
}
class _ServiceFormScreenState extends State<ServiceFormScreen> {
late ServiceModel currentService;
@override
void initState() {
super.initState();
// Se passiamo un servizio esistente lo carichiamo, altrimenti ne creiamo uno "vuoto"
currentService =
widget.initialService ??
ServiceModel(
storeId: 'ID_NEGOZIO_QUI', // Poi lo prenderai dal profilo utente
number: '',
energyServices: const [],
finServices: const [],
entertainmentServices: const [],
);
}
// Metodo generico per aggiungere un servizio energia
void _addEnergy() {
setState(() {
final newList =
List<EnergyServiceModel>.from(currentService.energyServices)..add(
EnergyServiceModel(
type: EnergyType.luce, // Default
expiration: DateTime.now().add(const Duration(days: 365)),
providerId: '', // Lo sceglierà l'utente
),
);
currentService = currentService.copyWith(energyServices: newList);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
widget.initialService == null ? "Nuova Pratica" : "Modifica",
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// --- SEZIONE DATI GENERALI ---
TextField(
decoration: const InputDecoration(labelText: "Numero Pratica"),
onChanged: (v) =>
currentService = currentService.copyWith(number: v),
),
const Divider(height: 32),
// --- SEZIONE ENERGY ---
_SectionHeader(
title: "Energia (Luce/Gas)",
onAdd: _addEnergy,
icon: Icons.electric_bolt,
),
...currentService.energyServices.asMap().entries.map((entry) {
int idx = entry.key;
var item = entry.value;
return Card(
child: ListTile(
title: Text(
"${item.type.name.toUpperCase()} - Scadenza: ${item.expiration.year}",
),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () {
setState(() {
final newList = List<EnergyServiceModel>.from(
currentService.energyServices,
)..removeAt(idx);
currentService = currentService.copyWith(
energyServices: newList,
);
});
},
),
),
);
}),
const SizedBox(height: 40),
// --- BOTTONE SALVA ---
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
onPressed: () {
context.read<ServicesCubit>().addService(currentService);
Navigator.pop(context);
},
child: const Text("SALVA TUTTO"),
),
],
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final VoidCallback onAdd;
final IconData icon;
const _SectionHeader({
required this.title,
required this.onAdd,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, color: Colors.orange),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const Spacer(),
IconButton(
onPressed: onAdd,
icon: const Icon(Icons.add_circle, color: Colors.green, size: 30),
),
],
);
}
}

View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:go_router/go_router.dart';
// Importa i tuoi modelli e cubit
class ServicesScreen extends StatefulWidget {
const ServicesScreen({super.key});
@override
State<ServicesScreen> createState() => _ServicesScreenState();
}
class _ServicesScreenState extends State<ServicesScreen> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// Agganciamo il listener per la paginazione (Scroll Infinito)
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_isBottom) {
context.read<ServicesCubit>().loadServices();
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
// Carica quando mancano 200px alla fine
return currentScroll >= (maxScroll * 0.9);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Gestione Servizi"),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// Qui potrai implementare una barra di ricerca
},
),
],
),
body: BlocBuilder<ServicesCubit, ServicesState>(
builder: (context, state) {
// 1. Stato di caricamento iniziale
if (state.isLoading && state.allServices.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
// 2. Lista vuota
if (state.allServices.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Nessuna pratica trovata."),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () => context.read<ServicesCubit>().loadServices(
refresh: true,
),
child: const Text("Riprova"),
),
],
),
);
}
// 3. La Lista (con Pull-to-refresh)
return RefreshIndicator(
onRefresh: () =>
context.read<ServicesCubit>().loadServices(refresh: true),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB
itemCount: state.hasReachedMax
? state.allServices.length
: state.allServices.length + 1,
itemBuilder: (context, index) {
if (index >= state.allServices.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final service = state.allServices[index];
return _buildServiceCard(context, service);
},
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.pushNamed('service-form'), // GoRouter
child: const Icon(Icons.add),
),
);
}
Widget _buildServiceCard(BuildContext context, ServiceModel service) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
contentPadding: const EdgeInsets.all(12),
title: Row(
children: [
Expanded(
child: Text(
service.customerDisplayName ?? "Cliente sconosciuto",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
if (service.isBozza)
const Chip(
label: Text(
"BOZZA",
style: TextStyle(fontSize: 10, color: Colors.white),
),
backgroundColor: Colors.orange,
visualDensity: VisualDensity.compact,
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
"Pratica: ${service.number}${service.createdAt?.day}/${service.createdAt?.month}/${service.createdAt?.year}",
),
const SizedBox(height: 8),
// I nostri mini-chip per i servizi attivati
Wrap(
spacing: 6,
children: [
if (service.al > 0 || service.mnp > 0)
_miniBadge("📞 Tel", Colors.blue),
if (service.energyServices.isNotEmpty)
_miniBadge("⚡ Energy", Colors.green),
if (service.finServices.isNotEmpty)
_miniBadge("💰 Fin", Colors.purple),
if (service.entertainmentServices.isNotEmpty)
_miniBadge("📺 Ent", Colors.red),
],
),
],
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.pushNamed('service-form', extra: service),
),
);
}
Widget _miniBadge(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Text(
text,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
);
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
@@ -16,6 +18,8 @@ import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/data/staff_repository.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
import 'package:flux/features/master_data/store/data/store_repository.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/data/services_repository.dart';
import 'package:flux/features/settings/settings.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
@@ -59,6 +63,7 @@ Future<void> setupLocator() async {
getIt.registerLazySingleton<CustomerRepository>(() => CustomerRepository());
getIt.registerLazySingleton<ProductRepository>(() => ProductRepository());
getIt.registerLazySingleton<StaffRepository>(() => StaffRepository());
getIt.registerLazySingleton<ServicesRepository>(() => ServicesRepository());
}
class FluxApp extends StatefulWidget {
@@ -95,6 +100,9 @@ class _FluxAppState extends State<FluxApp> {
create: (_) =>
StaffCubit(context.read<SessionBloc>())..loadAllStaff(),
),
BlocProvider<ServicesCubit>(
create: (_) => ServicesCubit(context.read<SessionBloc>()),
),
],
child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, state) {