feat - Service - Providers

This commit is contained in:
2026-04-16 11:48:11 +02:00
parent 29790a7a36
commit 787873a26f
11 changed files with 758 additions and 55 deletions

View File

@@ -1,6 +1,7 @@
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';
@@ -54,11 +55,14 @@ class ServicesState extends Equatable {
class ServicesCubit extends Cubit<ServicesState> {
final ServicesRepository _repository = GetIt.I<ServicesRepository>();
final SessionBloc _sessionBloc;
ServicesCubit() : super(const ServicesState());
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
@@ -74,6 +78,7 @@ class ServicesCubit extends Cubit<ServicesState> {
try {
final newServices = await _repository.fetchServices(
companyId: _sessionBloc.state.company!.id,
offset: currentOffset,
searchTerm: state.query,
dateRange: state.dateRange,

View File

@@ -1,94 +1,106 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/service_model.dart';
// Importa gli altri modelli se sono in file separati
class ServicesRepository {
final _supabase = Supabase.instance.client;
// --- RECUPERO TUTTI I SERVIZI ---
// --- 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 {
var query = _supabase.from('service').select('''
*,
energy_service(*),
fin_service(*),
entertainment_service(*)
''');
// 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 per range di date
// Filtro Range Date
if (dateRange != null) {
query = query
.gte('created_at', dateRange.start.toIso8601String())
.lte('created_at', dateRange.end.toIso8601String());
}
// Ordinamento e Paginazione
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);
final List<ServiceModel> services = (response as List)
return (response as List)
.map((map) => ServiceModel.fromMap(map))
.toList();
// Filtro testuale lato client per semplicità (o potresti farlo in SQL se preferisci)
if (searchTerm != null && searchTerm.isNotEmpty) {
return services.where((s) {
// Qui cercheremo per numero pratica o note (il nome cliente lo vedremo poi con le Join)
return s.number.toLowerCase().contains(searchTerm.toLowerCase()) ||
s.note.toLowerCase().contains(searchTerm.toLowerCase());
}).toList();
}
return services;
} catch (e) {
throw Exception('Errore fetch: $e');
throw Exception('Errore nel caricamento servizi: $e');
}
}
// --- SALVATAGGIO COMPLETO (A CASCATA) ---
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<void> saveFullService(ServiceModel service) async {
try {
// 1. Inserimento Padre
// 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')
.insert(service.toMap())
.upsert(service.toMap())
.select()
.single();
final String newId = serviceData['id'];
// 2. Inserimento Energy (se presenti)
// 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>> energyToInsert = [];
final List<Map<String, dynamic>> toInsert = [];
for (var item in service.energyServices) {
energyToInsert.add(item.copyWith(serviceId: newId).toMap());
toInsert.add(item.copyWith(serviceId: newId).toMap());
}
await _supabase.from('energy_service').insert(energyToInsert);
await _supabase.from('energy_service').insert(toInsert);
}
// 3. Inserimento Finanziamenti (se presenti)
// 4. Inserimento FinServices
if (service.finServices.isNotEmpty) {
final List<Map<String, dynamic>> finToInsert = [];
final List<Map<String, dynamic>> toInsert = [];
for (var item in service.finServices) {
finToInsert.add(item.copyWith(serviceId: newId).toMap());
toInsert.add(item.copyWith(serviceId: newId).toMap());
}
await _supabase.from('fin_service').insert(finToInsert);
await _supabase.from('fin_service').insert(toInsert);
}
// 4. Inserimento Entertainment (se presenti)
// 5. Inserimento EntertainmentServices
if (service.entertainmentServices.isNotEmpty) {
final List<Map<String, dynamic>> entToInsert = [];
final List<Map<String, dynamic>> toInsert = [];
for (var item in service.entertainmentServices) {
entToInsert.add(item.copyWith(serviceId: newId).toMap());
toInsert.add(item.copyWith(serviceId: newId).toMap());
}
await _supabase.from('entertainment_service').insert(entToInsert);
await _supabase.from('entertainment_service').insert(toInsert);
}
} catch (e) {
throw Exception('Errore durante il salvataggio: $e');
@@ -96,8 +108,6 @@ class ServicesRepository {
}
// --- ELIMINAZIONE ---
// Grazie ai "ON DELETE CASCADE" che hai messo nell'SQL,
// cancellando il padre Supabase pialla automaticamente i figli. Top!
Future<void> deleteService(String id) async {
try {
await _supabase.from('service').delete().eq('id', id);

View File

@@ -13,6 +13,7 @@ class ServiceModel extends Equatable {
final bool isBozza;
final String note;
final bool resultOk;
final String? customerDisplayName;
// Telefonia
final int al;
@@ -44,6 +45,7 @@ class ServiceModel extends Equatable {
this.energyServices = const [],
this.finServices = const [],
this.entertainmentServices = const [],
this.customerDisplayName,
});
ServiceModel copyWith({
@@ -64,6 +66,7 @@ class ServiceModel extends Equatable {
List<EnergyServiceModel>? energyServices,
List<FinServiceModel>? finServices,
List<EntertainmentServiceModel>? entertainmentServices,
String? customerDisplayName,
}) {
return ServiceModel(
id: id ?? this.id,
@@ -84,6 +87,7 @@ class ServiceModel extends Equatable {
finServices: finServices ?? this.finServices,
entertainmentServices:
entertainmentServices ?? this.entertainmentServices,
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
);
}
@@ -106,6 +110,7 @@ class ServiceModel extends Equatable {
energyServices,
finServices,
entertainmentServices,
customerDisplayName,
];
factory ServiceModel.fromMap(Map<String, dynamic> map) {
@@ -141,6 +146,9 @@ class ServiceModel extends Equatable {
?.map((x) => EntertainmentServiceModel.fromMap(x))
.toList() ??
const [],
customerDisplayName: map['customer'] != null
? "${map['customer']['name']} ${map['customer']['surname']}"
: "Cliente sconosciuto",
);
}

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,
),
),
);
}
}