renamed services folder to operations
This commit is contained in:
232
lib/features/operations/blocs/service_files_bloc.dart
Normal file
232
lib/features/operations/blocs/service_files_bloc.dart
Normal file
@@ -0,0 +1,232 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/operations/data/services_repository.dart';
|
||||
import 'package:flux/features/operations/models/service_file_model.dart';
|
||||
import 'package:flux/features/operations/models/service_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
part 'service_files_events.dart';
|
||||
part 'service_files_state.dart';
|
||||
|
||||
class ServiceFilesBloc extends Bloc<ServiceFilesEvent, ServiceFilesState> {
|
||||
final _repository = GetIt.I.get<ServicesRepository>();
|
||||
final String? serviceId;
|
||||
|
||||
ServiceFilesBloc({this.serviceId})
|
||||
: super(
|
||||
ServiceFilesState(
|
||||
status: ServiceFilesStatus.initial,
|
||||
serviceId: serviceId,
|
||||
),
|
||||
) {
|
||||
on<ServiceSavedEvent>(_onServiceSaved);
|
||||
on<LoadServiceFilesEvent>(_onLoadServiceFiles);
|
||||
on<AddServiceFilesEvent>(_onAddServiceFiles);
|
||||
on<UploadServiceFilesEvent>(_onUploadServiceFiles);
|
||||
on<UploadMultipleServiceFilesEvent>(_onUploadMultipleServiceFiles);
|
||||
on<DeleteServiceFilesEvent>(_onDeleteServiceFiles);
|
||||
on<ToggleServiceFileSelectionEvent>(_onToggleServiceFileSelection);
|
||||
// Se il BLoC nasce con un ID, accendiamo subito lo stream!
|
||||
if (serviceId != null) {
|
||||
add(LoadServiceFilesEvent(serviceId: serviceId));
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onServiceSaved(
|
||||
ServiceSavedEvent event,
|
||||
Emitter<ServiceFilesState> emit,
|
||||
) {
|
||||
// 1. Aggiorniamo l'ID nello stato
|
||||
// 2. PIALLIAMO i file locali: ormai sono partiti per Supabase!
|
||||
// Così la UI si pulisce all'istante e aspetta quelli remoti.
|
||||
emit(
|
||||
state.copyWith(
|
||||
serviceId: event.serviceId,
|
||||
localFiles: [], // <-- LA MAGIA ANTI-DUPLICATI
|
||||
),
|
||||
);
|
||||
|
||||
// Lanciamo il caricamento
|
||||
add(LoadServiceFilesEvent(serviceId: event.serviceId));
|
||||
}
|
||||
|
||||
FutureOr<void> _onLoadServiceFiles(
|
||||
LoadServiceFilesEvent event,
|
||||
Emitter<ServiceFilesState> emit,
|
||||
) async {
|
||||
// Usiamo l'ID dell'evento, e se non c'è usiamo quello dello stato
|
||||
final currentId = event.serviceId ?? state.serviceId;
|
||||
|
||||
if (currentId != null) {
|
||||
emit(state.copyWith(status: ServiceFilesStatus.loading));
|
||||
|
||||
await emit.forEach(
|
||||
_repository.getServiceFilesStream(
|
||||
currentId,
|
||||
), // <-- Usiamo l'ID corretto!
|
||||
onData: (data) => state.copyWith(
|
||||
status: ServiceFilesStatus.success,
|
||||
remoteFiles: data,
|
||||
),
|
||||
onError: (error, stackTrace) => state.copyWith(
|
||||
status: ServiceFilesStatus.failure,
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onAddServiceFiles(
|
||||
AddServiceFilesEvent event,
|
||||
Emitter<ServiceFilesState> emit,
|
||||
) async {
|
||||
final currentId = state.serviceId;
|
||||
// BIVIO 1: PRATICA NUOVA (Nessun ID)
|
||||
if (currentId == null) {
|
||||
// Mettiamo i file nel "parcheggio" locale dello State
|
||||
final newLocalFiles = event.files.map((file) {
|
||||
return ServiceFileModel(
|
||||
id: null,
|
||||
serviceId: serviceId ?? '',
|
||||
name: file.name.fileNameWithoutExtension(),
|
||||
extension: file.name.fileExtension(),
|
||||
storagePath: '',
|
||||
fileSize: file.size,
|
||||
localBytes: file.bytes,
|
||||
);
|
||||
}).toList();
|
||||
final List<ServiceFileModel> updatedLocalFiles = [
|
||||
...state.localFiles,
|
||||
...newLocalFiles,
|
||||
];
|
||||
emit(
|
||||
state.copyWith(
|
||||
localFiles: updatedLocalFiles,
|
||||
status: ServiceFilesStatus.success,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID)
|
||||
emit(state.copyWith(status: ServiceFilesStatus.uploading));
|
||||
try {
|
||||
// Logica identica a quella che abbiamo fatto per i clienti
|
||||
for (var file in event.files) {
|
||||
await _repository.uploadAndRegisterServiceFile(
|
||||
serviceId: serviceId!,
|
||||
pickedFile: file,
|
||||
);
|
||||
}
|
||||
emit(state.copyWith(status: ServiceFilesStatus.success));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onUploadServiceFiles(
|
||||
UploadServiceFilesEvent event,
|
||||
Emitter<ServiceFilesState> emit,
|
||||
) async {
|
||||
if (event.pickedFiles == null && event.photos == null) return;
|
||||
if (event.pickedFiles!.isEmpty && event.photos!.isEmpty) return;
|
||||
|
||||
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID
|
||||
emit(state.copyWith(status: ServiceFilesStatus.uploading));
|
||||
try {
|
||||
// Logica identica a quella che abbiamo fatto per i clienti
|
||||
if (event.pickedFiles != null && event.pickedFiles!.isNotEmpty) {
|
||||
for (var file in event.pickedFiles!) {
|
||||
await _repository.uploadAndRegisterServiceFile(
|
||||
serviceId: state.serviceId!,
|
||||
pickedFile: file,
|
||||
);
|
||||
}
|
||||
}
|
||||
emit(state.copyWith(status: ServiceFilesStatus.success));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onUploadMultipleServiceFiles(
|
||||
UploadMultipleServiceFilesEvent event,
|
||||
Emitter<ServiceFilesState> emit,
|
||||
) async {
|
||||
if (event.files.isEmpty) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServiceFilesStatus.failure,
|
||||
error: "Nessun file selezionato",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(status: ServiceFilesStatus.uploading, error: null));
|
||||
try {
|
||||
// 2. Creiamo una lista di "Promesse" (Futures) per il repository
|
||||
final List<Future<void>> uploadTasks = [];
|
||||
for (var file in event.files) {
|
||||
// Aggiungiamo il task alla lista, ma NON usiamo await qui dentro!
|
||||
uploadTasks.add(
|
||||
_repository.uploadAndRegisterServiceFile(
|
||||
serviceId: state.serviceId!,
|
||||
pickedFile: file,
|
||||
),
|
||||
);
|
||||
}
|
||||
// 3. ESECUZIONE PARALLELA!
|
||||
// Aspettiamo che tutti i file siano caricati contemporaneamente.
|
||||
await Future.wait(uploadTasks);
|
||||
// 4. GRAN FINALE: Tutto caricato, emettiamo il success!
|
||||
emit(state.copyWith(status: ServiceFilesStatus.success));
|
||||
} catch (e) {
|
||||
// Se anche un solo file fallisce, catturiamo l'errore
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServiceFilesStatus.failure,
|
||||
error: "Errore durante l'upload multiplo: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onDeleteServiceFiles(
|
||||
DeleteServiceFilesEvent event,
|
||||
Emitter<ServiceFilesState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: ServiceFilesStatus.loading));
|
||||
try {
|
||||
await _repository.deleteServiceFiles(state.selectedFiles);
|
||||
emit(
|
||||
state.copyWith(status: ServiceFilesStatus.success, selectedFiles: []),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onToggleServiceFileSelection(
|
||||
ToggleServiceFileSelectionEvent event,
|
||||
Emitter<ServiceFilesState> emit,
|
||||
) {
|
||||
List<ServiceFileModel> selectedFiles = List.from(state.selectedFiles);
|
||||
if (selectedFiles.contains(event.file)) {
|
||||
selectedFiles.remove(event.file);
|
||||
} else {
|
||||
selectedFiles.add(event.file);
|
||||
}
|
||||
emit(state.copyWith(selectedFiles: selectedFiles));
|
||||
}
|
||||
}
|
||||
56
lib/features/operations/blocs/service_files_events.dart
Normal file
56
lib/features/operations/blocs/service_files_events.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
part of 'service_files_bloc.dart';
|
||||
|
||||
abstract class ServiceFilesEvent extends Equatable {
|
||||
const ServiceFilesEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class ServiceSavedEvent extends ServiceFilesEvent {
|
||||
final String serviceId;
|
||||
const ServiceSavedEvent(this.serviceId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [serviceId];
|
||||
}
|
||||
|
||||
class LoadServiceFilesEvent extends ServiceFilesEvent {
|
||||
final String? serviceId;
|
||||
final ServiceModel? service;
|
||||
const LoadServiceFilesEvent({this.serviceId, this.service});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [serviceId, service];
|
||||
}
|
||||
|
||||
class AddServiceFilesEvent extends ServiceFilesEvent {
|
||||
final List<PlatformFile> files;
|
||||
const AddServiceFilesEvent(this.files);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [files];
|
||||
}
|
||||
|
||||
class UploadServiceFilesEvent extends ServiceFilesEvent {
|
||||
final List<PlatformFile>? pickedFiles;
|
||||
final List<File>? photos;
|
||||
const UploadServiceFilesEvent({this.pickedFiles, this.photos});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [pickedFiles, photos];
|
||||
}
|
||||
|
||||
class UploadMultipleServiceFilesEvent extends ServiceFilesEvent {
|
||||
final List<PlatformFile> files;
|
||||
const UploadMultipleServiceFilesEvent(this.files);
|
||||
@override
|
||||
List<Object?> get props => [files];
|
||||
}
|
||||
|
||||
class DeleteServiceFilesEvent extends ServiceFilesEvent {}
|
||||
|
||||
class ToggleServiceFileSelectionEvent extends ServiceFilesEvent {
|
||||
final ServiceFileModel file;
|
||||
const ToggleServiceFileSelectionEvent(this.file);
|
||||
}
|
||||
52
lib/features/operations/blocs/service_files_state.dart
Normal file
52
lib/features/operations/blocs/service_files_state.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
part of 'service_files_bloc.dart';
|
||||
|
||||
enum ServiceFilesStatus { initial, loading, uploading, success, failure }
|
||||
|
||||
class ServiceFilesState extends Equatable {
|
||||
const ServiceFilesState({
|
||||
this.serviceId,
|
||||
required this.status,
|
||||
this.error,
|
||||
this.localFiles = const [],
|
||||
this.remoteFiles = const [],
|
||||
this.selectedFiles = const [],
|
||||
});
|
||||
|
||||
final String? serviceId;
|
||||
final ServiceFilesStatus status;
|
||||
final String? error;
|
||||
final List<ServiceFileModel> localFiles;
|
||||
final List<ServiceFileModel> remoteFiles;
|
||||
|
||||
final List<ServiceFileModel> selectedFiles;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
serviceId,
|
||||
status,
|
||||
error,
|
||||
localFiles,
|
||||
remoteFiles,
|
||||
selectedFiles,
|
||||
];
|
||||
|
||||
List<ServiceFileModel> get allFiles => [...remoteFiles, ...localFiles];
|
||||
|
||||
ServiceFilesState copyWith({
|
||||
String? serviceId,
|
||||
ServiceFilesStatus? status,
|
||||
String? error,
|
||||
List<ServiceFileModel>? localFiles,
|
||||
List<ServiceFileModel>? remoteFiles,
|
||||
List<ServiceFileModel>? selectedFiles,
|
||||
}) {
|
||||
return ServiceFilesState(
|
||||
serviceId: serviceId ?? this.serviceId,
|
||||
status: status ?? this.status,
|
||||
error: error,
|
||||
localFiles: localFiles ?? this.localFiles,
|
||||
remoteFiles: remoteFiles ?? this.remoteFiles,
|
||||
selectedFiles: selectedFiles ?? this.selectedFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
348
lib/features/operations/blocs/services_cubit.dart
Normal file
348
lib/features/operations/blocs/services_cubit.dart
Normal file
@@ -0,0 +1,348 @@
|
||||
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_cubit.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/operations/data/services_repository.dart';
|
||||
import 'package:flux/features/operations/models/energy_service_model.dart';
|
||||
import 'package:flux/features/operations/models/entertainment_service_model.dart';
|
||||
import 'package:flux/features/operations/models/fin_service_model.dart';
|
||||
import 'package:flux/features/operations/models/service_file_model.dart';
|
||||
import 'package:flux/features/operations/models/service_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
part 'services_state.dart';
|
||||
|
||||
class ServicesCubit extends Cubit<ServicesState> {
|
||||
final ServicesRepository _repository = GetIt.I<ServicesRepository>();
|
||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||
|
||||
ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial));
|
||||
|
||||
// --- CARICAMENTO E PAGINAZIONE ---
|
||||
|
||||
Future<void> loadServices({bool refresh = false}) async {
|
||||
// Se stiamo già caricando, evitiamo chiamate doppie
|
||||
if (state.status == ServicesStatus.loading) return;
|
||||
|
||||
// Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo
|
||||
if (!refresh && state.hasReachedMax) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
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 = _sessionCubit.state.company?.id;
|
||||
|
||||
if (companyId == null) {
|
||||
throw Exception("Company ID non trovato nella sessione");
|
||||
}
|
||||
|
||||
final newServices = await _repository.fetchServices(
|
||||
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(
|
||||
status: ServicesStatus.ready,
|
||||
allServices: refresh
|
||||
? newServices
|
||||
: [...state.allServices, ...newServices],
|
||||
hasReachedMax: reachedMax,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
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 ?? state.query,
|
||||
dateRange: range ?? state.dateRange,
|
||||
),
|
||||
);
|
||||
loadServices(refresh: 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: _sessionCubit.state.currentStore?.id ?? '',
|
||||
number: '', // Sarà compilato dall'utente
|
||||
createdAt: DateTime.now(),
|
||||
companyId: _sessionCubit.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,
|
||||
bool shouldPop = true,
|
||||
List<ServiceFileModel>? files,
|
||||
}) async {
|
||||
if (state.currentService == null) return;
|
||||
|
||||
emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null));
|
||||
try {
|
||||
// 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente
|
||||
final serviceToSave = state.currentService!.copyWith(
|
||||
isBozza: isBozza,
|
||||
files: files,
|
||||
);
|
||||
|
||||
// 2. Salvataggio corazzato
|
||||
final updatedService = await _repository.saveFullService(serviceToSave);
|
||||
|
||||
// 3. Reset e ricaricamento
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: shouldPop ? ServicesStatus.saved : ServicesStatus.savedNoPop,
|
||||
currentService: shouldPop ? null : updatedService,
|
||||
),
|
||||
);
|
||||
await loadServices(refresh: true);
|
||||
} catch (e) {
|
||||
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(),
|
||||
storagePath: '',
|
||||
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(List<ServiceFileModel> selectedFiles) async {
|
||||
final currentService = state.currentService;
|
||||
|
||||
// 1. Check di sicurezza: se non c'è il cliente, non sappiamo dove copiare
|
||||
if (currentService == null || currentService.customerId == null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage:
|
||||
"Impossibile copiare: nessun cliente associato alla pratica.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: ServicesStatus.loading));
|
||||
|
||||
try {
|
||||
// 2. SALVATAGGIO CORAZZATO
|
||||
// Chiamiamo il repo e otteniamo la pratica con TUTTI i file ora dotati di ID e storagePath
|
||||
final updatedService = await _repository.saveFullService(currentService);
|
||||
|
||||
// 3. COPIA RELAZIONALE
|
||||
// Per ogni file che l'utente ha selezionato nella UI, cerchiamo la sua versione
|
||||
// "ufficiale" (quella con lo storagePath) nel modello appena tornato dal DB.
|
||||
for (var selectedFile in selectedFiles) {
|
||||
// Cerchiamo il match nel modello aggiornato
|
||||
final persistedFile = updatedService.files.firstWhere(
|
||||
(f) =>
|
||||
f.name == selectedFile.name &&
|
||||
f.extension == selectedFile.extension,
|
||||
orElse: () => throw Exception(
|
||||
"File ${selectedFile.name} non trovato dopo il salvataggio.",
|
||||
),
|
||||
);
|
||||
|
||||
// Creiamo il link nel database del cliente
|
||||
await _repository.copyFileToCustomer(
|
||||
file: persistedFile,
|
||||
customerId: currentService.customerId!,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. AGGIORNAMENTO STATO
|
||||
// Aggiorniamo il Cubit con il servizio salvato così la UI mostra i file come "Remoti"
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.success,
|
||||
currentService: updatedService,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage: "Errore durante il salvataggio e copia: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
lib/features/operations/blocs/services_state.dart
Normal file
68
lib/features/operations/blocs/services_state.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
part of 'services_cubit.dart';
|
||||
|
||||
enum ServicesStatus {
|
||||
initial,
|
||||
loading,
|
||||
ready,
|
||||
saving,
|
||||
saved,
|
||||
savedNoPop,
|
||||
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;
|
||||
final bool isSavingDraft;
|
||||
|
||||
const ServicesState({
|
||||
required this.status,
|
||||
this.allServices = const [],
|
||||
this.currentService,
|
||||
this.errorMessage,
|
||||
this.query = '',
|
||||
this.dateRange,
|
||||
this.hasReachedMax = false,
|
||||
this.isSavingDraft = false,
|
||||
});
|
||||
|
||||
ServicesState copyWith({
|
||||
ServicesStatus? status,
|
||||
List<ServiceModel>? allServices,
|
||||
ServiceModel? currentService,
|
||||
String? errorMessage,
|
||||
String? query,
|
||||
DateTimeRange? dateRange,
|
||||
bool? hasReachedMax,
|
||||
bool? isSavingDraft,
|
||||
}) {
|
||||
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,
|
||||
isSavingDraft: isSavingDraft ?? this.isSavingDraft,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
allServices,
|
||||
currentService,
|
||||
errorMessage,
|
||||
query,
|
||||
dateRange,
|
||||
hasReachedMax,
|
||||
isSavingDraft,
|
||||
];
|
||||
}
|
||||
375
lib/features/operations/data/services_repository.dart
Normal file
375
lib/features/operations/data/services_repository.dart
Normal file
@@ -0,0 +1,375 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/customers/data/customer_repository.dart';
|
||||
import 'package:flux/features/customers/models/customer_file_model.dart';
|
||||
import 'package:flux/features/operations/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<SessionCubit>().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({
|
||||
required String companyId,
|
||||
required int offset,
|
||||
int limit = 50,
|
||||
String? searchTerm,
|
||||
DateTimeRange? dateRange,
|
||||
}) async {
|
||||
try {
|
||||
// Nota: 'customer(name, surname)' serve per il display name nella card
|
||||
var query = _supabase
|
||||
.from('service')
|
||||
.select('''
|
||||
*,
|
||||
customer(nome),
|
||||
energy_service(*),
|
||||
fin_service(*),
|
||||
entertainment_service(*),
|
||||
service_file(*)
|
||||
''')
|
||||
.eq('company_id', companyId);
|
||||
|
||||
// Filtro Range Date
|
||||
if (dateRange != null) {
|
||||
query = query
|
||||
.gte('created_at', dateRange.start.toIso8601String())
|
||||
.lte('created_at', dateRange.end.toIso8601String());
|
||||
}
|
||||
|
||||
if (searchTerm != null && searchTerm.isNotEmpty) {
|
||||
// Filtra sui campi della tabella principale O su quelli della tabella joinata
|
||||
query = query.or(
|
||||
'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.nome.ilike.%$searchTerm%',
|
||||
);
|
||||
}
|
||||
|
||||
final response = await query
|
||||
.order('created_at', ascending: false)
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
return (response as List)
|
||||
.map((map) => ServiceModel.fromMap(map))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('Errore nel caricamento servizi: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<ServiceModel>> getLastStoreServicesStream({
|
||||
required String storeId,
|
||||
required int limit,
|
||||
}) {
|
||||
return _supabase
|
||||
.from('service')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('store_id', storeId)
|
||||
.order('created_at', ascending: false)
|
||||
.limit(limit)
|
||||
.map(
|
||||
(listOfMaps) =>
|
||||
listOfMaps.map((map) => ServiceModel.fromMap(map)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
||||
Future<ServiceModel> saveFullService(ServiceModel service) async {
|
||||
try {
|
||||
// 1. Upsert del record principale
|
||||
final serviceData = await _supabase
|
||||
.from('service')
|
||||
.upsert(service.toMap())
|
||||
.select()
|
||||
.single();
|
||||
|
||||
final String newId = serviceData['id'];
|
||||
|
||||
// 2. MODIFICA: Pulizia atomica dei figli
|
||||
// Se stiamo modificando (id != null), resettiamo le tabelle collegate
|
||||
if (service.id != null) {
|
||||
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 dei moduli in parallelo per velocità
|
||||
final List<Future> insertTasks = [];
|
||||
|
||||
if (service.energyServices.isNotEmpty) {
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('energy_service')
|
||||
.insert(
|
||||
service.energyServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (service.finServices.isNotEmpty) {
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('fin_service')
|
||||
.insert(
|
||||
service.finServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (service.entertainmentServices.isNotEmpty) {
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('entertainment_service')
|
||||
.insert(
|
||||
service.entertainmentServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (insertTasks.isNotEmpty) {
|
||||
await Future.wait(insertTasks);
|
||||
}
|
||||
|
||||
// 4. UPLOAD DEI FILE LOCALI (Nuovi)
|
||||
// Filtriamo solo i file che non hanno ancora un ID (quindi sono locali)
|
||||
final localFilesToUpload = service.files
|
||||
.where((f) => f.id == null)
|
||||
.toList();
|
||||
|
||||
if (localFilesToUpload.isNotEmpty) {
|
||||
final List<Future> uploadTasks = [];
|
||||
|
||||
for (var file in localFilesToUpload) {
|
||||
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,
|
||||
storagePath: storagePath,
|
||||
);
|
||||
|
||||
// Creiamo una funzione asincrona per caricare file e scrivere nel DB
|
||||
Future<void> uploadAndLink() async {
|
||||
// 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, upsert: true),
|
||||
);
|
||||
|
||||
// B. Inserimento riga nel DB relazionale
|
||||
await _supabase.from('service_file').insert(fileToSave.toMap());
|
||||
}
|
||||
|
||||
uploadTasks.add(uploadAndLink());
|
||||
}
|
||||
|
||||
// Eseguiamo tutti gli upload in parallelo per la massima velocità
|
||||
await Future.wait(uploadTasks);
|
||||
}
|
||||
|
||||
// 5. GRAN FINALE: RECUPERO DEL MODELLO COMPLETO E AGGIORNATO
|
||||
// Interroghiamo Supabase per farci restituire la pratica con TUTTI gli ID generati
|
||||
// (inclusi quelli della tabella service_file appena inseriti)
|
||||
final updatedServiceData = await _supabase
|
||||
.from('service')
|
||||
.select('''
|
||||
*,
|
||||
energy_service(*),
|
||||
fin_service(*),
|
||||
entertainment_service(*),
|
||||
service_file(*)
|
||||
''')
|
||||
.eq('id', newId)
|
||||
.single();
|
||||
|
||||
return ServiceModel.fromMap(updatedServiceData);
|
||||
} catch (e) {
|
||||
// Qui potresti aggiungere una logica di "rollback manuale" se necessario
|
||||
throw Exception('Errore durante il salvataggio corazzato: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- ELIMINAZIONE ---
|
||||
Future<void> deleteService(String id) async {
|
||||
try {
|
||||
await _supabase.from('service').delete().eq('id', id);
|
||||
} catch (e) {
|
||||
throw Exception('Errore durante l\'eliminazione: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Ascolta in tempo reale i file caricati per una pratica
|
||||
Stream<List<ServiceFileModel>> getServiceFilesStream(String serviceId) {
|
||||
return _supabase
|
||||
.from('service_file')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('service_id', serviceId)
|
||||
.order('created_at', ascending: false)
|
||||
.map(
|
||||
(listOfMaps) =>
|
||||
listOfMaps.map((map) => ServiceFileModel.fromMap(map)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ServiceFileModel> uploadAndRegisterServiceFile({
|
||||
required String serviceId,
|
||||
required PlatformFile pickedFile,
|
||||
}) async {
|
||||
final cleanFileName = pickedFile.name.replaceAll(
|
||||
RegExp(r'[^a-zA-Z0-9\.\-]'),
|
||||
'_',
|
||||
);
|
||||
final storagePath =
|
||||
'$companyId/services/$serviceId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
|
||||
final int fileSize = pickedFile.size;
|
||||
final fileToSave = ServiceFileModel(
|
||||
serviceId: serviceId,
|
||||
name: cleanFileName.fileNameWithoutExtension(),
|
||||
extension: cleanFileName.fileExtension(),
|
||||
storagePath: storagePath,
|
||||
fileSize: fileSize,
|
||||
);
|
||||
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
|
||||
? 'application/pdf'
|
||||
: 'image/${fileToSave.extension}';
|
||||
try {
|
||||
// Usiamo bytes invece del path per massima compatibilità
|
||||
if (pickedFile.bytes == null && pickedFile.path == null) {
|
||||
throw 'Impossibile leggere il contenuto del file';
|
||||
}
|
||||
|
||||
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
|
||||
if (pickedFile.bytes != null) {
|
||||
await _supabase.storage
|
||||
.from('documents')
|
||||
.uploadBinary(
|
||||
storagePath,
|
||||
pickedFile.bytes!,
|
||||
fileOptions: FileOptions(contentType: mimeType, upsert: true),
|
||||
);
|
||||
}
|
||||
|
||||
final response = await _supabase
|
||||
.from('service_file')
|
||||
.insert(fileToSave.toMap())
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return ServiceFileModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante l\'upload: $e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> copyFileToCustomer({
|
||||
required ServiceFileModel file,
|
||||
required String customerId,
|
||||
}) async {
|
||||
CustomerFileModel fileToCopy = CustomerFileModel(
|
||||
customerId: customerId,
|
||||
name: file.name,
|
||||
storagePath: file.storagePath,
|
||||
extension: file.extension,
|
||||
fileSize: file.fileSize,
|
||||
);
|
||||
await _customerRepository.saveFileReference(fileToCopy);
|
||||
}
|
||||
|
||||
Future<void> deleteServiceFiles(List<ServiceFileModel> files) async {
|
||||
if (files.isEmpty) return;
|
||||
// 1. Prepariamo le liste di ID e di Percorsi
|
||||
final List<String> idsToDelete = files.map((f) => f.id!).toList();
|
||||
final List<String> storagePaths = files.map((f) => f.storagePath).toList();
|
||||
|
||||
try {
|
||||
await _supabase.from('service_file').delete().inFilter('id', idsToDelete);
|
||||
|
||||
await _supabase.storage.from('documents').remove(storagePaths);
|
||||
|
||||
debugPrint("Eliminati con successo ${files.length} file.");
|
||||
} on PostgrestException catch (e) {
|
||||
debugPrint("Errore DB: ${e.message}");
|
||||
throw 'Errore database: ${e.message}';
|
||||
} catch (e) {
|
||||
debugPrint("Errore generico: $e");
|
||||
throw 'Errore durante l\'eliminazione dei file: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
72
lib/features/operations/models/energy_service_model.dart
Normal file
72
lib/features/operations/models/energy_service_model.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum EnergyType { luce, gas } // Mappa il tuo public.energy_type
|
||||
|
||||
class EnergyServiceModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final EnergyType type;
|
||||
final DateTime expiration;
|
||||
final String providerId;
|
||||
final String? serviceId;
|
||||
|
||||
const EnergyServiceModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.type,
|
||||
required this.expiration,
|
||||
required this.providerId,
|
||||
this.serviceId,
|
||||
});
|
||||
|
||||
EnergyServiceModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
EnergyType? type,
|
||||
DateTime? expiration,
|
||||
String? providerId,
|
||||
String? serviceId,
|
||||
}) {
|
||||
return EnergyServiceModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
expiration: expiration ?? this.expiration,
|
||||
providerId: providerId ?? this.providerId,
|
||||
serviceId: serviceId ?? this.serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
type,
|
||||
expiration,
|
||||
providerId,
|
||||
serviceId,
|
||||
];
|
||||
|
||||
factory EnergyServiceModel.fromMap(Map<String, dynamic> map) {
|
||||
return EnergyServiceModel(
|
||||
id: map['id'],
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
type: map['type'] == 'gas' ? EnergyType.gas : EnergyType.luce,
|
||||
expiration: DateTime.parse(map['expiration']),
|
||||
providerId: map['provider_id'],
|
||||
serviceId: map['service_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'type': type.name, // .name trasforma l'enum in 'luce' o 'gas'
|
||||
'expiration': expiration.toIso8601String(),
|
||||
'provider_id': providerId,
|
||||
'service_id': serviceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class EntertainmentServiceModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final String type; // es. Sky, DAZN, ecc.
|
||||
final bool constrained; // Vincolato?
|
||||
final DateTime constrainExpiration;
|
||||
final String? serviceId;
|
||||
final String? providerId;
|
||||
|
||||
const EntertainmentServiceModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.type,
|
||||
required this.constrained,
|
||||
required this.constrainExpiration,
|
||||
this.serviceId,
|
||||
this.providerId,
|
||||
});
|
||||
|
||||
EntertainmentServiceModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? type,
|
||||
bool? constrained,
|
||||
DateTime? constrainExpiration,
|
||||
String? serviceId,
|
||||
String? providerId,
|
||||
}) {
|
||||
return EntertainmentServiceModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
constrained: constrained ?? this.constrained,
|
||||
constrainExpiration: constrainExpiration ?? this.constrainExpiration,
|
||||
serviceId: serviceId ?? this.serviceId,
|
||||
providerId: providerId ?? this.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
type,
|
||||
constrained,
|
||||
constrainExpiration,
|
||||
serviceId,
|
||||
providerId,
|
||||
];
|
||||
|
||||
factory EntertainmentServiceModel.fromMap(Map<String, dynamic> map) {
|
||||
return EntertainmentServiceModel(
|
||||
id: map['id'],
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
type: map['type'],
|
||||
constrained: map['constrained'] ?? false,
|
||||
constrainExpiration: DateTime.parse(map['constrain_expiration']),
|
||||
serviceId: map['service_id'],
|
||||
providerId: map['provider_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'type': type,
|
||||
'constrained': constrained,
|
||||
'constrain_expiration': constrainExpiration.toIso8601String(),
|
||||
'service_id': serviceId,
|
||||
'provider_id': providerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
63
lib/features/operations/models/fin_service_model.dart
Normal file
63
lib/features/operations/models/fin_service_model.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class FinServiceModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final DateTime expiration;
|
||||
final String? serviceId;
|
||||
final String? modelId; // FK verso model (es. iPhone, Samsung, ecc.)
|
||||
final String? providerId;
|
||||
|
||||
const FinServiceModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.expiration,
|
||||
this.serviceId,
|
||||
this.modelId,
|
||||
this.providerId,
|
||||
});
|
||||
|
||||
FinServiceModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
DateTime? expiration,
|
||||
String? serviceId,
|
||||
String? modelId,
|
||||
String? providerId,
|
||||
}) {
|
||||
return FinServiceModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
expiration: expiration ?? this.expiration,
|
||||
serviceId: serviceId ?? this.serviceId,
|
||||
modelId: modelId ?? this.modelId,
|
||||
providerId: providerId ?? this.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, createdAt, expiration, serviceId, modelId];
|
||||
|
||||
factory FinServiceModel.fromMap(Map<String, dynamic> map) {
|
||||
return FinServiceModel(
|
||||
id: map['id'],
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
expiration: DateTime.parse(map['expiration']),
|
||||
serviceId: map['service_id'],
|
||||
modelId: map['model_id'],
|
||||
providerId: map['provider_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'expiration': expiration.toIso8601String(),
|
||||
'service_id': serviceId,
|
||||
'model_id': modelId,
|
||||
'provider_id': providerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
100
lib/features/operations/models/service_file_model.dart
Normal file
100
lib/features/operations/models/service_file_model.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
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 storagePath;
|
||||
final String serviceId;
|
||||
final int fileSize;
|
||||
final Uint8List? localBytes;
|
||||
|
||||
const ServiceFileModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.name,
|
||||
required this.extension,
|
||||
required this.storagePath,
|
||||
required this.serviceId,
|
||||
required this.fileSize,
|
||||
this.localBytes,
|
||||
});
|
||||
|
||||
bool get isLocal => localBytes != null;
|
||||
|
||||
// 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? storagePath,
|
||||
String? serviceId,
|
||||
int? fileSize,
|
||||
Uint8List? localBytes,
|
||||
}) {
|
||||
return ServiceFileModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
name: name ?? this.name,
|
||||
extension: extension ?? this.extension,
|
||||
storagePath: storagePath ?? this.storagePath,
|
||||
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'] ?? '',
|
||||
storagePath: map['storage_path'] ?? '',
|
||||
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,
|
||||
'storage_path': storagePath,
|
||||
'service_id': serviceId,
|
||||
'file_size': fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
name,
|
||||
extension,
|
||||
storagePath,
|
||||
serviceId,
|
||||
fileSize,
|
||||
localBytes,
|
||||
];
|
||||
}
|
||||
200
lib/features/operations/models/service_model.dart
Normal file
200
lib/features/operations/models/service_model.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/operations/models/energy_service_model.dart';
|
||||
import 'package:flux/features/operations/models/entertainment_service_model.dart';
|
||||
import 'package:flux/features/operations/models/fin_service_model.dart';
|
||||
import 'package:flux/features/operations/models/service_file_model.dart'; // <-- Aggiunto Import
|
||||
|
||||
class ServiceModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final String storeId;
|
||||
final String? employeeId;
|
||||
final String? customerId;
|
||||
final String number;
|
||||
final bool isBozza;
|
||||
final String note;
|
||||
final bool resultOk;
|
||||
final String? customerDisplayName;
|
||||
final String companyId;
|
||||
|
||||
// Telefonia
|
||||
final int al;
|
||||
final int mnp;
|
||||
final int nip;
|
||||
final int unica;
|
||||
final int telepass;
|
||||
|
||||
// Moduli (Liste)
|
||||
final List<EnergyServiceModel> energyServices;
|
||||
final List<FinServiceModel> finServices;
|
||||
final List<EntertainmentServiceModel> entertainmentServices;
|
||||
|
||||
// ALLEGATI (Aggiunto)
|
||||
final List<ServiceFileModel> files;
|
||||
|
||||
const ServiceModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.storeId,
|
||||
this.employeeId,
|
||||
this.customerId,
|
||||
required this.number,
|
||||
this.isBozza = true,
|
||||
this.note = '',
|
||||
this.resultOk = true,
|
||||
this.al = 0,
|
||||
this.mnp = 0,
|
||||
this.nip = 0,
|
||||
this.unica = 0,
|
||||
this.telepass = 0,
|
||||
this.energyServices = const [],
|
||||
this.finServices = const [],
|
||||
this.entertainmentServices = const [],
|
||||
this.files = const [], // <-- Aggiunto default vuoto
|
||||
this.customerDisplayName,
|
||||
required this.companyId,
|
||||
});
|
||||
|
||||
ServiceModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? storeId,
|
||||
String? employeeId,
|
||||
String? customerId,
|
||||
String? number,
|
||||
bool? isBozza,
|
||||
String? note,
|
||||
bool? resultOk,
|
||||
int? al,
|
||||
int? mnp,
|
||||
int? nip,
|
||||
int? unica,
|
||||
int? telepass,
|
||||
List<EnergyServiceModel>? energyServices,
|
||||
List<FinServiceModel>? finServices,
|
||||
List<EntertainmentServiceModel>? entertainmentServices,
|
||||
List<ServiceFileModel>? files, // <-- Aggiunto
|
||||
String? customerDisplayName,
|
||||
String? companyId,
|
||||
}) {
|
||||
return ServiceModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
storeId: storeId ?? this.storeId,
|
||||
employeeId: employeeId ?? this.employeeId,
|
||||
customerId: customerId ?? this.customerId,
|
||||
number: number ?? this.number,
|
||||
isBozza: isBozza ?? this.isBozza,
|
||||
note: note ?? this.note,
|
||||
resultOk: resultOk ?? this.resultOk,
|
||||
al: al ?? this.al,
|
||||
mnp: mnp ?? this.mnp,
|
||||
nip: nip ?? this.nip,
|
||||
unica: unica ?? this.unica,
|
||||
telepass: telepass ?? this.telepass,
|
||||
energyServices: energyServices ?? this.energyServices,
|
||||
finServices: finServices ?? this.finServices,
|
||||
entertainmentServices:
|
||||
entertainmentServices ?? this.entertainmentServices,
|
||||
files: files ?? this.files, // <-- Aggiunto
|
||||
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
|
||||
companyId: companyId ?? this.companyId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
storeId,
|
||||
employeeId,
|
||||
customerId,
|
||||
number,
|
||||
isBozza,
|
||||
note,
|
||||
resultOk,
|
||||
al,
|
||||
mnp,
|
||||
nip,
|
||||
unica,
|
||||
telepass,
|
||||
energyServices,
|
||||
finServices,
|
||||
entertainmentServices,
|
||||
files, // <-- Aggiunto
|
||||
customerDisplayName,
|
||||
companyId,
|
||||
];
|
||||
|
||||
factory ServiceModel.fromMap(Map<String, dynamic> map) {
|
||||
return ServiceModel(
|
||||
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,
|
||||
al: map['al'] ?? 0,
|
||||
mnp: map['mnp'] ?? 0,
|
||||
nip: map['nip'] ?? 0,
|
||||
unica: map['unica'] ?? 0,
|
||||
telepass: map['telepass'] ?? 0,
|
||||
|
||||
// Estrazione sicura liste collegate
|
||||
energyServices:
|
||||
(map['energy_service'] as List?)
|
||||
?.map((x) => EnergyServiceModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
finServices:
|
||||
(map['fin_service'] as List?)
|
||||
?.map((x) => FinServiceModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
entertainmentServices:
|
||||
(map['entertainment_service'] as List?)
|
||||
?.map((x) => EntertainmentServiceModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
|
||||
// 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']['nome'] ?? ''}".myFormat()
|
||||
: "Cliente non assegnato",
|
||||
companyId: map['company_id'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'store_id': storeId,
|
||||
'employee_id': employeeId,
|
||||
'customer_id': customerId,
|
||||
'number': number,
|
||||
'bozza': isBozza,
|
||||
'note': note,
|
||||
'result_ok': resultOk,
|
||||
'al': al,
|
||||
'mnp': mnp,
|
||||
'nip': nip,
|
||||
'unica': unica,
|
||||
'telepass': telepass,
|
||||
'company_id': companyId,
|
||||
// Le liste non le mettiamo qui perché vanno in tabelle diverse!
|
||||
};
|
||||
}
|
||||
}
|
||||
76
lib/features/operations/ui/service_action_card.dart
Normal file
76
lib/features/operations/ui/service_action_card.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ServiceActionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final Color color;
|
||||
final int count;
|
||||
const ServiceActionCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.color,
|
||||
this.count = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isActive = count > 0;
|
||||
|
||||
return Card(
|
||||
elevation: isActive ? 4 : 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: isActive ? color : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
width: 110, // Dimensione fissa per farle stare in una Row/Wrap
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: isActive ? color.withValues(alpha: 0.1) : Colors.transparent,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isActive ? color : Colors.grey.shade400,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? color : Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (isActive) ...[
|
||||
const SizedBox(height: 4),
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: color,
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
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_cubit.dart';
|
||||
import 'package:flux/core/widgets/image_viewer_widget.dart';
|
||||
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
|
||||
import 'package:flux/core/widgets/qr_upload_dialog.dart';
|
||||
import 'package:flux/features/operations/blocs/service_files_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/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<ServiceFilesBloc>().add(AddServiceFilesEvent(result.files));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ServiceFilesBloc serviceFilesBloc = BlocProvider.of<ServiceFilesBloc>(
|
||||
context,
|
||||
);
|
||||
|
||||
return BlocListener<ServicesCubit, ServicesState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.currentService?.id == null &&
|
||||
current.currentService?.id != null,
|
||||
listener: (context, state) {
|
||||
// FIGASSA! La pratica è stata salvata e ora ha un ID.
|
||||
// Diciamo al Bloc dei file di agganciarsi al database.
|
||||
final newId = state.currentService!.id!;
|
||||
context.read<ServiceFilesBloc>().add(ServiceSavedEvent(newId));
|
||||
},
|
||||
child: BlocBuilder<ServiceFilesBloc, ServiceFilesState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER SEZIONE ---
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"DOCUMENTI ALLEGATI",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.attach_file),
|
||||
label: const Text("Aggiungi File"),
|
||||
onPressed: () => _pickFiles(context),
|
||||
),
|
||||
if (!context
|
||||
.read<SessionCubit>()
|
||||
.state
|
||||
.isMobileDevice) ...[
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _handleGenerateQr(context),
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text("GENERA QR"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// --- LISTA VUOTA ---
|
||||
if (state.allFiles.isEmpty) // Furbata: usiamo allFiles.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),
|
||||
),
|
||||
)
|
||||
// --- LISTA PIENA ---
|
||||
else ...[
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: state.allFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = state.allFiles[index];
|
||||
final sizeMb = (file.fileSize / (1024 * 1024))
|
||||
.toStringAsFixed(2);
|
||||
final isPdf = file.extension.toLowerCase() == 'pdf';
|
||||
final isSelected = state.selectedFiles.contains(file);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => serviceFilesBloc.add(
|
||||
ToggleServiceFileSelectionEvent(file),
|
||||
),
|
||||
onDoubleTap: () => _handleDoubleClick(context, file),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 0,
|
||||
// UX Fina: cambiamo colore del bordo se selezionato
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
// UX Fina: Sfondo leggermente colorato se selezionato
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.05)
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isSelected
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(
|
||||
file.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
file.isLocal ? "$sizeMb MB • (Nuovo)" : " MB",
|
||||
),
|
||||
trailing: Icon(
|
||||
isPdf ? Icons.picture_as_pdf : Icons.image,
|
||||
color: isPdf ? Colors.red : Colors.blue,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// --- PANNELLO AZIONI CONTESTUALI (LA MAGIA) ---
|
||||
// Appare SOLO se c'è almeno un file selezionato
|
||||
if (state.selectedFiles.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Contatore
|
||||
Text(
|
||||
"${state.selectedFiles.length} file selezionati",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// Bottone Elimina
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text("Elimina"),
|
||||
onPressed: () {
|
||||
// Qui lancerai l'evento per eliminare i file selezionati!
|
||||
// Es: serviceFilesBloc.add(DeleteSelectedFilesEvent());
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Bottone Copia
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text("Copia in Cliente"),
|
||||
onPressed: () => saveAndCopyFilesToCustomer(
|
||||
context,
|
||||
state.selectedFiles,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleGenerateQr(BuildContext context) async {
|
||||
final cubit = context.read<ServicesCubit>();
|
||||
var currentService = cubit.state.currentService;
|
||||
|
||||
// 1. CATTURIAMO IL BLOC MENTRE SIAMO ANCORA NELLA PAGINA
|
||||
final serviceFilesBloc = context.read<ServiceFilesBloc>();
|
||||
|
||||
// 2. SE LA PRATICA E' NUOVA (Manca l'ID)
|
||||
if (currentService == null || currentService.id == null) {
|
||||
// NIENTE BlocListener qui! Solo un semplice Dialog di conferma
|
||||
final bool? confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Salvataggio Necessario"),
|
||||
content: const Text(
|
||||
"Per generare il QR Code e caricare file dal telefono, la pratica deve essere prima salvata in BOZZA.\n\nVuoi salvare ora?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text("Salva in Bozza"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return; // Utente ha annullato
|
||||
|
||||
// Salviamo forzatamente in bozza
|
||||
await cubit.saveCurrentService(
|
||||
isBozza: true,
|
||||
shouldPop: false,
|
||||
files: serviceFilesBloc.state.localFiles,
|
||||
);
|
||||
|
||||
// Recuperiamo il servizio aggiornato con l'ID!
|
||||
currentService = cubit.state.currentService;
|
||||
|
||||
if (currentService?.id == null) return;
|
||||
}
|
||||
|
||||
// 3. MOSTRIAMO IL QR CODE (Con il Ponte e l'Auto-Chiusura!)
|
||||
if (context.mounted) {
|
||||
final nomePratica = "Pratica ${currentService?.customerDisplayName ?? ''}"
|
||||
.trim();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => BlocProvider.value(
|
||||
// INIETTIAMO IL BLOC NEL CONTESTO DEL DIALOG ALIENO
|
||||
value: serviceFilesBloc,
|
||||
|
||||
// ORA METTIAMO L'AUTO-CHIUSURA SUL QR CODE!
|
||||
child: BlocListener<ServiceFilesBloc, ServiceFilesState>(
|
||||
listener: (context, state) {
|
||||
// Se arrivano file remoti e lo stato è success, chiudiamo il QR!
|
||||
// (Nota: usiamo dialogContext per assicurarci di chiudere il popup giusto)
|
||||
if (state.status == ServiceFilesStatus.success &&
|
||||
state.remoteFiles.isNotEmpty) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
},
|
||||
child: QrUploadDialog(
|
||||
deepLinkUrl:
|
||||
'fluxapp:///service/${currentService!.id}/upload?name=${Uri.encodeComponent(nomePratica)}',
|
||||
title: 'Scatta per\n$nomePratica',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- LOGICA DI COPIA AL CLIENTE ---
|
||||
void saveAndCopyFilesToCustomer(
|
||||
BuildContext context,
|
||||
List<ServiceFileModel> files,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Copia nei documenti Cliente"),
|
||||
content: const Text(
|
||||
"Vuoi copiare i file selezionati 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(files);
|
||||
},
|
||||
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.storagePath.isNotEmpty
|
||||
? file.storagePath
|
||||
: null,
|
||||
bytes: file.localBytes,
|
||||
)
|
||||
: ImageViewerWidget(
|
||||
storagePath: file.storagePath.isNotEmpty
|
||||
? file.storagePath
|
||||
: null,
|
||||
bytes: file.localBytes,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/features/customers/ui/customer_search_sheet.dart';
|
||||
import 'package:flux/features/operations/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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/operations/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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.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/operations/data/services_repository.dart';
|
||||
import 'package:flux/features/operations/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<SessionCubit>().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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/operations/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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
158
lib/features/operations/ui/service_form_screen/int_dialogs.dart
Normal file
158
lib/features/operations/ui/service_form_screen/int_dialogs.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/models/service_model.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/attachment_section.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/customer_section.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/general_info_section.dart';
|
||||
import 'package:flux/features/operations/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);
|
||||
}
|
||||
if (state.status == ServicesStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Errore: ${state.errorMessage ?? ''}"),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.status == ServicesStatus.savedNoPop) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Pratica salvata con successo!"),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flux/features/operations/blocs/service_files_bloc.dart';
|
||||
|
||||
class ServiceMobileUploadScreen extends StatefulWidget {
|
||||
final String serviceId;
|
||||
final String serviceName;
|
||||
|
||||
const ServiceMobileUploadScreen({
|
||||
super.key,
|
||||
required this.serviceId,
|
||||
required this.serviceName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ServiceMobileUploadScreen> createState() =>
|
||||
_ServiceMobileUploadScreenState();
|
||||
}
|
||||
|
||||
class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
|
||||
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
|
||||
final List<PlatformFile> _stagedFiles = [];
|
||||
|
||||
// 2. STATO DI CARICAMENTO GLOBALE
|
||||
bool _isUploading = false;
|
||||
|
||||
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
|
||||
bool _isImage(String path) {
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png', 'webp'].contains(ext);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ServiceFilesBloc, ServiceFilesState>(
|
||||
listener: (context, state) {
|
||||
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
|
||||
if (state.status == ServiceFilesStatus.success && _isUploading) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Tutti i file caricati con successo! ✅"),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
if (state.status == ServiceFilesStatus.failure) {
|
||||
setState(() => _isUploading = false);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text("Errore: ${state.error}")));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Upload Pratica:\n${widget.serviceName}"),
|
||||
automaticallyImplyLeading: !_isUploading,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isUploading ? null : _handleCamera,
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text("SCATTA"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isUploading ? null : _handleFilePicker,
|
||||
icon: const Icon(Icons.folder),
|
||||
label: const Text("GALLERIA"),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
|
||||
Expanded(
|
||||
child: _stagedFiles.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
3, // 3 colonne come la galleria dell'iPhone
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: _stagedFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = _stagedFiles[index];
|
||||
final isImg = _isImage(file.name);
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// L'ANTEPRIMA
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: isImg
|
||||
? Image.file(
|
||||
File(file.path!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: const Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.picture_as_pdf,
|
||||
color: Colors.red,
|
||||
size: 36,
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
"PDF",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// IL PULSANTE CESTINO (In alto a destra)
|
||||
Positioned(
|
||||
top: -8,
|
||||
right: -8,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_stagedFiles.removeAt(index);
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// --- SEZIONE INVIA E CHIUDI ---
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
// Il pulsante si accende SOLO se ci sono file nel carrello
|
||||
onPressed: _stagedFiles.isEmpty || _isUploading
|
||||
? null
|
||||
: _submitAllFiles,
|
||||
icon: const Icon(Icons.cloud_upload),
|
||||
label: Text(
|
||||
"INVIA ${_stagedFiles.length} FILE E CHIUDI",
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
|
||||
if (_isUploading)
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
child: const Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
"Caricamento in corso...",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- LOGICA FOTOCAMERA E LIBRERIA ---
|
||||
Future<void> _handleCamera() async {
|
||||
final picker = ImagePicker();
|
||||
final photo = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 80,
|
||||
);
|
||||
if (photo != null) {
|
||||
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
|
||||
final photoSize = await photo.length();
|
||||
|
||||
final platformFile = PlatformFile(
|
||||
name: photo.name,
|
||||
size: photoSize,
|
||||
path: photo.path,
|
||||
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
|
||||
);
|
||||
setState(() {
|
||||
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleFilePicker() async {
|
||||
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
|
||||
final result = await FilePicker.pickFiles(allowMultiple: true);
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
_stagedFiles.addAll(result.files);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- LOGICA DI INVIO AL BLoC ---
|
||||
void _submitAllFiles() {
|
||||
setState(() => _isUploading = true);
|
||||
|
||||
// Diciamo al BLoC di caricare tutti i file.
|
||||
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
|
||||
final bloc = context.read<ServiceFilesBloc>();
|
||||
bloc.add(UploadMultipleServiceFilesEvent(_stagedFiles));
|
||||
|
||||
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
|
||||
}
|
||||
}
|
||||
@@ -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/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/models/energy_service_model.dart';
|
||||
import 'package:flux/features/operations/models/entertainment_service_model.dart';
|
||||
import 'package:flux/features/operations/models/fin_service_model.dart';
|
||||
import 'package:flux/features/operations/models/service_model.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/action_card.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/energy_service_dialog.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/entertainment_service_card.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/finance_service_dialog.dart';
|
||||
import 'package:flux/features/operations/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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
206
lib/features/operations/ui/services_screen.dart
Normal file
206
lib/features/operations/ui/services_screen.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/models/service_model.dart';
|
||||
import 'package:flux/features/operations/utils/service_actions.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);
|
||||
// Carichiamo i servizi iniziali
|
||||
context.read<ServicesCubit>().loadServices();
|
||||
}
|
||||
|
||||
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.status == ServicesStatus.loading &&
|
||||
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: () => startNewService(context),
|
||||
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, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
|
||||
// Teniamo anche il parametro URL per coerenza di routing
|
||||
queryParameters: service.id != null ? {'serviceId': service.id!} : {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
82
lib/features/operations/utils/service_actions.dart
Normal file
82
lib/features/operations/utils/service_actions.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
||||
import 'package:flux/features/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/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<SessionCubit>().state;
|
||||
final currentStoreId = session.currentStore?.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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user