feat-insert-service (#5)

Reviewed-on: http://catelliub.zapto.org:3000/brontomark/flux/pulls/5
Co-authored-by: mark-cachy <marco@catelli.it>
Co-committed-by: mark-cachy <marco@catelli.it>
This commit is contained in:
2026-04-20 16:52:20 +02:00
committed by brontomark
parent 667bbf6404
commit c3d4f3fac7
63 changed files with 4715 additions and 1371 deletions

View File

@@ -1,116 +1,323 @@
import 'package:equatable/equatable.dart';
import 'package:file_picker/file_picker.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/core/utils/string_extensions.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_file_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,
];
}
import 'package:collection/collection.dart';
part 'services_state.dart';
class ServicesCubit extends Cubit<ServicesState> {
final ServicesRepository _repository = GetIt.I<ServicesRepository>();
final SessionBloc _sessionBloc;
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
ServicesCubit(this._sessionBloc) : super(const ServicesState());
ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial));
// --- CARICAMENTO E PAGINAZIONE ---
// 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 stiamo già caricando, evitiamo chiamate doppie
if (state.status == ServicesStatus.loading) 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,
status: ServicesStatus.loading,
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
status: ServicesStatus.ready,
allServices: refresh
? newServices
: [...state.allServices, ...newServices],
hasReachedMax: reachedMax,
),
);
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
emit(
state.copyWith(
status: ServicesStatus.failure,
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,
String? serviceId,
}) async {
if (existingService != null) {
emit(
state.copyWith(
currentService: existingService,
status: ServicesStatus.ready,
),
);
} else if (serviceId != null) {
ServiceModel? serviceModel = state.allServices.firstWhereOrNull(
(s) => s.id == serviceId,
);
serviceModel ??= await _repository.fetchServiceById(serviceId);
emit(
state.copyWith(
currentService: serviceModel,
status: ServicesStatus.ready,
),
);
} else {
// Crea un template vuoto con lo store di default (se disponibile)
emit(
state.copyWith(
currentService: ServiceModel(
storeId: _sessionBloc.state.selectedStore?.id ?? '',
number: '', // Sarà compilato dall'utente
createdAt: DateTime.now(),
companyId: _sessionBloc.state.company!.id,
),
status: ServicesStatus.ready,
),
);
}
}
/// 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,
String? customerDisplayName,
}) {
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,
customerDisplayName: customerDisplayName,
);
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({required bool isBozza}) async {
if (state.currentService == null) return;
emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null));
try {
await _repository.saveFullService(service);
await loadServices(); // Ricarichiamo la lista aggiornata
// 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente
final serviceToSave = state.currentService!.copyWith(isBozza: isBozza);
// 2. Salvataggio corazzato
await _repository.saveFullService(serviceToSave);
// 3. Reset e ricaricamento
emit(state.copyWith(status: ServicesStatus.saved, currentService: null));
await loadServices(refresh: true);
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
emit(
state.copyWith(
status: ServicesStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- GESTIONE ALLEGATI LOCALI ---
void addAttachments(List<PlatformFile> files) {
final newAttachments = files.map((file) {
return ServiceFileModel(
id: null, // Meglio null se non è su DB
serviceId: state.currentService?.id ?? '',
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
url: '',
fileSize: file.size,
localBytes: file.bytes,
createdAt: DateTime.now(),
);
}).toList();
// Creiamo una nuova lista pulita
final List<ServiceFileModel> updatedList = [
...(state.currentService?.files ?? []),
...newAttachments,
];
// Emettiamo lo stato assicurandoci che il ServiceModel venga clonato
if (state.currentService != null) {
emit(
state.copyWith(
currentService: state.currentService!.copyWith(files: updatedList),
),
);
}
}
void removeAttachment(int index) {
if (state.currentService == null) return;
final updatedList = List<ServiceFileModel>.from(
state.currentService!.files,
);
updatedList.removeAt(index);
emit(
state.copyWith(
currentService: state.currentService?.copyWith(files: updatedList),
),
);
}
void saveAndCopyFileToCustomer(ServiceFileModel file) async {
final currentService = state.currentService;
if (currentService == null || currentService.customerId == null) {
// Magari mostra un errore: non posso copiare al cliente se non c'è un cliente!
return;
}
emit(state.copyWith(status: ServicesStatus.loading));
try {
// 1. Salviamo la pratica (Bozza o definitiva che sia)
// Questo assicura che il file sia stato caricato su Storage e censito su DB
await saveCurrentService(isBozza: currentService.isBozza);
// 2. Recuperiamo il file "aggiornato"
// Dopo il saveCurrentService, il file che prima era "locale" ora ha un URL.
// Lo cerchiamo nella lista aggiornata per nome o estensione.
final savedFile = state.currentService!.files.firstWhere(
(f) => f.name == file.name && f.extension == file.extension,
orElse: () => file,
);
if (savedFile.url.isEmpty) {
throw Exception(
"Errore: URL del file non trovato dopo il salvataggio.",
);
}
// 3. Chiamiamo il repository per la copia fisica nel database del cliente
// Passiamo l'URL del file e l'ID del cliente
await _repository.copyFileToCustomer(
file: savedFile,
customerId: currentService.customerId!,
);
// 4. Feedback all'utente
// Potresti emettere un successo o mostrare un toast
emit(state.copyWith(status: ServicesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: ServicesStatus.failure,
errorMessage: "Errore durante la copia del file: $e",
),
);
}
}
}

View File

@@ -0,0 +1,54 @@
part of 'services_cubit.dart';
enum ServicesStatus { initial, loading, ready, saving, saved, success, failure }
class ServicesState extends Equatable {
final ServicesStatus status;
final List<ServiceModel> allServices;
final ServiceModel? currentService; // La bozza che stiamo editando
final String? errorMessage;
final String query;
final DateTimeRange? dateRange;
final bool hasReachedMax;
const ServicesState({
required this.status,
this.allServices = const [],
this.currentService,
this.errorMessage,
this.query = '',
this.dateRange,
this.hasReachedMax = false,
});
ServicesState copyWith({
ServicesStatus? status,
List<ServiceModel>? allServices,
ServiceModel? currentService,
String? errorMessage,
String? query,
DateTimeRange? dateRange,
bool? hasReachedMax,
}) {
return ServicesState(
status: status ?? this.status,
allServices: allServices ?? this.allServices,
currentService: currentService ?? this.currentService,
errorMessage: errorMessage,
query: query ?? this.query,
dateRange: dateRange ?? this.dateRange,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
);
}
@override
List<Object?> get props => [
status,
allServices,
currentService,
errorMessage,
query,
dateRange,
hasReachedMax,
];
}

View File

@@ -1,9 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/features/services/models/service_file_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/service_model.dart';
class ServicesRepository {
final _supabase = Supabase.instance.client;
final companyId = GetIt.I.get<SessionBloc>().state.company!.id;
final CustomerRepository _customerRepository = GetIt.I<CustomerRepository>();
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
Future<ServiceModel> fetchServiceById(String id) async {
try {
final response = await _supabase
.from('service')
.select('''
*,
customer(nome),
energy_service(*),
fin_service(*),
entertainment_service(*),
service_file(*)
''')
.eq('id', id)
.single();
return ServiceModel.fromMap(response);
} catch (e) {
throw Exception('Errore nel caricamento del servizio: $e');
}
}
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
Future<List<ServiceModel>> fetchServices({
@@ -19,10 +48,11 @@ class ServicesRepository {
.from('service')
.select('''
*,
customer(name, surname),
customer(nome),
energy_service(*),
fin_service(*),
entertainment_service(*)
entertainment_service(*),
service_file(*)
''')
.eq('company_id', companyId);
@@ -36,7 +66,7 @@ class ServicesRepository {
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or(
'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%,customer.surname.ilike.%$searchTerm%',
'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.nome.ilike.%$searchTerm%',
);
}
@@ -55,8 +85,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 +94,103 @@ 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());
insertTasks.add(
_supabase
.from('entertainment_service')
.insert(
service.entertainmentServices
.map((item) => item.copyWith(serviceId: newId).toMap())
.toList(),
),
);
}
if (insertTasks.isNotEmpty) {
await Future.wait(insertTasks);
}
if (service.files.isNotEmpty) {
final List<Future> uploadTasks = [];
for (var file in service.files) {
final storagePath =
'$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}';
final String mimeType = file.extension.toLowerCase() == 'pdf'
? 'application/pdf'
: 'image/${file.extension}';
final fileToSave = file.copyWith(serviceId: newId, url: storagePath);
// Creiamo una funzione asincrona per caricare file e scrivere nel DB
Future<void> uploadAndLink() async {
// Determiniamo il MIME type corretto in base all'estensione
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
await _supabase.storage
.from('documents')
.uploadBinary(
storagePath,
fileToSave.localBytes!,
fileOptions: FileOptions(
contentType:
mimeType, // Diciamo a Supabase esattamente cos'è!
upsert:
true, // Opzionale: sovrascrive se esiste già un file con lo stesso nome
),
);
await _supabase.from('service_file').insert(fileToSave.toMap());
}
uploadTasks.add(uploadAndLink());
}
await _supabase.from('entertainment_service').insert(toInsert);
// Eseguiamo tutti gli upload in parallelo per la massima velocità
await Future.wait(uploadTasks);
}
} 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');
}
}
@@ -115,4 +202,50 @@ class ServicesRepository {
throw Exception('Errore durante l\'eliminazione: $e');
}
}
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
Future<List<String>> fetchTopEntertainmentTypes(String companyId) async {
try {
// Cerchiamo i tipi più frequenti associati ai servizi di questa company
// Nota: dobbiamo passare attraverso la tabella 'service' per filtrare per company_id
final response = await _supabase
.from('entertainment_service')
.select('type, service!inner(store!inner(company_id))')
.eq('service.store.company_id', companyId)
.limit(100); // Prendiamo un campione
// Logica rapida per contare le occorrenze e prendere i primi 5
final Map<String, int> counts = {};
for (var item in (response as List)) {
final type = item['type'] as String;
counts[type] = (counts[type] ?? 0) + 1;
}
var sortedKeys = counts.keys.toList()
..sort((a, b) => counts[b]!.compareTo(counts[a]!));
return sortedKeys.take(5).toList();
} catch (e) {
return [
"Netflix",
"DAZN",
"Disney+",
"Sky",
]; // Fallback se non c'è ancora storia
}
}
Future<void> copyFileToCustomer({
required ServiceFileModel file,
required String customerId,
}) async {
CustomerFileModel fileToCopy = CustomerFileModel(
customerId: customerId,
name: file.name,
url: file.url,
extension: file.extension,
fileSize: file.fileSize,
);
await _customerRepository.saveCustomerFile(fileToCopy);
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
class ServiceFileModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String name;
final String extension;
final String url;
final String serviceId;
final int fileSize;
final Uint8List? localBytes;
const ServiceFileModel({
this.id,
this.createdAt,
required this.name,
required this.extension,
required this.url,
required this.serviceId,
required this.fileSize,
this.localBytes,
});
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (fileSize.toString().length - 1) ~/ 3;
if (i >= suffixes.length) i = suffixes.length - 1;
double num = fileSize / (1 << (i * 10));
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
ServiceFileModel copyWith({
String? id,
DateTime? createdAt,
String? name,
String? extension,
String? url,
String? serviceId,
int? fileSize,
Uint8List? localBytes,
}) {
return ServiceFileModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
name: name ?? this.name,
extension: extension ?? this.extension,
url: url ?? this.url,
serviceId: serviceId ?? this.serviceId,
fileSize: fileSize ?? this.fileSize,
localBytes: localBytes ?? this.localBytes,
);
}
factory ServiceFileModel.fromMap(Map<String, dynamic> map) {
return ServiceFileModel(
id: map['id'] as String,
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
name: map['name'] ?? '',
extension: map['extension'] ?? '',
url: map['url'] ?? '',
serviceId: map['service_id']?.toString() ?? '',
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'name': name,
'extension': extension,
'url': url,
'service_id': serviceId,
'file_size': fileSize,
};
}
@override
List<Object?> get props => [
id,
createdAt,
name,
extension,
url,
serviceId,
fileSize,
localBytes,
];
}

View File

@@ -1,7 +1,9 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/services/models/energy_service_model.dart';
import 'package:flux/features/services/models/entertainment_service_model.dart';
import 'package:flux/features/services/models/fin_service_model.dart';
import 'package:flux/features/services/models/service_file_model.dart'; // <-- Aggiunto Import
class ServiceModel extends Equatable {
final String? id;
@@ -14,6 +16,7 @@ class ServiceModel extends Equatable {
final String note;
final bool resultOk;
final String? customerDisplayName;
final String companyId;
// Telefonia
final int al;
@@ -27,6 +30,9 @@ class ServiceModel extends Equatable {
final List<FinServiceModel> finServices;
final List<EntertainmentServiceModel> entertainmentServices;
// ALLEGATI (Aggiunto)
final List<ServiceFileModel> files;
const ServiceModel({
this.id,
this.createdAt,
@@ -45,7 +51,9 @@ class ServiceModel extends Equatable {
this.energyServices = const [],
this.finServices = const [],
this.entertainmentServices = const [],
this.files = const [], // <-- Aggiunto default vuoto
this.customerDisplayName,
required this.companyId,
});
ServiceModel copyWith({
@@ -66,7 +74,9 @@ class ServiceModel extends Equatable {
List<EnergyServiceModel>? energyServices,
List<FinServiceModel>? finServices,
List<EntertainmentServiceModel>? entertainmentServices,
List<ServiceFileModel>? files, // <-- Aggiunto
String? customerDisplayName,
String? companyId,
}) {
return ServiceModel(
id: id ?? this.id,
@@ -87,7 +97,9 @@ class ServiceModel extends Equatable {
finServices: finServices ?? this.finServices,
entertainmentServices:
entertainmentServices ?? this.entertainmentServices,
files: files ?? this.files, // <-- Aggiunto
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
companyId: companyId ?? this.companyId,
);
}
@@ -110,17 +122,21 @@ class ServiceModel extends Equatable {
energyServices,
finServices,
entertainmentServices,
files, // <-- Aggiunto
customerDisplayName,
companyId,
];
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 +146,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 +162,19 @@ class ServiceModel extends Equatable {
?.map((x) => EntertainmentServiceModel.fromMap(x))
.toList() ??
const [],
// I FILE! (Assicurati che la foreign key su Supabase usi esattamente questo nome)
files:
(map['service_file'] as List?)
?.map((x) => ServiceFileModel.fromMap(x))
.toList() ??
const [],
// Display name del cliente con fallback
customerDisplayName: map['customer'] != null
? "${map['customer']['name']} ${map['customer']['surname']}"
: "Cliente sconosciuto",
? "${map['customer']['nome'] ?? ''}".myFormat()
: "Cliente non assegnato",
companyId: map['company_id'] as String,
);
}
@@ -167,6 +193,7 @@ class ServiceModel extends Equatable {
'nip': nip,
'unica': unica,
'telepass': telepass,
'company_id': companyId,
// Le liste non le mettiamo qui perché vanno in tabelle diverse!
};
}

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

View File

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

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
class ActionCard extends StatelessWidget {
final String label;
final int count;
final IconData icon;
final Color color;
final VoidCallback onTap;
const ActionCard({
super.key,
required this.label,
required this.count,
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final isActive = count > 0;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 110, // Larghezza fissa per avere una griglia ordinata
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
decoration: BoxDecoration(
color: isActive
? color.withValues(alpha: 0.15)
: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isActive ? color : Colors.grey.withValues(alpha: 0.3),
width: isActive ? 2 : 1,
),
boxShadow: isActive
? [
BoxShadow(
color: color.withValues(alpha: 0.2),
blurRadius: 8,
spreadRadius: 1,
),
]
: [],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: isActive ? color : Colors.grey, size: 28),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? color : Colors.grey.shade700,
),
textAlign: TextAlign.center,
),
if (isActive) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Text(
count.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,184 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_file_model.dart';
class AttachmentsSection extends StatelessWidget {
const AttachmentsSection({super.key});
Future<void> _pickFiles(BuildContext context) async {
// Usiamo withData: true fondamentale per avere i bytes e caricare su Supabase Storage
FilePickerResult? result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
withData: true,
);
if (result != null && context.mounted) {
context.read<ServicesCubit>().addAttachments(result.files);
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ServicesCubit, ServicesState>(
builder: (context, state) {
final files = state.currentService?.files ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"DOCUMENTI ALLEGATI",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
letterSpacing: 1.2,
),
),
OutlinedButton.icon(
icon: const Icon(Icons.attach_file),
label: const Text("Aggiungi File"),
onPressed: () => _pickFiles(context),
),
],
),
const SizedBox(height: 12),
if (files.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade300,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade50,
),
child: const Text(
"Nessun documento allegato alla bozza.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: files.length,
itemBuilder: (context, index) {
final file = files[index];
// Calcoliamo la dimensione in MB
final sizeMb = (file.fileSize / (1024 * 1024))
.toStringAsFixed(2);
// Scegliamo un'icona in base al tipo di file
final isPdf = file.extension.toLowerCase() == 'pdf';
return GestureDetector(
onTap: () => _handleSingleClick(context, file),
onDoubleTap: () => _handleDoubleClick(context, file),
child: Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Colors.grey.shade300),
),
child: ListTile(
leading: Icon(
isPdf ? Icons.picture_as_pdf : Icons.image,
color: isPdf ? Colors.red : Colors.blue,
size: 32,
),
title: Text(
file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text("$sizeMb MB"),
trailing: IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.red,
),
onPressed: () => context
.read<ServicesCubit>()
.removeAttachment(index),
),
),
),
);
},
),
],
);
},
);
}
// --- LOGICA DI COPIA AL CLIENTE ---
void _handleSingleClick(BuildContext context, ServiceFileModel file) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Copia nei documenti Cliente"),
content: const Text(
"Vuoi copiare questo file nell'anagrafica del cliente? \n\n"
"Attenzione: per procedere, la pratica attuale verrà prima salvata in stato BOZZA.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
// 1. Diciamo al Cubit di salvare in Bozza e fare la copia
context.read<ServicesCubit>().saveAndCopyFileToCustomer(file);
},
child: const Text("Salva e Copia"),
),
],
),
);
}
// --- LOGICA DI VISUALIZZAZIONE OVERLAY ---
void _handleDoubleClick(BuildContext context, ServiceFileModel file) {
showDialog(
context: context,
barrierDismissible: true,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height * 0.8,
child: file.isPdf
? PdfViewerWidget(
storagePath: file.url.isNotEmpty ? file.url : null,
bytes: file.localBytes,
)
: ImageViewerWidget(
storagePath: file.url.isNotEmpty ? file.url : null,
bytes: file.localBytes,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flux/features/customers/ui/customer_search_sheet.dart';
import 'package:flux/features/services/models/service_model.dart';
class CustomerSection extends StatelessWidget {
final ServiceModel service;
const CustomerSection({super.key, required this.service});
void _openCustomerSearch(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (modalContext) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(modalContext).viewInsets.bottom,
),
// La modale di ricerca
child: const CustomerSearchSheet(),
);
},
);
}
@override
Widget build(BuildContext context) {
// Niente BlocBuilder qui! Leggiamo solo la variabile 'service'
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),
if (!hasCustomer)
Center(
child: ElevatedButton.icon(
onPressed: () => _openCustomerSearch(context),
icon: const Icon(Icons.search),
label: const Text("Seleziona o Crea Cliente"),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
)
else
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
service.customerDisplayName ?? "Cliente Selezionato",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
TextButton.icon(
onPressed: () => _openCustomerSearch(context),
icon: const Icon(Icons.edit, size: 18),
label: const Text("Cambia"),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,417 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/services/models/energy_service_model.dart'; // Assicurati degli import
class EnergyServiceDialog extends StatefulWidget {
final List<EnergyServiceModel> initialServices;
final String
currentStoreId; // Ci serve per sapere per quale negozio caricare i gestori
const EnergyServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
});
@override
State<EnergyServiceDialog> createState() => _EnergyServiceDialogState();
}
class _EnergyServiceDialogState extends State<EnergyServiceDialog> {
// Lista temporanea per non "sporcare" il cubit finché non si preme Conferma
late List<EnergyServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Al caricamento della modale, chiediamo al Cubit di recuperare i gestori veri!
context.read<ProvidersCubit>().loadActiveProvidersForStore(
widget.currentStoreId,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(Icons.bolt, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(_isAddingNew ? "Nuovo Contratto" : "Servizi Energia"),
],
),
content: AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: SizedBox(
width: double.maxFinite,
// Cambia vista in base al flag
child: _isAddingNew
? _EnergyForm(
onSave: (newService) {
setState(() {
_tempList.add(newService);
_isAddingNew = false; // Torna alla lista
});
},
onCancel: () {
setState(() => _isAddingNew = false);
},
)
: _EnergyList(
services: _tempList,
onDelete: (index) {
setState(() => _tempList.removeAt(index));
},
onAddTap: () {
setState(() => _isAddingNew = true); // Passa al form
},
activeProviders: [
// Passiamo i provider attivi filtrati per tipo Energia
...context
.read<ProvidersCubit>()
.state
.activeProviders
.where((p) => p.energia == true),
],
),
),
),
actions: [
if (!_isAddingNew) ...[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, _tempList),
child: const Text("Conferma Tutti"),
),
],
],
);
}
}
// ==========================================
// VISTA 1: LA LISTA DEI CONTRATTI
// ==========================================
class _EnergyList extends StatelessWidget {
final List<EnergyServiceModel> services;
final List<ProviderModel>
activeProviders; // <--- NUOVO: La lista vera dal Cubit
final Function(int) onDelete;
final VoidCallback onAddTap;
const _EnergyList({
required this.services,
required this.activeProviders, // <--- Richiesto
required this.onDelete,
required this.onAddTap,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (services.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Text(
"Nessun contratto energia inserito.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: services.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final s = services[index];
final isLuce = s.type == EnergyType.luce;
// LA MAGIA: Troviamo il nome partendo dall'ID salvato nel servizio
final providerIndex = activeProviders.indexWhere(
(p) => p.id == s.providerId,
);
final providerName = providerIndex >= 0
? (activeProviders[providerIndex].nome)
: 'Gestore Rimosso/Sconosciuto';
// Formattazione data pulita (es. 04/09/2025)
final day = s.expiration.day.toString().padLeft(2, '0');
final month = s.expiration.month.toString().padLeft(2, '0');
final formattedDate = "$day/$month/${s.expiration.year}";
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: isLuce
? Colors.orange.shade100
: Colors.blue.shade100,
child: Icon(
isLuce
? Icons.lightbulb_outline
: Icons.local_fire_department,
color: isLuce ? Colors.orange : Colors.blue,
),
),
title: Text(
providerName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text("Scadenza: $formattedDate"),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => onDelete(index),
),
);
},
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi Contratto"),
),
],
);
}
}
// ==========================================
// VISTA 2: IL FORM DI INSERIMENTO
// ==========================================
class _EnergyForm extends StatefulWidget {
final Function(EnergyServiceModel) onSave;
final VoidCallback onCancel;
const _EnergyForm({required this.onSave, required this.onCancel});
@override
State<_EnergyForm> createState() => _EnergyFormState();
}
class _EnergyFormState extends State<_EnergyForm> {
EnergyType _selectedType = EnergyType.luce;
String? _selectedProviderId;
DateTime? _selectedExpiration;
int? _selectedMonthsPreset;
void _applyPreset(int? months) {
if (months == null) return;
setState(() {
_selectedMonthsPreset = months;
// Calcoliamo la data: oggi + X mesi
final now = DateTime.now();
_selectedExpiration = DateTime(now.year, now.month + months, now.day);
});
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now().add(
const Duration(days: 365),
), // Default 1 anno
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
);
if (picked != null) {
setState(() => _selectedExpiration = picked);
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. Tipo (Luce o Gas) - Segmented Button stile M3
SegmentedButton<EnergyType>(
segments: const [
ButtonSegment(
value: EnergyType.luce,
label: Text("Luce"),
icon: Icon(Icons.lightbulb_outline),
),
ButtonSegment(
value: EnergyType.gas,
label: Text("Gas"),
icon: Icon(Icons.local_fire_department),
),
],
selected: {_selectedType},
onSelectionChanged: (Set<EnergyType> newSelection) {
setState(() => _selectedType = newSelection.first);
},
),
const SizedBox(height: 20),
// 2. SCADENZA INTELLIGENTE (La parte PRO)
const Text(
"Scadenza Contratto",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 8),
SegmentedButton<int?>(
showSelectedIcon: false, // Per un look più pulito
segments: const [
ButtonSegment(value: 12, label: Text("12m")),
ButtonSegment(value: 24, label: Text("24m")),
ButtonSegment(value: 36, label: Text("36m")),
ButtonSegment(
value: null,
label: Icon(Icons.calendar_month, size: 20),
),
],
selected: {_selectedMonthsPreset},
onSelectionChanged: (Set<int?> newSelection) {
final val = newSelection.first;
if (val == null) {
_pickDate(); // Se clicca l'icona calendario, apre il picker
} else {
_applyPreset(val); // Altrimenti applica 12, 24 o 36
}
},
),
const SizedBox(height: 12),
// Visualizzazione della data calcolata (o scelta)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _selectedExpiration != null
? Theme.of(context).colorScheme.primary
: Colors.transparent,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event,
size: 18,
color: _selectedExpiration != null
? Theme.of(context).colorScheme.primary
: Colors.grey,
),
const SizedBox(width: 8),
Text(
_selectedExpiration != null
? "Scade il: ${_selectedExpiration!.day.toString().padLeft(2, '0')}/${_selectedExpiration!.month.toString().padLeft(2, '0')}/${_selectedExpiration!.year}"
: "Seleziona una scadenza",
style: TextStyle(
fontWeight: FontWeight.bold,
color: _selectedExpiration != null
? Theme.of(context).colorScheme.onSurface
: Colors.grey,
),
),
],
),
),
const SizedBox(height: 20),
// 2. Provider Dropdown
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: LinearProgressIndicator(),
); // Mostra una barretta di caricamento
}
if (state.activeProviders.isEmpty) {
return const Text(
"Nessun gestore associato a questo negozio.",
style: TextStyle(color: Colors.red),
);
}
// Filtra solo i provider di tipo Energia (Se hai una categoria nel modello)
// Se non hai una categoria nel ProviderModel, puoi rimuovere il .where
final energyProviders = state.activeProviders;
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: "Gestore / Provider",
border: OutlineInputBorder(),
),
initialValue: _selectedProviderId,
items: energyProviders.map((p) {
return DropdownMenuItem(value: p.id, child: Text(p.nome));
}).toList(),
onChanged: (val) => setState(() => _selectedProviderId = val),
);
},
),
const SizedBox(height: 16),
// 3. Scadenza (DatePicker integrato in un TextField)
TextFormField(
readOnly: true,
onTap: _pickDate,
decoration: InputDecoration(
labelText: "Data Scadenza",
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.calendar_month),
),
// Mostra la data se selezionata, altrimenti vuoto
controller: TextEditingController(
text: _selectedExpiration != null
? "${_selectedExpiration!.day}/${_selectedExpiration!.month}/${_selectedExpiration!.year}"
: "",
),
),
const SizedBox(height: 24),
// 4. Pulsanti Interni al Form
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text("Indietro"),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed:
(_selectedProviderId == null || _selectedExpiration == null)
? null // Disabilitato se mancano dati obbligatori
: () {
final newService = EnergyServiceModel(
type: _selectedType,
expiration: _selectedExpiration!,
providerId: _selectedProviderId!,
);
widget.onSave(newService);
},
child: const Text("Salva Contratto"),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
import 'package:flux/features/services/data/services_repository.dart';
import 'package:flux/features/services/models/entertainment_service_model.dart';
import 'package:get_it/get_it.dart';
class EntertainmentServiceDialog extends StatefulWidget {
final List<EntertainmentServiceModel> initialServices;
final String currentStoreId;
const EntertainmentServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
});
@override
State<EntertainmentServiceDialog> createState() =>
_EntertainmentServiceDialogState();
}
class _EntertainmentServiceDialogState
extends State<EntertainmentServiceDialog> {
late List<EntertainmentServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Carichiamo i provider attivi per lo store corrente
context.read<ProvidersCubit>().loadActiveProvidersForStore(
widget.currentStoreId,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(
Icons.movie_filter_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(_isAddingNew ? "Nuovo Servizio" : "Servizi Intrattenimento"),
],
),
content: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: _isAddingNew
? _EntertainmentForm(
// Il form che abbiamo creato prima
onSave: (newService) => setState(() {
_tempList.add(newService);
_isAddingNew = false;
}),
onCancel: () => setState(() => _isAddingNew = false),
)
: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
// Passiamo allProviders per garantire la visione dello storico
return _EntertainmentList(
services: _tempList,
allProviders: state.allProviders,
onDelete: (index) =>
setState(() => _tempList.removeAt(index)),
onAddTap: () => setState(() => _isAddingNew = true),
);
},
),
),
),
actions: !_isAddingNew
? [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, _tempList),
child: const Text("Conferma Tutti"),
),
]
: null, // I pulsanti del form sono interni al form stesso
);
}
}
class _EntertainmentList extends StatelessWidget {
final List<EntertainmentServiceModel> services;
final List<ProviderModel> allProviders;
final Function(int) onDelete;
final VoidCallback onAddTap;
const _EntertainmentList({
required this.services,
required this.allProviders,
required this.onDelete,
required this.onAddTap,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (services.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Text(
"Nessun servizio intrattenimento.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: services.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final s = services[index];
final providerName = allProviders
.firstWhere(
(p) => p.id == s.providerId,
orElse: () => ProviderModel(
id: '',
nome: 'Fornitore Storico',
companyId: '',
isActive: false,
energia: false,
telefoniaFissa: false,
telefoniaMobile: false,
assicurazioni: false,
finanziamenti: false,
altro: false,
intrattenimento: false,
),
)
.nome;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Colors.purple.shade100,
child: const Icon(
Icons.movie_creation_outlined,
color: Colors.purple,
),
),
title: Text(
"${s.type}$providerName",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
s.constrained
? "Vincolo fino al: ${s.constrainExpiration.day}/${s.constrainExpiration.month}/${s.constrainExpiration.year}"
: "Senza vincoli",
style: TextStyle(
color: s.constrained
? Colors.red.shade700
: Colors.green.shade700,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => onDelete(index),
),
);
},
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi Servizio"),
),
],
);
}
}
// ---ENTERTAINMENT FORM (MODALE)---
class _EntertainmentForm extends StatefulWidget {
final Function(EntertainmentServiceModel) onSave;
final VoidCallback onCancel;
const _EntertainmentForm({required this.onSave, required this.onCancel});
@override
State<_EntertainmentForm> createState() => _EntertainmentFormState();
}
class _EntertainmentFormState extends State<_EntertainmentForm> {
String? _selectedProviderId;
final TextEditingController _typeController = TextEditingController();
bool _isConstrained = false;
DateTime _expirationDate = DateTime.now().add(
const Duration(days: 365),
); // Default 12 mesi
// Preset rapidi per il vincolo (es: 12, 24 mesi)
int? _selectedPresetMonths;
void _applyPreset(int months) {
setState(() {
_selectedPresetMonths = months;
_isConstrained = true;
final now = DateTime.now();
_expirationDate = DateTime(now.year, now.month + months, now.day);
});
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _expirationDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
);
if (picked != null) {
setState(() {
_expirationDate = picked;
_selectedPresetMonths = null;
_isConstrained = true;
});
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. GESTORE (Filtro intrattenimento)
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
final filtered = state.activeProviders
.where((p) => p.intrattenimento)
.toList();
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: "Fornitore (es: Sky, TIM)",
border: OutlineInputBorder(),
),
items: filtered
.map(
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
)
.toList(),
onChanged: (val) => setState(() => _selectedProviderId = val),
);
},
),
const SizedBox(height: 16),
// 2. TIPO SERVIZIO (TextField con suggerimenti rapidi sotto)
TextFormField(
controller: _typeController,
decoration: const InputDecoration(
labelText: "Servizio",
hintText: "es: Netflix, DAZN, Disney+",
border: OutlineInputBorder(),
),
onChanged: (val) => setState(() {}),
),
const SizedBox(height: 8),
// Suggerimenti rapidi (Chip)
FutureBuilder<List<String>>(
future: GetIt.I<ServicesRepository>().fetchTopEntertainmentTypes(
GetIt.I<SessionBloc>().state.company!.id,
),
builder: (context, snapshot) {
final suggestions = snapshot.data ?? ["Netflix", "DAZN", "Sky"];
return Wrap(
spacing: 8,
children: suggestions.map((s) {
return ActionChip(
label: Text(s, style: const TextStyle(fontSize: 12)),
onPressed: () => setState(() => _typeController.text = s),
);
}).toList(),
);
},
),
const SizedBox(height: 16),
// 3. VINCOLO CONTRATTUALE
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Vincolo di permanenza",
style: TextStyle(fontWeight: FontWeight.bold),
),
Switch(
value: _isConstrained,
onChanged: (val) => setState(() {
_isConstrained = val;
if (!val) _selectedPresetMonths = null;
}),
),
],
),
if (_isConstrained) ...[
const SizedBox(height: 8),
SegmentedButton<int?>(
segments: const [
ButtonSegment(value: 12, label: Text("12m")),
ButtonSegment(value: 24, label: Text("24m")),
ButtonSegment(
value: null,
label: Icon(Icons.calendar_month, size: 20),
),
],
selected: {_selectedPresetMonths},
onSelectionChanged: (val) {
if (val.first == null) {
_pickDate();
} else {
_applyPreset(val.first!);
}
},
),
const SizedBox(height: 12),
// Box data scadenza vincolo
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.event_busy, size: 18, color: Colors.redAccent),
const SizedBox(width: 8),
Text(
"Scadenza vincolo: ${_expirationDate.day.toString().padLeft(2, '0')}/${_expirationDate.month.toString().padLeft(2, '0')}/${_expirationDate.year}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
],
const SizedBox(height: 24),
// PULSANTI
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text("Annulla"),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed:
(_selectedProviderId == null || _typeController.text.isEmpty)
? null
: () => widget.onSave(
EntertainmentServiceModel(
providerId: _selectedProviderId!,
type: _typeController.text,
constrained: _isConstrained,
constrainExpiration: _expirationDate,
),
),
child: const Text("Aggiungi"),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,479 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/master_data/products/models/model_model.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/services/models/fin_service_model.dart';
import 'package:flux/features/master_data/providers/models/provider_model.dart';
// ===========================================================================
// DIALOG PRINCIPALE
// ===========================================================================
class FinanceServiceDialog extends StatefulWidget {
final List<FinServiceModel> initialServices;
final String currentStoreId;
final ProductCubit productCubit;
const FinanceServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
required this.productCubit,
});
@override
State<FinanceServiceDialog> createState() => _FinanceServiceDialogState();
}
class _FinanceServiceDialogState extends State<FinanceServiceDialog> {
late List<FinServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Carichiamo i dati necessari dai Cubit
context.read<ProvidersCubit>().loadActiveProvidersForStore(
widget.currentStoreId,
);
context.read<ProductCubit>().loadBrands();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: widget.productCubit,
child: AlertDialog(
title: Row(
children: [
Icon(
Icons.payments_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(_isAddingNew ? "Dettagli Finanziamento" : "Finanziamenti"),
],
),
content: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: _isAddingNew
? _FinanceForm(
onSave: (newFin) => setState(() {
_tempList.add(newFin);
_isAddingNew = false;
}),
onCancel: () => setState(() => _isAddingNew = false),
)
: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, provState) {
return BlocBuilder<ProductCubit, ProductState>(
builder: (context, prodState) {
return _FinanceList(
services: _tempList,
allProviders:
provState.allProviders, // Per vedere lo storico
allModels: prodState.models,
onDelete: (index) =>
setState(() => _tempList.removeAt(index)),
onAddTap: () => setState(() => _isAddingNew = true),
);
},
);
},
),
),
),
actions: !_isAddingNew
? [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, _tempList),
child: const Text("Conferma"),
),
]
: null,
),
);
}
}
// ===========================================================================
// VISTA LISTA (STORICA)
// ===========================================================================
class _FinanceList extends StatelessWidget {
final List<FinServiceModel> services;
final List<ProviderModel> allProviders;
final List<ModelModel> allModels;
final Function(int) onDelete;
final VoidCallback onAddTap;
const _FinanceList({
required this.services,
required this.allProviders,
required this.allModels,
required this.onDelete,
required this.onAddTap,
});
@override
Widget build(BuildContext context) {
if (services.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Text(
"Nessun finanziamento inserito.",
style: TextStyle(color: Colors.grey),
),
),
OutlinedButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi primo"),
),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: services.length,
separatorBuilder: (_, _) => const Divider(),
itemBuilder: (context, index) {
final s = services[index];
// Cerchiamo il nome del provider in TUTTI quelli caricati (storico)
final providerName = allProviders
.firstWhere(
(p) => p.id == s.providerId,
orElse: () => ProviderModel(
id: '',
nome: 'Operatore Storico',
companyId: '',
isActive: false,
energia: false,
telefoniaFissa: false,
telefoniaMobile: false,
assicurazioni: false,
altro: false,
intrattenimento: false,
finanziamenti: false,
),
)
.nome;
// Cerchiamo il nome del modello
final modelName = allModels
.firstWhere(
(m) => m.id == s.modelId,
orElse: () => ModelModel(
id: '',
name: 'Prodotto',
nameWithBrand: 'Prodotto Storico',
brandId: '',
),
)
.nameWithBrand;
final dateStr =
"${s.expiration.day.toString().padLeft(2, '0')}/${s.expiration.month.toString().padLeft(2, '0')}/${s.expiration.year}";
return ListTile(
title: Text(
modelName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text("$providerName • Scade: $dateStr"),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => onDelete(index),
),
);
},
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi altro"),
),
],
);
}
}
// ===========================================================================
// FORM CON OMNI-SEARCH
// ===========================================================================
class _FinanceForm extends StatefulWidget {
final Function(FinServiceModel) onSave;
final VoidCallback onCancel;
const _FinanceForm({required this.onSave, required this.onCancel});
@override
State<_FinanceForm> createState() => _FinanceFormState();
}
class _FinanceFormState extends State<_FinanceForm> {
String? _selectedProviderId;
ModelModel? _selectedModel;
int _selectedMonths = 30; // Default richiesto
Timer? _debounce;
final TextEditingController _searchController = TextEditingController();
late DateTime _selectedExpirationDate;
@override
void initState() {
super.initState();
final now = DateTime.now();
_selectedExpirationDate = DateTime(
now.year,
now.month + _selectedMonths,
now.day,
); // Inizialmente 30 mesi dalla data attuale
}
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
context.read<ProductCubit>().searchModels(query);
});
}
// Funzione per aggiornare la data quando si clicca sui segmenti 24, 30, 48
void _updateExpirationByMonths(int months) {
setState(() {
_selectedMonths = months;
final now = DateTime.now();
// Calcolo preciso: aggiungiamo i mesi alla data attuale
_selectedExpirationDate = DateTime(now.year, now.month + months, now.day);
});
}
// Funzione per il picker manuale
Future<void> _selectManualDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedExpirationDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(
const Duration(days: 365 * 10),
), // Fino a 10 anni
);
if (picked != null && picked != _selectedExpirationDate) {
setState(() {
_selectedExpirationDate = picked;
_selectedMonths = 0; // Resettiamo i segmenti perché è una data custom
});
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. SCELTA ISTITUTO (Solo attivi)
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
final finProviders = state.activeProviders
.where((p) => p.finanziamenti)
.toList(); // Già filtrati dal caricamento della dialog
return DropdownButtonFormField<String>(
initialValue: _selectedProviderId,
decoration: const InputDecoration(
labelText: "Gestore",
border: OutlineInputBorder(),
),
items: finProviders
.map(
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
)
.toList(),
onChanged: (val) => setState(() => _selectedProviderId = val),
);
},
),
const SizedBox(height: 16),
// 2. RICERCA MODELLO
if (_selectedModel == null) ...[
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Cerca modello (es: iPhone...)",
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => _showQuickCreate(context),
),
),
onChanged: (val) {
_onSearchChanged(val);
},
),
const SizedBox(height: 8),
_buildSearchSuggestions(),
] else
Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: ListTile(
leading: const Icon(Icons.phone_android),
title: Text(
_selectedModel!.nameWithBrand,
style: const TextStyle(fontWeight: FontWeight.bold),
),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() => _selectedModel = null),
),
),
),
const SizedBox(height: 16),
// 3. DURATA PRESET
const Text(
"Durata Rate",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 24, label: Text("24m")),
ButtonSegment(value: 30, label: Text("30m")),
ButtonSegment(value: 48, label: Text("48m")),
],
selected: {_selectedMonths},
onSelectionChanged: (val) => _updateExpirationByMonths(val.first),
),
const SizedBox(height: 16),
// RIEPILOGO DATA E PICKER MANUALE (Stile Energia)
const Text(
"Scadenza Finanziamento",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 4),
InkWell(
onTap: _selectManualDate,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(
Icons.calendar_today,
size: 18,
color: Colors.blue,
),
const SizedBox(width: 12),
Text(
"${_selectedExpirationDate.day.toString().padLeft(2, '0')}/${_selectedExpirationDate.month.toString().padLeft(2, '0')}/${_selectedExpirationDate.year}",
style: const TextStyle(fontSize: 16),
),
],
),
const Icon(Icons.edit, size: 18, color: Colors.grey),
],
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text("Indietro"),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: (_selectedProviderId == null || _selectedModel == null)
? null
: () {
final now = DateTime.now();
widget.onSave(
FinServiceModel(
providerId: _selectedProviderId!,
modelId: _selectedModel!.id!,
expiration: DateTime(
now.year,
now.month + _selectedMonths,
now.day,
),
),
);
},
child: const Text("Salva"),
),
],
),
],
);
}
Widget _buildSearchSuggestions() {
return BlocBuilder<ProductCubit, ProductState>(
builder: (context, state) {
final query = _searchController.text.toLowerCase();
if (query.isEmpty) return const SizedBox.shrink();
final filtered = state.models
.where((m) => m.nameWithBrand.toLowerCase().contains(query))
.take(3)
.toList();
return Column(
children: filtered
.map(
(m) => ListTile(
title: Text(m.nameWithBrand),
onTap: () => setState(() => _selectedModel = m),
dense: true,
),
)
.toList(),
);
},
);
}
void _showQuickCreate(BuildContext context) {
// Implementazione rapida dialog creazione Brand/Modello come discusso prima
}
}

View File

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

View File

@@ -0,0 +1,158 @@
import 'dart:async'; // Necessario per il Timer
import 'package:flutter/material.dart';
Future<void> updateCountDialog(
BuildContext context,
String title,
int currentValue,
Function(int) onSave,
) async {
int tempValue =
currentValue; // Variabile locale per gestire il conteggio nella dialog
final result = await showDialog<int>(
context: context,
builder: (context) => AlertDialog(
title: Text("Imposta $title"),
content: QuickCounter(
initialValue: tempValue,
onChanged: (val) => tempValue =
val, // Aggiorna il valore locale quando il counter cambia
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, tempValue),
child: const Text("Conferma"),
),
],
),
);
if (result != null) {
onSave(result);
}
}
// --- Widget Interno Specifico per il Counter Veloce ---
class QuickCounter extends StatefulWidget {
final int initialValue;
final ValueChanged<int>
onChanged; // Callback per notificare il padre dei cambiamenti
const QuickCounter({
super.key,
required this.initialValue,
required this.onChanged,
});
@override
State<QuickCounter> createState() => _QuickCounterState();
}
class _QuickCounterState extends State<QuickCounter> {
late int _value;
Timer? _longPressTimer; // Il timer per l'auto-incremento
@override
void initState() {
super.initState();
_value = widget.initialValue;
}
@override
void dispose() {
_longPressTimer
?.cancel(); // IMPORTANTE: Annulla sempre il timer alla distruzione
super.dispose();
}
// Logica comune per incremento/decremento singolo o rapido
void _update(int delta) {
setState(() {
_value += delta;
if (_value < 0) _value = 0; // Impedisci numeri negativi
});
widget.onChanged(_value); // Notifica il padre
}
// Gestione dell'inizio della pressione prolungata
void _startLongPress(int delta) {
_update(delta); // Esegui subito il primo aggiornamento al tocco iniziale
_longPressTimer = Timer.periodic(const Duration(milliseconds: 100), (
timer,
) {
_update(delta); // Aggiorna velocemente finché la pressione continua
});
}
// Gestione della fine della pressione prolungata
void _stopLongPress() {
_longPressTimer?.cancel();
}
@override
Widget build(BuildContext context) {
final canDecrement = _value > 0;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// --- Pulsante MENO ---
GestureDetector(
onLongPressStart: canDecrement ? (_) => _startLongPress(-1) : null,
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: () => _stopLongPress(),
onTap: canDecrement ? () => _update(-1) : null,
child: Opacity(
// Visivamente disabilitato se < 0
opacity: canDecrement ? 1.0 : 0.4,
child: const ActionButton(icon: Icons.remove, color: Colors.red),
),
),
// --- Valore Centrale ---
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
_value.toString(),
style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
),
),
// --- Pulsante PIU' ---
GestureDetector(
onLongPressStart: (_) => _startLongPress(1),
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: () => _stopLongPress(),
onTap: () => _update(1),
child: const ActionButton(icon: Icons.add, color: Colors.green),
),
],
);
}
}
// Piccolo widget di utilità per l'aspetto del pulsante
class ActionButton extends StatelessWidget {
final IconData icon;
final Color color;
const ActionButton({super.key, required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
border: Border.all(color: color, width: 2),
),
child: Icon(icon, color: color, size: 30),
);
}
}

View File

@@ -0,0 +1,173 @@
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/ui/service_form_screen/attachment_section.dart';
import 'package:flux/features/services/ui/service_form_screen/customer_section.dart';
import 'package:flux/features/services/ui/service_form_screen/general_info_section.dart';
import 'package:flux/features/services/ui/service_form_screen/services_grid.dart';
class ServiceFormScreen extends StatefulWidget {
final String? serviceId;
final ServiceModel? existingService; // <-- AGGIUNTO
const ServiceFormScreen({
super.key,
this.serviceId,
this.existingService, // <-- AGGIUNTO
});
@override
State<ServiceFormScreen> createState() => _ServiceFormScreenState();
}
class _ServiceFormScreenState extends State<ServiceFormScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// Diamo in pasto al Cubit tutto quello che abbiamo!
context.read<ServicesCubit>().initServiceForm(
existingService: widget.existingService,
serviceId: widget.serviceId,
);
});
}
void _performSave(BuildContext context, {required bool isBozza}) {
FocusScope.of(context).unfocus();
context.read<ServicesCubit>().saveCurrentService(isBozza: isBozza);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<ServicesCubit, ServicesState>(
listener: (context, state) {
if (state.status == ServicesStatus.saved) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Pratica salvata con successo!"),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
} else if (state.status == ServicesStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Errore: ${state.errorMessage ?? ''}"),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
final service = state.currentService;
final isSaving = state.status == ServicesStatus.saving;
final isEditMode = widget.serviceId != null;
return Scaffold(
appBar: AppBar(
title: Text(isEditMode ? "Modifica Pratica" : "Nuova Pratica"),
actions: [
if (isSaving)
const Padding(
padding: EdgeInsets.only(right: 20.0),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else if (service != null) ...[
IconButton(
icon: const Icon(Icons.edit_note),
tooltip: "Salva come Bozza",
onPressed: () => _performSave(context, isBozza: true),
),
IconButton(
icon: const Icon(
Icons.check_circle_outline,
color: Colors.green,
),
tooltip: "Conferma Pratica",
onPressed: () => _performSave(context, isBozza: false),
),
const SizedBox(width: 8),
],
],
),
body: (service == null)
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomerSection(service: service),
const SizedBox(height: 24),
GeneralInfoSection(service: service),
const SizedBox(height: 24),
ServicesGrid(service: service),
const SizedBox(height: 32),
AttachmentsSection(),
const SizedBox(height: 32),
_buildBottomActionButtons(context, isSaving: isSaving),
const SizedBox(height: 32),
],
),
),
);
},
);
}
Widget _buildBottomActionButtons(
BuildContext context, {
required bool isSaving,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
flex: 1,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
icon: const Icon(Icons.edit_note),
label: const Text("Salva in Bozza"),
onPressed: isSaving
? null
: () => _performSave(context, isBozza: true),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade600,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
icon: const Icon(Icons.check_circle_outline),
label: const Text(
"CONFERMA PRATICA",
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1),
),
onPressed: isSaving
? null
: () => _performSave(context, isBozza: false),
),
),
],
);
}
}

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/energy_service_model.dart';
import 'package:flux/features/services/models/entertainment_service_model.dart';
import 'package:flux/features/services/models/fin_service_model.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:flux/features/services/ui/service_form_screen/action_card.dart';
import 'package:flux/features/services/ui/service_form_screen/energy_service_dialog.dart';
import 'package:flux/features/services/ui/service_form_screen/entertainment_service_card.dart';
import 'package:flux/features/services/ui/service_form_screen/finance_service_dialog.dart';
import 'package:flux/features/services/ui/service_form_screen/int_dialogs.dart'; // Assicurati di importare il modello
class ServicesGrid extends StatelessWidget {
final ServiceModel service;
const ServicesGrid({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.layers_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
"Servizi e Accessori",
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: [
// --- CONTATORI SEMPLICI ---
ActionCard(
label: "AL",
count: service.al,
icon: Icons.sim_card,
color: Colors.blue,
onTap: () => updateCountDialog(
context,
"AL",
service.al,
(val) =>
context.read<ServicesCubit>().updateField(al: val),
),
),
ActionCard(
label: "MNP",
count: service.mnp,
icon: Icons.phone_android,
color: Colors.indigo,
onTap: () => updateCountDialog(
context,
"MNP",
service.mnp,
(val) =>
context.read<ServicesCubit>().updateField(mnp: val),
),
),
ActionCard(
label: "NIP",
count: service.nip,
icon: Icons.compare_arrows,
color: Colors.cyan,
onTap: () => updateCountDialog(
context,
"NIP",
service.nip,
(val) =>
context.read<ServicesCubit>().updateField(nip: val),
),
),
ActionCard(
label: "Unica",
count: service.unica,
icon: Icons.all_inclusive,
color: Colors.purple,
onTap: () => updateCountDialog(
context,
"Unica",
service.unica,
(val) =>
context.read<ServicesCubit>().updateField(unica: val),
),
),
ActionCard(
label: "Telepass",
count: service.telepass,
icon: Icons.directions_car,
color: Colors.amber.shade700,
onTap: () => updateCountDialog(
context,
"Telepass",
service.telepass,
(val) => context.read<ServicesCubit>().updateField(
telepass: val,
),
),
),
// --- MODULI COMPLESSI (Le liste) ---
ActionCard(
label: "Energia",
count: service.energyServices.length,
icon: Icons.bolt,
color: Colors.green,
onTap: () async {
// Apriamo la modale e aspettiamo il risultato
final result = await showDialog<List<EnergyServiceModel>>(
context: context,
builder: (context) => EnergyServiceDialog(
currentStoreId: service.storeId,
initialServices: service
.energyServices, // Passiamo la lista attuale
),
);
// Se l'utente ha premuto "Conferma" e non "Annulla" o tap fuori
if (result != null && context.mounted) {
context.read<ServicesCubit>().updateEnergyServices(
result,
);
}
},
),
ActionCard(
label: "Finanziam.",
count: service.finServices.length,
icon: Icons.euro_symbol,
color: Colors.teal,
onTap: () async {
final result = await showDialog<List<FinServiceModel>>(
context: context,
builder: (context) => FinanceServiceDialog(
productCubit: context.read<ProductCubit>(),
currentStoreId: service.storeId,
initialServices:
service.finServices, // Passiamo la lista attuale
),
);
if (result != null && context.mounted) {
context.read<ServicesCubit>().updateFinServices(result);
}
},
),
ActionCard(
label: "Intratten.",
count: service.entertainmentServices.length,
icon: Icons.movie_filter_outlined,
color: Colors.purple,
onTap: () async {
final result =
await showDialog<List<EntertainmentServiceModel>>(
context: context,
builder: (context) => EntertainmentServiceDialog(
initialServices: service.entertainmentServices,
currentStoreId: service.storeId,
),
);
if (result != null && context.mounted) {
context
.read<ServicesCubit>()
.updateEntertainmentServices(result);
}
},
),
],
),
),
],
),
),
);
}
}

View File

@@ -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
@@ -20,6 +21,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
super.initState();
// Agganciamo il listener per la paginazione (Scroll Infinito)
_scrollController.addListener(_onScroll);
// Carichiamo i servizi iniziali
context.read<ServicesCubit>().loadServices();
}
void _onScroll() {
@@ -60,7 +63,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
body: BlocBuilder<ServicesCubit, ServicesState>(
builder: (context, state) {
// 1. Stato di caricamento iniziale
if (state.isLoading && state.allServices.isEmpty) {
if (state.status == ServicesStatus.loading &&
state.allServices.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
@@ -111,7 +115,7 @@ class _ServicesScreenState extends State<ServicesScreen> {
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.pushNamed('service-form'), // GoRouter
onPressed: () => startNewService(context),
child: const Icon(Icons.add),
),
);
@@ -171,7 +175,12 @@ class _ServicesScreenState extends State<ServicesScreen> {
],
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.pushNamed('service-form', extra: service),
onTap: () => context.pushNamed(
'service-form',
extra: service, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
// Teniamo anche il parametro URL per coerenza di routing
queryParameters: service.id != null ? {'serviceId': service.id!} : {},
),
),
);
}

View File

@@ -0,0 +1,82 @@
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(
existingService: ServiceModel(
storeId: currentStoreId,
employeeId: member.id,
number: '',
createdAt: DateTime.now(),
companyId: session.company!.id,
),
);
// 2. Chiudi la modal
Navigator.pop(modalContext);
// 3. Naviga verso il form
context.pushNamed('service-form');
},
),
),
const SizedBox(height: 16),
],
),
);
},
);
},
);
}