Refactor service management: streamline service form, enhance state management, and improve loading logic
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/home/ui/dashboard_action_card.dart';
|
||||
import 'package:flux/features/services/utils/service_actions.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class DashboardAdaptiveGrid extends StatelessWidget {
|
||||
@@ -36,7 +37,7 @@ class DashboardAdaptiveGrid extends StatelessWidget {
|
||||
label: 'Nuova Op',
|
||||
icon: Icons.add_task,
|
||||
color: context.accent,
|
||||
onTap: () {},
|
||||
onTap: () => startNewService(context),
|
||||
),
|
||||
DashboardActionCard(
|
||||
label: 'Clienti',
|
||||
|
||||
@@ -3,55 +3,12 @@ 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/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: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,
|
||||
];
|
||||
}
|
||||
part 'services_state.dart';
|
||||
|
||||
class ServicesCubit extends Cubit<ServicesState> {
|
||||
final ServicesRepository _repository = GetIt.I<ServicesRepository>();
|
||||
@@ -59,58 +16,178 @@ class ServicesCubit extends Cubit<ServicesState> {
|
||||
|
||||
ServicesCubit(this._sessionBloc) : super(const ServicesState());
|
||||
|
||||
// Carica tutto il pacchetto
|
||||
// --- CARICAMENTO E PAGINAZIONE ---
|
||||
|
||||
Future<void> loadServices({bool refresh = false}) async {
|
||||
// Se non è un refresh e abbiamo già dati, non disturbare Supabase
|
||||
if (!refresh && state.allServices.isNotEmpty) return;
|
||||
// Se stiamo già caricando, evitiamo chiamate doppie
|
||||
if (state.isLoading) return;
|
||||
|
||||
// Se facciamo refresh, resettiamo tutto
|
||||
final currentOffset = refresh ? 0 : state.allServices.length;
|
||||
// Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo
|
||||
if (!refresh && state.hasReachedMax) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: true,
|
||||
errorMessage: null,
|
||||
// Se è un refresh, svuotiamo la lista attuale per mostrare lo shimmer/loading
|
||||
allServices: refresh ? [] : state.allServices,
|
||||
hasReachedMax: refresh ? false : state.hasReachedMax,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final currentOffset = refresh ? 0 : state.allServices.length;
|
||||
final companyId = _sessionBloc.state.company?.id;
|
||||
|
||||
if (companyId == null) {
|
||||
throw Exception("Company ID non trovato nella sessione");
|
||||
}
|
||||
|
||||
final newServices = await _repository.fetchServices(
|
||||
companyId: _sessionBloc.state.company!.id,
|
||||
companyId: companyId,
|
||||
offset: currentOffset,
|
||||
limit: 50,
|
||||
searchTerm: state.query,
|
||||
dateRange: state.dateRange,
|
||||
);
|
||||
|
||||
// Se ricevi meno record del limite, significa che non ce ne sono altri sul DB
|
||||
final bool reachedMax = newServices.length < 50;
|
||||
|
||||
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
|
||||
allServices: refresh
|
||||
? newServices
|
||||
: [...state.allServices, ...newServices],
|
||||
hasReachedMax: reachedMax,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: "Errore nel caricamento servizi: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GESTIONE FILTRI ---
|
||||
|
||||
/// Aggiorna i parametri di ricerca e ricarica da zero
|
||||
void updateFilters({String? query, DateTimeRange? range}) {
|
||||
emit(state.copyWith(query: query, dateRange: range));
|
||||
loadServices(refresh: true); // Applica i filtri e riparte da zero
|
||||
emit(
|
||||
state.copyWith(
|
||||
query: query ?? state.query,
|
||||
dateRange: range ?? state.dateRange,
|
||||
),
|
||||
);
|
||||
loadServices(refresh: true);
|
||||
}
|
||||
|
||||
// Salva e ricarica
|
||||
Future<void> addService(ServiceModel service) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
/// Pulisce tutti i filtri
|
||||
void clearFilters() {
|
||||
emit(state.copyWith(query: '', dateRange: null));
|
||||
loadServices(refresh: true);
|
||||
}
|
||||
|
||||
// --- GESTIONE BOZZA (DRAFT) ---
|
||||
|
||||
/// Inizializza un nuovo servizio o ne carica uno esistente per la modifica
|
||||
void initServiceForm(ServiceModel? existingService) {
|
||||
if (existingService != null) {
|
||||
emit(state.copyWith(currentService: existingService));
|
||||
} else {
|
||||
// Crea un template vuoto con lo store di default (se disponibile)
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: ServiceModel(
|
||||
storeId: _sessionBloc.state.selectedStore?.id ?? '',
|
||||
number: '', // Sarà compilato dall'utente
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Metodo generico per aggiornare i campi base (AL, MNP, Note, ecc.)
|
||||
void updateField({
|
||||
int? al,
|
||||
int? mnp,
|
||||
int? nip,
|
||||
int? unica,
|
||||
int? telepass,
|
||||
String? note,
|
||||
String? number,
|
||||
bool? isBozza,
|
||||
bool? resultOk,
|
||||
String? customerId,
|
||||
}) {
|
||||
if (state.currentService == null) return;
|
||||
|
||||
final updated = state.currentService!.copyWith(
|
||||
al: al,
|
||||
mnp: mnp,
|
||||
nip: nip,
|
||||
unica: unica,
|
||||
telepass: telepass,
|
||||
note: note,
|
||||
number: number,
|
||||
isBozza: isBozza,
|
||||
resultOk: resultOk,
|
||||
customerId: customerId,
|
||||
);
|
||||
|
||||
emit(state.copyWith(currentService: updated));
|
||||
}
|
||||
|
||||
// --- GESTIONE MODULI COMPLESSI ---
|
||||
|
||||
void updateEnergyServices(List<EnergyServiceModel> energyList) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: state.currentService?.copyWith(
|
||||
energyServices: energyList,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateFinServices(List<FinServiceModel> finList) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: state.currentService?.copyWith(finServices: finList),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateEntertainmentServices(List<EntertainmentServiceModel> entList) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: state.currentService?.copyWith(
|
||||
entertainmentServices: entList,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- PERSISTENZA ---
|
||||
|
||||
Future<void> saveCurrentService() async {
|
||||
if (state.currentService == null) return;
|
||||
|
||||
emit(state.copyWith(isSaving: true, errorMessage: null));
|
||||
try {
|
||||
await _repository.saveFullService(service);
|
||||
await loadServices(); // Ricarichiamo la lista aggiornata
|
||||
// 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);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
|
||||
emit(state.copyWith(isSaving: false, errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
57
lib/features/services/blocs/services_state.dart
Normal file
57
lib/features/services/blocs/services_state.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
part of 'services_cubit.dart';
|
||||
|
||||
class ServicesState extends Equatable {
|
||||
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({
|
||||
this.allServices = const [],
|
||||
this.currentService,
|
||||
this.isLoading = false,
|
||||
this.isSaving = false,
|
||||
this.errorMessage,
|
||||
this.query = '',
|
||||
this.dateRange,
|
||||
this.hasReachedMax = false,
|
||||
});
|
||||
|
||||
ServicesState copyWith({
|
||||
List<ServiceModel>? allServices,
|
||||
ServiceModel? currentService,
|
||||
bool? isLoading,
|
||||
bool? isSaving,
|
||||
String? errorMessage,
|
||||
String? query,
|
||||
DateTimeRange? dateRange,
|
||||
bool? hasReachedMax,
|
||||
}) {
|
||||
return ServicesState(
|
||||
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,
|
||||
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
allServices,
|
||||
currentService,
|
||||
isLoading,
|
||||
isSaving,
|
||||
errorMessage,
|
||||
query,
|
||||
dateRange,
|
||||
hasReachedMax,
|
||||
];
|
||||
}
|
||||
@@ -55,8 +55,7 @@ class ServicesRepository {
|
||||
// --- 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)
|
||||
// 1. Upsert del record principale
|
||||
final serviceData = await _supabase
|
||||
.from('service')
|
||||
.upsert(service.toMap())
|
||||
@@ -65,45 +64,65 @@ class ServicesRepository {
|
||||
|
||||
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)
|
||||
// 2. MODIFICA: Pulizia atomica dei figli
|
||||
// Se stiamo modificando (id != null), resettiamo le tabelle collegate
|
||||
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);
|
||||
await Future.wait([
|
||||
_supabase.from('energy_service').delete().eq('service_id', newId),
|
||||
_supabase.from('fin_service').delete().eq('service_id', newId),
|
||||
_supabase
|
||||
.from('entertainment_service')
|
||||
.delete()
|
||||
.eq('service_id', newId),
|
||||
// Aggiungi qui eventuali altre tabelle pivot o file
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Inserimento EnergyServices
|
||||
// 3. Inserimento dei moduli in parallelo per velocità
|
||||
final List<Future> insertTasks = [];
|
||||
|
||||
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);
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('energy_service')
|
||||
.insert(
|
||||
service.energyServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('fin_service')
|
||||
.insert(
|
||||
service.finServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('entertainment_service')
|
||||
.insert(
|
||||
service.entertainmentServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (insertTasks.isNotEmpty) {
|
||||
await Future.wait(insertTasks);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Errore durante il salvataggio: $e');
|
||||
// Qui potresti aggiungere una logica di "rollback manuale" se necessario
|
||||
throw Exception('Errore durante il salvataggio corazzato: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
||||
import 'package:flux/features/services/models/fin_service_model.dart';
|
||||
|
||||
enum EnergyType { luce, gas } // Mappa il tuo public.energy_type
|
||||
|
||||
|
||||
@@ -115,12 +115,14 @@ class ServiceModel extends Equatable {
|
||||
|
||||
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'] ?? '',
|
||||
id: map['id'].toString(),
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: DateTime.now(),
|
||||
storeId: map['store_id'] ?? '',
|
||||
employeeId: map['employee_id']?.toString(),
|
||||
customerId: map['customer_id']?.toString(),
|
||||
number: map['number']?.toString() ?? '',
|
||||
isBozza: map['bozza'] ?? true,
|
||||
note: map['note'] ?? '',
|
||||
resultOk: map['result_ok'] ?? true,
|
||||
@@ -130,7 +132,7 @@ class ServiceModel extends Equatable {
|
||||
unica: map['unica'] ?? 0,
|
||||
telepass: map['telepass'] ?? 0,
|
||||
|
||||
// Mappaggio delle liste collegate (se incluse nella query)
|
||||
// Estrazione sicura liste collegate
|
||||
energyServices:
|
||||
(map['energy_service'] as List?)
|
||||
?.map((x) => EnergyServiceModel.fromMap(x))
|
||||
@@ -146,9 +148,12 @@ class ServiceModel extends Equatable {
|
||||
?.map((x) => EntertainmentServiceModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
|
||||
// Display name del cliente con fallback
|
||||
customerDisplayName: map['customer'] != null
|
||||
? "${map['customer']['name']} ${map['customer']['surname']}"
|
||||
: "Cliente sconosciuto",
|
||||
? "${map['customer']['name'] ?? ''} ${map['customer']['surname'] ?? ''}"
|
||||
.trim()
|
||||
: "Cliente non assegnato",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
76
lib/features/services/ui/service_action_card.dart
Normal file
76
lib/features/services/ui/service_action_card.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ServiceActionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final Color color;
|
||||
final int count;
|
||||
const ServiceActionCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.color,
|
||||
this.count = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isActive = count > 0;
|
||||
|
||||
return Card(
|
||||
elevation: isActive ? 4 : 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: isActive ? color : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
width: 110, // Dimensione fissa per farle stare in una Row/Wrap
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: isActive ? color.withValues(alpha: 0.1) : Colors.transparent,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isActive ? color : Colors.grey.shade400,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? color : Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (isActive) ...[
|
||||
const SizedBox(height: 4),
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: color,
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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';
|
||||
|
||||
class GeneralInfoSection extends StatelessWidget {
|
||||
final ServiceModel service;
|
||||
const GeneralInfoSection({super.key, required this.service});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Info Generali",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Numero di Riferimento / Telefono
|
||||
TextFormField(
|
||||
initialValue: service.number,
|
||||
keyboardType: TextInputType
|
||||
.phone, // Fa aprire il tastierino numerico su mobile
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Numero di Telefono / Riferimento",
|
||||
hintText: "Es. 3331234567",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.phone),
|
||||
),
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(number: val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// I due Switch affiancati (Bozza e A buon fine)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: const Text("Bozza"),
|
||||
subtitle: const Text(
|
||||
"Pratica in lavorazione",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
value: service.isBozza,
|
||||
activeThumbColor: Colors.orange,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(isBozza: val);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: const Text("A buon fine"),
|
||||
subtitle: const Text(
|
||||
"Esito positivo",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
value: service.resultOk,
|
||||
activeThumbColor: Colors.green,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(resultOk: val);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Campo Note
|
||||
TextFormField(
|
||||
initialValue: service.note,
|
||||
maxLines: 4,
|
||||
minLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Note Operazione",
|
||||
hintText:
|
||||
"Scrivi qui eventuali dettagli o richieste del cliente...",
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(note: val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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/ui/service_form_screen/general_info_section.dart';
|
||||
|
||||
class ServiceFormScreen extends StatelessWidget {
|
||||
const ServiceFormScreen({super.key});
|
||||
|
||||
@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
|
||||
const _CustomerSection(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// SEZIONE 2: INFO GENERALI (Da fare)
|
||||
GeneralInfoSection(service: service),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// SEZIONE 3: I MODULI (Da fare)
|
||||
Text(
|
||||
"Servizi e Accessori",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// const _ServicesGrid(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// SEZIONE 4: ALLEGATI (Da fare)
|
||||
// const _AttachmentsSection(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- COMPONENTI DELLA PAGINA ---
|
||||
|
||||
class _SaveButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ServicesCubit, ServicesState>(
|
||||
builder: (context, state) {
|
||||
if (state.isSaving) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.save),
|
||||
tooltip: "Salva Pratica",
|
||||
onPressed: () {
|
||||
// TODO: Aggiungere una validazione prima di salvare!
|
||||
context.read<ServicesCubit>().saveCurrentService();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomerSection extends StatelessWidget {
|
||||
const _CustomerSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ServicesCubit, ServicesState>(
|
||||
builder: (context, state) {
|
||||
final service = state.currentService!;
|
||||
final hasCustomer = service.customerId != null;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Dati Cliente",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Se non c'è il cliente, mostriamo il tastone per cercarlo
|
||||
if (!hasCustomer)
|
||||
Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: Aprire modale/dialog per ricerca clienti
|
||||
print("Apro ricerca clienti...");
|
||||
},
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text("Seleziona o Crea Cliente"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Se c'è, mostriamo chi è e diamo la possibilità di cambiarlo
|
||||
else
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
service.customerDisplayName ?? "Cliente Selezionato",
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: Aprire modale/dialog per ricerca clienti
|
||||
},
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text("Cambia"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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:flux/features/services/utils/service_actions.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
// Importa i tuoi modelli e cubit
|
||||
|
||||
@@ -111,7 +112,7 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.pushNamed('service-form'), // GoRouter
|
||||
onPressed: () => startNewService(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
|
||||
81
lib/features/services/utils/service_actions.dart
Normal file
81
lib/features/services/utils/service_actions.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
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/store/bloc/store_cubit.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';
|
||||
|
||||
/// Avvia la creazione di un nuovo servizio partendo dalla selezione dell'operatore.
|
||||
void startNewService(BuildContext context) {
|
||||
final session = context.read<SessionBloc>().state;
|
||||
final currentStoreId = session.selectedStore?.id;
|
||||
|
||||
if (currentStoreId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Seleziona uno store prima di iniziare")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
// Usiamo lo StoreCubit invece dello StaffCubit!
|
||||
return BlocBuilder<StoreCubit, StoreState>(
|
||||
builder: (context, storeState) {
|
||||
// Recuperiamo lo staff assegnato a questo specifico store usando la mappa che avevi già creato
|
||||
final storeStaff = storeState.staffByStore[currentStoreId] ?? [];
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"Chi sta eseguendo l'operazione?",
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
if (storeStaff.isEmpty)
|
||||
const Text(
|
||||
"Nessun membro dello staff configurato per questo store.\nVai in Anagrafica > Negozi per assegnare il personale.",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
...storeStaff.map(
|
||||
(member) => ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.person)),
|
||||
title: Text(member.name),
|
||||
onTap: () {
|
||||
// 1. Inizializza il form nel Cubit
|
||||
context.read<ServicesCubit>().initServiceForm(
|
||||
ServiceModel(
|
||||
storeId: currentStoreId,
|
||||
employeeId: member.id,
|
||||
number: '',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Chiudi la modal
|
||||
Navigator.pop(modalContext);
|
||||
|
||||
// 3. Naviga verso il form
|
||||
context.pushNamed('service-form');
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user