service (#2)
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:
116
lib/features/services/blocs/services_cubit.dart
Normal file
116
lib/features/services/blocs/services_cubit.dart
Normal 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
118
lib/features/services/data/services_repository.dart
Normal file
118
lib/features/services/data/services_repository.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
72
lib/features/services/models/energy_service_model.dart
Normal file
72
lib/features/services/models/energy_service_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
63
lib/features/services/models/fin_service_model.dart
Normal file
63
lib/features/services/models/fin_service_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
173
lib/features/services/models/service_model.dart
Normal file
173
lib/features/services/models/service_model.dart
Normal 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!
|
||||
};
|
||||
}
|
||||
}
|
||||
150
lib/features/services/ui/service_form_screen.dart
Normal file
150
lib/features/services/ui/service_form_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
197
lib/features/services/ui/services_screen.dart
Normal file
197
lib/features/services/ui/services_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user