Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-03 12:05:47 +02:00
parent 4559db620d
commit 6bb65e8296
13 changed files with 832 additions and 815 deletions

View File

@@ -50,7 +50,7 @@ class CoreRepository {
.select() .select()
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) // Buona pratica .eq('is_active', true) // Buona pratica
.order('nome'); // O come si chiama il campo nome .order('name'); // O come si chiama il campo nome
return (response as List).map((s) => StoreModel.fromMap(s)).toList(); return (response as List).map((s) => StoreModel.fromMap(s)).toList();
} catch (e) { } catch (e) {

View File

@@ -51,7 +51,7 @@ class ProviderRepository {
) )
''') ''')
.eq('company_id', companyId) .eq('company_id', companyId)
.order('nome'); .order('name');
return (response as List).map((m) => ProviderModel.fromMap(m)).toList(); return (response as List).map((m) => ProviderModel.fromMap(m)).toList();
} catch (e) { } catch (e) {

View File

@@ -3,30 +3,30 @@ import 'package:flux/features/master_data/store/models/store_model.dart';
class ProviderModel extends Equatable { class ProviderModel extends Equatable {
final String? id; final String? id;
final String nome; final String name;
final bool telefoniaFissa; final bool landline;
final bool telefoniaMobile; final bool mobile;
final bool energia; final bool energy;
final bool assicurazioni; final bool insurance;
final bool intrattenimento; final bool entertainment;
final bool finanziamenti; final bool financing;
final bool telepass; final bool telepass;
final bool altro; final bool other;
final bool isActive; final bool isActive;
final String companyId; final String companyId;
final List<StoreModel> associatedStores; final List<StoreModel> associatedStores;
const ProviderModel({ const ProviderModel({
this.id, this.id,
required this.nome, required this.name,
required this.telefoniaFissa, required this.landline,
required this.telefoniaMobile, required this.mobile,
required this.energia, required this.energy,
required this.assicurazioni, required this.insurance,
required this.intrattenimento, required this.entertainment,
required this.finanziamenti, required this.financing,
required this.telepass, required this.telepass,
required this.altro, required this.other,
required this.isActive, required this.isActive,
required this.companyId, required this.companyId,
this.associatedStores = const [], this.associatedStores = const [],
@@ -46,15 +46,15 @@ class ProviderModel extends Equatable {
} }
return ProviderModel( return ProviderModel(
id: map['id'], id: map['id'],
nome: map['nome'], name: map['name'],
telefoniaFissa: map['telefonia_fissa'] ?? false, landline: map['landline'] ?? false,
telefoniaMobile: map['telefonia_mobile'] ?? false, mobile: map['mobile'] ?? false,
energia: map['energia'] ?? false, energy: map['energy'] ?? false,
assicurazioni: map['assicurazioni'] ?? false, insurance: map['insurance'] ?? false,
intrattenimento: map['intrattenimento'] ?? false, entertainment: map['entertainment'] ?? false,
finanziamenti: map['finanziamenti'] ?? false, financing: map['financing'] ?? false,
telepass: map['telepass'] ?? false, telepass: map['telepass'] ?? false,
altro: map['altro'] ?? false, other: map['other'] ?? false,
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
companyId: map['company_id'], companyId: map['company_id'],
associatedStores: stores, associatedStores: stores,
@@ -63,15 +63,15 @@ class ProviderModel extends Equatable {
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
final map = { final map = {
'nome': nome, 'name': name,
'telefonia_fissa': telefoniaFissa, 'landline': landline,
'telefonia_mobile': telefoniaMobile, 'mobile': mobile,
'energia': energia, 'energy': energy,
'assicurazioni': assicurazioni, 'insurance': insurance,
'intrattenimento': intrattenimento, 'entertainment': entertainment,
'finanziamenti': finanziamenti, 'financing': financing,
'telepass': telepass, 'telepass': telepass,
'altro': altro, 'other': other,
'is_active': isActive, 'is_active': isActive,
'company_id': companyId, 'company_id': companyId,
}; };
@@ -86,15 +86,15 @@ class ProviderModel extends Equatable {
@override @override
List<Object?> get props => [ List<Object?> get props => [
id, id,
nome, name,
telefoniaFissa, landline,
telefoniaMobile, mobile,
energia, energy,
assicurazioni, insurance,
intrattenimento, entertainment,
finanziamenti, financing,
telepass, telepass,
altro, other,
isActive, isActive,
companyId, companyId,
associatedStores, associatedStores,
@@ -102,30 +102,30 @@ class ProviderModel extends Equatable {
ProviderModel copyWith({ ProviderModel copyWith({
String? id, String? id,
String? nome, String? name,
bool? telefoniaFissa, bool? landline,
bool? telefoniaMobile, bool? mobile,
bool? energia, bool? energy,
bool? assicurazioni, bool? insurance,
bool? intrattenimento, bool? entertainment,
bool? finanziamenti, bool? financing,
bool? telepass, bool? telepass,
bool? altro, bool? other,
bool? isActive, bool? isActive,
String? companyId, String? companyId,
List<StoreModel>? associatedStores, List<StoreModel>? associatedStores,
}) { }) {
return ProviderModel( return ProviderModel(
id: id ?? this.id, id: id ?? this.id,
nome: nome ?? this.nome, name: name ?? this.name,
telefoniaFissa: telefoniaFissa ?? this.telefoniaFissa, landline: landline ?? this.landline,
telefoniaMobile: telefoniaMobile ?? this.telefoniaMobile, mobile: mobile ?? this.mobile,
energia: energia ?? this.energia, energy: energy ?? this.energy,
assicurazioni: assicurazioni ?? this.assicurazioni, insurance: insurance ?? this.insurance,
intrattenimento: intrattenimento ?? this.intrattenimento, entertainment: entertainment ?? this.entertainment,
finanziamenti: finanziamenti ?? this.finanziamenti, financing: financing ?? this.financing,
telepass: telepass ?? this.telepass, telepass: telepass ?? this.telepass,
altro: altro ?? this.altro, other: other ?? this.other,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,
associatedStores: associatedStores ?? this.associatedStores, associatedStores: associatedStores ?? this.associatedStores,

View File

@@ -15,14 +15,14 @@ class ProviderFormSheet extends StatefulWidget {
class _ProviderFormSheetState extends State<ProviderFormSheet> { class _ProviderFormSheetState extends State<ProviderFormSheet> {
late TextEditingController _nameController; late TextEditingController _nameController;
late bool _telefoniaFissa; late bool _landline;
late bool _telefoniaMobile; late bool _mobile;
late bool _energia; late bool _energy;
late bool _assicurazioni; late bool _insurance;
late bool _intrattenimento; late bool _entertainment;
late bool _finanziamenti; late bool _financing;
late bool _telepass; late bool _telepass;
late bool _altro; late bool _other;
late bool _isActive; late bool _isActive;
final List<String> _tempSelectedStoreIds = final List<String> _tempSelectedStoreIds =
[]; // Per gestire la selezione temporanea dei negozi []; // Per gestire la selezione temporanea dei negozi
@@ -34,15 +34,15 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
for (final store in p?.associatedStores ?? []) { for (final store in p?.associatedStores ?? []) {
_tempSelectedStoreIds.add(store.id!); _tempSelectedStoreIds.add(store.id!);
} }
_nameController = TextEditingController(text: p?.nome ?? ''); _nameController = TextEditingController(text: p?.name ?? '');
_telefoniaFissa = p?.telefoniaFissa ?? false; _landline = p?.landline ?? false;
_telefoniaMobile = p?.telefoniaMobile ?? false; _mobile = p?.mobile ?? false;
_energia = p?.energia ?? false; _energy = p?.energy ?? false;
_assicurazioni = p?.assicurazioni ?? false; _insurance = p?.insurance ?? false;
_intrattenimento = p?.intrattenimento ?? false; _entertainment = p?.entertainment ?? false;
_finanziamenti = p?.finanziamenti ?? false; _financing = p?.financing ?? false;
_telepass = p?.telepass ?? false; _telepass = p?.telepass ?? false;
_altro = p?.altro ?? false; _other = p?.other ?? false;
_isActive = p?.isActive ?? true; _isActive = p?.isActive ?? true;
} }
@@ -59,15 +59,15 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
final cubit = context.read<ProvidersCubit>(); final cubit = context.read<ProvidersCubit>();
final provider = ProviderModel( final provider = ProviderModel(
id: widget.initialProvider?.id, // Se nullo, Supabase farà insert id: widget.initialProvider?.id, // Se nullo, Supabase farà insert
nome: _nameController.text.trim(), name: _nameController.text.trim(),
telefoniaFissa: _telefoniaFissa, landline: _landline,
telefoniaMobile: _telefoniaMobile, mobile: _mobile,
energia: _energia, energy: _energy,
assicurazioni: _assicurazioni, insurance: _insurance,
intrattenimento: _intrattenimento, entertainment: _entertainment,
finanziamenti: _finanziamenti, financing: _financing,
telepass: _telepass, telepass: _telepass,
altro: _altro, other: _other,
isActive: _isActive, isActive: _isActive,
companyId: companyId:
'', // Verrà ignorato dal toMap e gestito dal Cubit/SessionBloc se hai messo la logica lì '', // Verrà ignorato dal toMap e gestito dal Cubit/SessionBloc se hai messo la logica lì
@@ -113,33 +113,33 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
), ),
_buildSwitch( _buildSwitch(
"Energia (Luce/Gas)", "Energia (Luce/Gas)",
_energia, _energy,
(v) => setState(() => _energia = v), (v) => setState(() => _energy = v),
), ),
_buildSwitch( _buildSwitch(
"Telefonia Fissa", "Telefonia Fissa",
_telefoniaFissa, _landline,
(v) => setState(() => _telefoniaFissa = v), (v) => setState(() => _landline = v),
), ),
_buildSwitch( _buildSwitch(
"Telefonia Mobile", "Telefonia Mobile",
_telefoniaMobile, _mobile,
(v) => setState(() => _telefoniaMobile = v), (v) => setState(() => _mobile = v),
), ),
_buildSwitch( _buildSwitch(
"Assicurazioni", "Assicurazioni",
_assicurazioni, _insurance,
(v) => setState(() => _assicurazioni = v), (v) => setState(() => _insurance = v),
), ),
_buildSwitch( _buildSwitch(
"Intrattenimento", "Intrattenimento",
_intrattenimento, _entertainment,
(v) => setState(() => _intrattenimento = v), (v) => setState(() => _entertainment = v),
), ),
_buildSwitch( _buildSwitch(
"Finanziamenti", "Finanziamenti",
_finanziamenti, _financing,
(v) => setState(() => _finanziamenti = v), (v) => setState(() => _financing = v),
), ),
_buildSwitch( _buildSwitch(
"Telepass", "Telepass",
@@ -148,8 +148,8 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
), ),
_buildSwitch( _buildSwitch(
"Altro/Accessori", "Altro/Accessori",
_altro, _other,
(v) => setState(() => _altro = v), (v) => setState(() => _other = v),
), ),
const Divider(), const Divider(),
_buildSwitch( _buildSwitch(

View File

@@ -93,7 +93,7 @@ class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
), ),
), ),
title: Text( title: Text(
provider.nome, provider.name,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: _buildCardSubtitle( subtitle: _buildCardSubtitle(
@@ -141,14 +141,13 @@ class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
return Wrap( return Wrap(
spacing: 4, spacing: 4,
children: [ children: [
if (p.telefoniaFissa || p.telefoniaMobile) if (p.landline || p.mobile) _smallTag("📞 Tel", Colors.blue),
_smallTag("📞 Tel", Colors.blue), if (p.energy) _smallTag("⚡ Energy", Colors.orange),
if (p.energia) _smallTag("⚡ Energy", Colors.orange), if (p.insurance) _smallTag("🛡️ Assic", Colors.teal),
if (p.assicurazioni) _smallTag("🛡️ Assic", Colors.teal), if (p.entertainment) _smallTag("📺 Ent", Colors.red),
if (p.intrattenimento) _smallTag("📺 Ent", Colors.red), if (p.financing) _smallTag("💰 Fin", Colors.purple),
if (p.finanziamenti) _smallTag("💰 Fin", Colors.purple),
if (p.telepass) _smallTag("🏎️ Telepass", Colors.yellow), if (p.telepass) _smallTag("🏎️ Telepass", Colors.yellow),
if (p.altro) _smallTag("📦 Altro", Colors.grey), if (p.other) _smallTag("📦 Altro", Colors.grey),
], ],
); );
} }

View File

@@ -98,7 +98,7 @@ class StoreRepository {
) )
''') ''')
.eq('company_id', companyId) .eq('company_id', companyId)
.order('nome'); .order('name');
return (response as List).map((m) => StoreModel.fromMap(m)).toList(); return (response as List).map((m) => StoreModel.fromMap(m)).toList();
} catch (e) { } catch (e) {

View File

@@ -191,7 +191,7 @@ class _StoreCardState extends State<StoreCard> {
(p) => p.id == provider.id, (p) => p.id == provider.id,
); );
return CheckboxListTile( return CheckboxListTile(
title: Text(provider.nome), title: Text(provider.name),
value: isAssociated, value: isAssociated,
onChanged: (selected) { onChanged: (selected) {
if (selected == true) { if (selected == true) {

View File

@@ -165,7 +165,7 @@ class OperationsCubit extends Cubit<OperationsState> {
); );
final updatedOperation = await _repository.saveFullOperation( final updatedOperation = await _repository.saveFullOperation(
operationToSave, operation: operationToSave,
); );
emit( emit(
@@ -233,6 +233,16 @@ class OperationsCubit extends Cubit<OperationsState> {
// Creiamo il modello aggiornato // Creiamo il modello aggiornato
// ATTENZIONE: adatta questa logica in base a come è scritto il tuo copyWith! // ATTENZIONE: adatta questa logica in base a come è scritto il tuo copyWith!
int? newQuantity;
if (clearQuantity) {
newQuantity = 1;
}
if (quantity != null && quantity <= 0) {
newQuantity = 0;
}
if (quantity != null && quantity > 0) {
newQuantity = quantity;
}
final updated = current.copyWith( final updated = current.copyWith(
customerId: customerId, customerId: customerId,
customerDisplayName: customerDisplayName, customerDisplayName: customerDisplayName,
@@ -242,7 +252,7 @@ class OperationsCubit extends Cubit<OperationsState> {
providerDisplayName: clearProvider providerDisplayName: clearProvider
? null ? null
: (providerDisplayName ?? current.providerDisplayName), : (providerDisplayName ?? current.providerDisplayName),
quantity: clearQuantity ? 1 : (quantity ?? current.quantity), quantity: newQuantity,
type: clearType ? null : (type ?? current.type), type: clearType ? null : (type ?? current.type),
subtype: clearSubtype ? null : (subtype ?? current.subtype), subtype: clearSubtype ? null : (subtype ?? current.subtype),
expirationDate: clearExpiration expirationDate: clearExpiration

View File

@@ -99,38 +99,35 @@ class OperationsRepository {
} }
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) --- // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<OperationModel> saveFullOperation(OperationModel operation) async { Future<OperationModel> saveFullOperation({
required OperationModel operation,
}) async {
try { try {
// 1. Upsert del record principale // 1. Salvataggio classico dell'operazione corrente
final operationData = await _supabase final response = await _supabase
.from('operation') .from('operation')
.upsert(operation.toMap()) .upsert(operation.toMap())
.select() .select(
'*, provider(*), model(*), store(*), staff_member(*), customer(*), attachment(*)',
)
.single(); .single();
final String newId = operationData['id']; final savedOperation = OperationModel.fromMap(response);
// 5. GRAN FINALE: RECUPERO DEL MODELLO COMPLETO E AGGIORNATO // 2. ALLINEAMENTO BATCH SEMPRE ATTIVO!
// Interroghiamo Supabase per farci restituire la pratica con TUTTI gli ID generati if (operation.batchUuid.isNotEmpty) {
// (inclusi quelli della tabella operation_file appena inseriti) await _supabase
final updatedOperationData = await _supabase
.from('operation') .from('operation')
.select(''' .update({'note': operation.note}) // Spalmiamo la nota attuale
*, .eq(
staff_member(name), 'batch_uuid',
store(name), operation.batchUuid,
provider(name), ); // Su tutte le pratiche di questo scontrino
model(name_with_brand), }
customer(name),
attachment(*)
''')
.eq('id', newId)
.single();
return OperationModel.fromMap(updatedOperationData); return savedOperation;
} catch (e) { } catch (e) {
// Qui potresti aggiungere una logica di "rollback manuale" se necessario throw Exception("Errore nel salvataggio dell'operazione: $e");
throw Exception('$e');
} }
} }

View File

@@ -169,35 +169,49 @@ class OperationModel extends Equatable {
factory OperationModel.fromMap(Map<String, dynamic> map) { factory OperationModel.fromMap(Map<String, dynamic> map) {
return OperationModel( return OperationModel(
id: map['id'], id: map['id'] as String?,
createdAt: map['created_at'] != null createdAt: map['created_at'] != null
? DateTime.parse(map['created_at']) ? DateTime.parse(map['created_at'])
: null, : null,
type: map['type'] as String? ?? '', type: map['type'] as String? ?? '',
subtype: map['sub_type'] as String?, subtype: map['sub_type'] as String?,
providerId: map['provider_id'] as String? ?? '',
providerDisplayName: "${map['provider']['name']}".myFormat(), // I campi relazionali nullabili restano rigorosamente null!
modelId: map['model_id'] as String? ?? '', providerId: map['provider_id'] as String?,
modelDisplayName: "${map['model']['name_with_brand'] ?? ''}".myFormat(), // MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
description: map['description'] as String? ?? '', providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(),
modelId: map['model_id'] as String?,
modelDisplayName: (map['model']?['name_with_brand'] as String?)
?.myFormat(),
description: map['description'] as String?,
expirationDate: map['expiration_date'] != null expirationDate: map['expiration_date'] != null
? DateTime.parse(map['expiration_date']) ? DateTime.parse(map['expiration_date'])
: null, : null,
note: map['note'] as String? ?? '', note: map['note'] as String? ?? '',
showInDashboard: map['show_in_dashboard'] as bool, showInDashboard: map['show_in_dashboard'] as bool? ?? true,
batchUuid: map['batch_uuid'] as String, batchUuid: map['batch_uuid'] as String? ?? '',
companyId: map['company_id'] as String, companyId: map['company_id'] as String,
storeId: map['store_id'] as String? ?? '',
storeDisplayName: "${map['store']['name']}".myFormat(), storeId:
map['store_id'] as String? ??
'', // Questo è non-nullable nella tua classe
storeDisplayName: (map['store']?['name'] as String?)?.myFormat(),
quantity: map['quantity'] is int quantity: map['quantity'] is int
? map['quantity'] ? map['quantity']
: int.tryParse(map['quantity']?.toString() ?? '0') ?? 0, : int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
staffId: map['staff_id'] as String? ?? '',
staffDisplayName: "${map['staff_member']['name'] ?? ''}".myFormat(), staffId: map['staff_id'] as String?,
lastCampaignId: map['last_campaign_id'] as String? ?? '', staffDisplayName: (map['staff_member']?['name'] as String?)?.myFormat(),
status: OperationStatus.fromString(map['status']),
customerId: map['customer_id'] as String? ?? '', lastCampaignId: map['last_campaign_id'] as String?,
customerDisplayName: "${map['customer']['name'] ?? ''}".myFormat(), status: OperationStatus.fromString(map['status'] ?? 'draft'),
customerId: map['customer_id'] as String?,
customerDisplayName: (map['customer']?['name'] as String?)?.myFormat(),
attachments: attachments:
(map['attachment'] as List?) (map['attachment'] as List?)
?.map((x) => AttachmentModel.fromMap(x)) ?.map((x) => AttachmentModel.fromMap(x))

View File

@@ -1,12 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customers_cubit.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/widgets/customer_section.dart';
import 'package:flux/features/operations/ui/widgets/details_section.dart'; // ASSICURATI DEL PATH
// import 'package:flux/features/attachments/ui/operation_files_section.dart'; // import 'package:flux/features/attachments/ui/operation_files_section.dart';
class OperationFormScreen extends StatefulWidget { class OperationFormScreen extends StatefulWidget {
@@ -26,7 +23,6 @@ class OperationFormScreen extends StatefulWidget {
class _OperationFormScreenState extends State<OperationFormScreen> { class _OperationFormScreenState extends State<OperationFormScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
// TEXT CONTROLLERS (Unici detentori di stato locale per evitare lag)
final _referenceController = TextEditingController(); final _referenceController = TextEditingController();
final _noteController = TextEditingController(); final _noteController = TextEditingController();
final _freeTextSubtypeController = TextEditingController(); final _freeTextSubtypeController = TextEditingController();
@@ -43,40 +39,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
'Custom', 'Custom',
]; ];
bool _doesProviderMatchOperationType(dynamic provider, String operationType) {
// Se è Custom o non riconosciuto, mostriamo tutto
if (operationType == 'Custom') return true;
// Qui mappiamo il tipo di operazione scelto con i bool del ProviderModel
switch (operationType) {
case 'AL':
return provider.telefoniaMobile == true;
case 'MNP':
return provider.telefoniaMobile == true;
case 'NIP':
return provider.telefoniaFissa == true;
case 'UNICA':
return provider.telefoniaFissa == true ||
provider.telefoniaMobile == true;
case 'Energy':
return provider.energia == true;
case 'Fin':
return provider.finanziamenti == true;
case 'Entertainment':
return provider.intrattenimento == true;
case 'TELEPASS':
return provider.telepass == true;
default:
return true;
}
}
bool _isInitialized = false; bool _isInitialized = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Inizializziamo il form nel Cubit
context.read<OperationsCubit>().initOperationForm( context.read<OperationsCubit>().initOperationForm(
existingOperation: widget.existingOperation, existingOperation: widget.existingOperation,
operationId: widget.operationId, operationId: widget.operationId,
@@ -91,7 +58,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
super.dispose(); super.dispose();
} }
// Sincronizza SOLO i testi liberi quando il Cubit ha caricato da DB
void _syncTextControllers(OperationModel model) { void _syncTextControllers(OperationModel model) {
if (_referenceController.text.isEmpty && model.reference.isNotEmpty) { if (_referenceController.text.isEmpty && model.reference.isNotEmpty) {
_referenceController.text = model.reference; _referenceController.text = model.reference;
@@ -107,23 +73,20 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
_isInitialized = true; _isInitialized = true;
} }
// --- LOGICA DI SALVATAGGIO ---
void _saveOperation({required bool keepAdding}) { void _saveOperation({required bool keepAdding}) {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
final cubit = context.read<OperationsCubit>(); final cubit = context.read<OperationsCubit>();
final currentOperation = cubit.state.currentOperation!; final currentOperation = cubit.state.currentOperation!;
// 1. "Travasiamo" i testi liberi dai controller al Modello prima di salvare
final operationToSave = currentOperation.copyWith( final operationToSave = currentOperation.copyWith(
reference: _referenceController.text, reference: _referenceController.text,
note: _noteController.text, note: _noteController.text,
// subtype: currentOperation.type == 'Custom' ? _customSubtypeController.text : currentOperation.subtype, // <-- Scommenta quando aggiungi subtype subtype: ['Entertainment', 'Custom'].contains(currentOperation.type)
? _freeTextSubtypeController.text
: currentOperation.subtype,
); );
// 2. Aggiorniamo il Cubit con i testi
cubit.initOperationForm(existingOperation: operationToSave); cubit.initOperationForm(existingOperation: operationToSave);
// 3. Salviamo!
cubit.saveCurrentOperation( cubit.saveCurrentOperation(
targetStatus: OperationStatus.ok, targetStatus: OperationStatus.ok,
shouldPop: !keepAdding, shouldPop: !keepAdding,
@@ -131,165 +94,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
} }
} }
// --- MODALE SELEZIONE CLIENTE ---
void _showCustomerModal() {
String currentSearchQuery = '';
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.8,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Cliente',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
// Barra di Ricerca
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: 'Cerca per nome, telefono o email...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) {
currentSearchQuery = query;
context.read<CustomersCubit>().searchCustomers(query);
},
),
),
// Pulsante Nuovo Cliente
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.person_add),
label: const Text('Crea Nuovo Cliente'),
onPressed: () async {
final OperationsCubit operationsCubit = context
.read<OperationsCubit>();
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
final newCustomer = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<CustomersCubit>(),
child: QuickCustomerDialog(
initialQuery:
currentSearchQuery, // <-- Passiamo quello che ha digitato!
),
);
},
);
// Se l'ha creato davvero (e non ha premuto annulla)...
if (newCustomer != null) {
// 1. Aggiorniamo il form delle operazioni
operationsCubit.updateOperationFields(
customerId: newCustomer.id,
customerDisplayName: newCustomer.name,
);
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
if (context.mounted) {
Navigator.pop(modalContext);
}
}
},
),
),
const Divider(),
// Lista Clienti dal Bloc
Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>(
builder: (context, state) {
if (state.status == CustomersStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.customers.isEmpty) {
return const Center(
child: Text(
'Nessun cliente trovato.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: state.customers.length,
itemBuilder: (context, index) {
final customer = state.customers[index];
return ListTile(
leading: CircleAvatar(
child: Text(
customer.name.substring(0, 1).toUpperCase(),
),
),
title: Text(
customer.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'${customer.phoneNumber}${customer.email}',
),
onTap: () {
// Aggiorniamo il form tramite il Cubit delle operazioni
context
.read<OperationsCubit>()
.updateOperationFields(
customerId: customer.id, // customer.id
customerDisplayName:
customer.name, // customer.name
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -299,7 +103,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
previous.status != current.status || previous.status != current.status ||
previous.currentOperation?.id != current.currentOperation?.id, previous.currentOperation?.id != current.currentOperation?.id,
listener: (context, state) { listener: (context, state) {
// Sincronizzazione iniziale
if (state.status == OperationsStatus.ready && if (state.status == OperationsStatus.ready &&
state.currentOperation != null && state.currentOperation != null &&
!_isInitialized) { !_isInitialized) {
@@ -315,9 +118,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
content: Text('Servizio aggiunto! Inserisci il prossimo.'), content: Text('Servizio aggiunto! Inserisci il prossimo.'),
), ),
); );
// Ripuliamo SOLO i testi liberi (il Cubit gestisce già i suoi reset)
_referenceController.clear();
_noteController.clear();
_freeTextSubtypeController.clear(); _freeTextSubtypeController.clear();
} else if (state.status == OperationsStatus.failure) { } else if (state.status == OperationsStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -329,7 +129,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
} }
}, },
builder: (context, state) { builder: (context, state) {
// Loader iniziale
if (!_isInitialized && if (!_isInitialized &&
(widget.operationId != null || widget.existingOperation != null) && (widget.operationId != null || widget.existingOperation != null) &&
state.status == OperationsStatus.loading) { state.status == OperationsStatus.loading) {
@@ -351,9 +150,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 900; final isDesktop = constraints.maxWidth > 900;
if (isDesktop) { if (isDesktop) {
// --- LAYOUT DESKTOP ---
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -375,7 +172,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
], ],
); );
} else { } else {
// --- LAYOUT MOBILE ---
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
@@ -392,7 +188,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
}, },
), ),
), ),
// --- LA CASSA ---
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -438,8 +233,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
); );
} }
// --- COSTRUTTORI UI COMPONENTI ---
Widget _buildMainFormContent(ThemeData theme, OperationsState state) { Widget _buildMainFormContent(ThemeData theme, OperationsState state) {
final currentOp = state.currentOperation; final currentOp = state.currentOperation;
final currentType = currentOp?.type ?? 'AL'; final currentType = currentOp?.type ?? 'AL';
@@ -447,9 +240,8 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// --- BLOCCO 1: CONTESTO ---
_buildSectionTitle('Cliente & Riferimento'), _buildSectionTitle('Cliente & Riferimento'),
_buildCustomerSelector(currentOp), CustomerSection(currentOp: currentOp),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _referenceController, controller: _referenceController,
@@ -460,7 +252,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
), ),
const Divider(height: 32), const Divider(height: 32),
// --- BLOCCO 2: TIPO DI OPERAZIONE ---
_buildSectionTitle('Cosa stiamo facendo?'), _buildSectionTitle('Cosa stiamo facendo?'),
Wrap( Wrap(
spacing: 8.0, spacing: 8.0,
@@ -471,7 +262,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
selected: currentType == type, selected: currentType == type,
onSelected: (selected) { onSelected: (selected) {
if (selected) { if (selected) {
// Diciamo al Cubit di cambiare tipo e spianare i campi dipendenti
context.read<OperationsCubit>().setTypeWithSmartDefault(type); context.read<OperationsCubit>().setTypeWithSmartDefault(type);
} }
}, },
@@ -480,155 +270,13 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
), ),
const Divider(height: 32), const Divider(height: 32),
// --- BLOCCO 3: DETTAGLI REATTIVI ---
_buildSectionTitle('Dettagli Servizio'), _buildSectionTitle('Dettagli Servizio'),
DetailsSection(
// PROVIDER (Mostrato quasi sempre) currentOp: currentOp,
ListTile( currentType: currentType,
title: const Text('Seleziona Gestore'), freeTextSubtypeController: _freeTextSubtypeController,
subtitle: Text( durationQuickPicks: _buildDurationQuickPicks(currentOp),
(currentOp?.providerDisplayName != null &&
currentOp!.providerDisplayName!.isNotEmpty)
? currentOp.providerDisplayName!
: 'Nessun gestore selezionato',
style: TextStyle(
color:
(currentOp?.providerId == null ||
currentOp!.providerId!.isEmpty)
? Colors.grey
: null,
fontWeight:
(currentOp?.providerId == null ||
currentOp!.providerId!.isEmpty)
? FontWeight.normal
: FontWeight.bold,
), ),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () {
_showProviderModal(currentType);
},
),
const SizedBox(height: 16),
// 1. SCENARIO ENERGY (Dropdown Fisso)
if (currentType == 'Energy') ...[
DropdownButtonFormField<String>(
initialValue:
(currentOp?.subtype != null && currentOp!.subtype!.isNotEmpty)
? currentOp.subtype
: null,
decoration: const InputDecoration(labelText: 'Dettaglio Fornitura'),
items: [
'Luce',
'Gas',
'Dual',
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) {
if (val != null) {
context.read<OperationsCubit>().updateOperationFields(
subtype: val,
);
}
},
),
const SizedBox(height: 16),
],
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
if (currentType == 'Fin') ...[
ListTile(
title: const Text('Seleziona Dispositivo/Prodotto'),
subtitle: Text(
(currentOp?.modelDisplayName != null &&
currentOp!.modelDisplayName!.isNotEmpty)
? currentOp.modelDisplayName!
: 'Nessun modello selezionato',
style: TextStyle(
color:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? Colors.grey
: null,
fontWeight:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? FontWeight.normal
: FontWeight.bold,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: _showModelModal,
),
const SizedBox(height: 16),
],
// 3. SCENARIO ENTERTAINMENT O CUSTOM (Testo libero)
if (currentType == 'Entertainment' || currentType == 'Custom') ...[
TextFormField(
controller: _freeTextSubtypeController,
decoration: InputDecoration(
labelText: currentType == 'Entertainment'
? 'Piattaforma (es. Netflix, DAZN, Spotify...)'
: 'Specifica il servizio (es. Monopattino)',
),
),
const SizedBox(height: 16),
],
// SCADENZA (Reattivo per tipi complessi)
if ([
'Energy',
'Fin',
'Entertainment',
'Custom',
].contains(currentType)) ...[
const SizedBox(height: 8),
// --- I CHIPS RAPIDI ---
_buildDurationQuickPicks(currentOp),
const SizedBox(height: 16),
// --- IL SELETTORE MANUALE ---
ListTile(
title: const Text('Data di Scadenza Effettiva'),
subtitle: Text(
currentOp?.expirationDate != null
? "${currentOp!.expirationDate!.day}/${currentOp.expirationDate!.month}/${currentOp.expirationDate!.year}"
: 'Nessuna scadenza impostata',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
trailing: const Icon(Icons.calendar_month, color: Colors.blue),
tileColor: Colors.blue.withValues(alpha: 0.05),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Colors.blue, width: 0.5),
),
onTap: () async {
final OperationsCubit operationsCubit = context
.read<OperationsCubit>();
final date = await showDatePicker(
context: context,
initialDate:
currentOp?.expirationDate ??
DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 3650)),
);
if (date != null) {
operationsCubit.updateOperationFields(expirationDate: date);
}
},
),
const SizedBox(height: 16),
],
// QUANTITÀ // QUANTITÀ
Row( Row(
@@ -662,7 +310,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
), ),
const Divider(height: 32), const Divider(height: 32),
// --- BLOCCO 5: ALLEGATI ---
_buildSectionTitle('Documenti & Foto'), _buildSectionTitle('Documenti & Foto'),
const Center( const Center(
child: Text( child: Text(
@@ -676,7 +323,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
Widget _buildDurationQuickPicks(OperationModel? currentOp) { Widget _buildDurationQuickPicks(OperationModel? currentOp) {
final durations = [3, 6, 12, 24, 30, 36, 48]; final durations = [3, 6, 12, 24, 30, 36, 48];
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -700,13 +346,12 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
backgroundColor: Colors.blue.withValues(alpha: 0.05), backgroundColor: Colors.blue.withValues(alpha: 0.05),
onPressed: () { onPressed: () {
final now = DateTime.now(); final now = DateTime.now();
final newDate = DateTime( context.read<OperationsCubit>().updateOperationFields(
expirationDate: DateTime(
now.year, now.year,
now.month + months, now.month + months,
now.day, now.day,
); ),
context.read<OperationsCubit>().updateOperationFields(
expirationDate: newDate,
); );
}, },
), ),
@@ -733,23 +378,20 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
); );
return isDesktop
if (isDesktop) { ? Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
title, title,
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded(child: noteField), Expanded(child: noteField),
], ],
); )
} else { : Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [title, const SizedBox(height: 8), noteField], children: [title, const SizedBox(height: 8), noteField],
); );
} }
}
Widget _buildSectionTitle(String title) { Widget _buildSectionTitle(String title) {
return Padding( return Padding(
@@ -762,284 +404,4 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
), ),
); );
} }
Widget _buildCustomerSelector(OperationModel? currentOp) {
final hasCustomer =
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
return InkWell(
onTap: _showCustomerModal,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(8),
color: Theme.of(
context,
).colorScheme.primaryContainer.withValues(alpha: 0.2),
),
child: Row(
children: [
const Icon(Icons.person),
const SizedBox(width: 12),
Expanded(
child: Text(
hasCustomer
? currentOp.customerDisplayName ?? ''
: 'Seleziona Cliente *',
style: TextStyle(
fontWeight: hasCustomer ? FontWeight.bold : FontWeight.normal,
color: hasCustomer ? null : Colors.grey,
),
),
),
const Icon(Icons.search),
],
),
),
);
}
void _showProviderModal(String currentOperationType) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.5, // Parte a metà schermo
minChildSize: 0.4,
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Gestore',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProvidersCubit, ProvidersState>(
// <--- Usa il tuo Cubit dei provider
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
// Simuliamo la lista di provider caricata dal tuo stato
final allProviders = state.activeProviders;
// Applichiamo il nostro filtro magico!
final filteredProviders = allProviders
.where(
(p) => _doesProviderMatchOperationType(
p,
currentOperationType,
),
)
.toList();
if (filteredProviders.isEmpty) {
return const Center(
child: Text(
'Nessun gestore compatibile con questo servizio.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: filteredProviders.length,
itemBuilder: (context, index) {
final provider = filteredProviders[index];
return ListTile(
leading: const Icon(Icons.business),
title: Text(
provider.nome,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
// Selezione effettuata! Diciamo al Cubit delle operazioni di aggiornarsi
context
.read<OperationsCubit>()
.updateOperationFields(
providerId: provider.id,
providerDisplayName: provider
.nome, // Fondamentale per la UI!
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
// --- MODALE SELEZIONE MODELLO (PER FINANZIAMENTI) ---
void _showModelModal() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Modello',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
decoration: InputDecoration(
hintText: 'Cerca modello (es. iPhone 15...)',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) {
context.read<ProductsCubit>().searchModels(query);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.add),
label: const Text('Aggiungi Modello al Volo'),
onPressed: () async {
final OperationsCubit operationsCubit = context
.read<OperationsCubit>();
// 1. Recuperiamo la lista dei brand (adatta questo in base a dove tieni i brand nel tuo stato)
final existingBrands = context
.read<ProductsCubit>()
.state
.brands; // <-- Verifica che sia corretto!
// 2. Apriamo il tuo Dialog.
// ATTENZIONE DA CECCHINO: showDialog crea una nuova "rotta" sopra l'albero dei widget,
// quindi dobbiamo passargli il Cubit usando BlocProvider.value per non farglielo perdere!
final newModel = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<ProductsCubit>(),
child: QuickProductDialog(
existingBrands: existingBrands,
),
);
},
);
// 3. Se l'utente ha effettivamente creato un modello e non ha premuto "Annulla"...
if (newModel != null) {
// A. Aggiorniamo il form del Cubit delle operazioni con il nuovo nato!
operationsCubit.updateOperationFields(
modelId: newModel.id,
modelDisplayName: newModel
.nameWithBrand, // <-- Verifica il nome della property
);
// B. Chiudiamo ANCHE la BottomSheet dei modelli per far tornare l'utente al form principale
if (context.mounted) {
Navigator.pop(modalContext);
}
}
},
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProductsCubit, ProductState>(
// <--- Usa il tuo Cubit dei modelli!
builder: (context, state) {
return ListView.builder(
controller: scrollController,
itemCount: state
.models
.length, // Sostituisci con state.models.length
itemBuilder: (context, index) {
final deviceModel = state.models[index];
return ListTile(
leading: const Icon(Icons.devices),
title: Text(
deviceModel.nameWithBrand,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
context
.read<OperationsCubit>()
.updateOperationFields(
modelId: deviceModel.id,
modelDisplayName: deviceModel.nameWithBrand,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
} }

View File

@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customers_cubit.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
class CustomerSection extends StatelessWidget {
final OperationModel? currentOp;
const CustomerSection({super.key, required this.currentOp});
@override
Widget build(BuildContext context) {
final hasCustomer =
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Cliente',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
InkWell(
onTap: () => _showCustomerModal(context), // Passiamo il context!
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.primary),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.2),
),
child: Row(
children: [
const Icon(Icons.person),
const SizedBox(width: 12),
Expanded(
child: Text(
hasCustomer
? currentOp!.customerDisplayName!
: 'Seleziona Cliente *',
style: TextStyle(
fontWeight: hasCustomer
? FontWeight.bold
: FontWeight.normal,
color: hasCustomer ? null : Colors.grey,
),
),
),
const Icon(Icons.search),
],
),
),
),
],
);
}
// --- MODALE SELEZIONE CLIENTE ---
void _showCustomerModal(BuildContext context) {
String currentSearchQuery = '';
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.8,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Cliente',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
// Barra di Ricerca
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: 'Cerca per nome, telefono o email...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) {
currentSearchQuery = query;
context.read<CustomersCubit>().searchCustomers(query);
},
),
),
// Pulsante Nuovo Cliente
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.person_add),
label: const Text('Crea Nuovo Cliente'),
onPressed: () async {
final OperationsCubit operationsCubit = context
.read<OperationsCubit>();
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
final newCustomer = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<CustomersCubit>(),
child: QuickCustomerDialog(
initialQuery:
currentSearchQuery, // <-- Passiamo quello che ha digitato!
),
);
},
);
// Se l'ha creato davvero (e non ha premuto annulla)...
if (newCustomer != null) {
// 1. Aggiorniamo il form delle operazioni
operationsCubit.updateOperationFields(
customerId: newCustomer.id,
customerDisplayName: newCustomer.name,
);
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
if (context.mounted) {
Navigator.pop(modalContext);
}
}
},
),
),
const Divider(),
// Lista Clienti dal Bloc
Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>(
builder: (context, state) {
if (state.status == CustomersStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.customers.isEmpty) {
return const Center(
child: Text(
'Nessun cliente trovato.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: state.customers.length,
itemBuilder: (context, index) {
final customer = state.customers[index];
return ListTile(
leading: CircleAvatar(
child: Text(
customer.name.substring(0, 1).toUpperCase(),
),
),
title: Text(
customer.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'${customer.phoneNumber}${customer.email}',
),
onTap: () {
// Aggiorniamo il form tramite il Cubit delle operazioni
context
.read<OperationsCubit>()
.updateOperationFields(
customerId: customer.id, // customer.id
customerDisplayName:
customer.name, // customer.name
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,413 @@
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/ui/quick_product_dialog.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
class DetailsSection extends StatelessWidget {
final OperationModel? currentOp;
final String currentType;
final TextEditingController freeTextSubtypeController;
final Widget durationQuickPicks;
const DetailsSection({
super.key,
required this.currentOp,
required this.currentType,
required this.freeTextSubtypeController,
required this.durationQuickPicks,
});
bool _doesProviderMatchOperationType(dynamic provider, String operationType) {
if (operationType == 'Custom') return true;
switch (operationType) {
case 'AL':
case 'MNP':
return provider.mobile == true;
case 'NIP':
return provider.landline == true;
case 'UNICA':
return provider.landline == true || provider.mobile == true;
case 'Energy':
return provider.energy == true;
case 'Fin':
return provider.financing == true;
case 'Entertainment':
return provider.entertainment == true;
case 'TELEPASS':
return provider.telepass == true;
default:
return true;
}
}
void _showProviderModal(BuildContext context, String operationType) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.4,
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Gestore',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final allProviders = state.activeProviders;
final filteredProviders = allProviders
.where(
(p) => _doesProviderMatchOperationType(
p,
operationType,
),
)
.toList();
if (filteredProviders.isEmpty) {
return const Center(
child: Text(
'Nessun gestore compatibile con questo servizio.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: filteredProviders.length,
itemBuilder: (context, index) {
final provider = filteredProviders[index];
return ListTile(
leading: const Icon(Icons.business),
title: Text(
provider.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
context
.read<OperationsCubit>()
.updateOperationFields(
providerId: provider.id,
providerDisplayName: provider.name,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
void _showModelModal(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Modello',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
decoration: InputDecoration(
hintText: 'Cerca modello (es. iPhone 15...)',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) =>
context.read<ProductsCubit>().searchModels(query),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.add),
label: const Text('Aggiungi Modello al Volo'),
onPressed: () async {
final operationsCubit = context.read<OperationsCubit>();
final existingBrands = context
.read<ProductsCubit>()
.state
.brands;
final newModel = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<ProductsCubit>(),
child: QuickProductDialog(
existingBrands: existingBrands,
),
);
},
);
if (newModel != null) {
operationsCubit.updateOperationFields(
modelId: newModel.id,
modelDisplayName: newModel.nameWithBrand,
);
if (context.mounted) Navigator.pop(modalContext);
}
},
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProductsCubit, ProductState>(
builder: (context, state) {
return ListView.builder(
controller: scrollController,
itemCount: state.models.length,
itemBuilder: (context, index) {
final deviceModel = state.models[index];
return ListTile(
leading: const Icon(Icons.devices),
title: Text(
deviceModel.nameWithBrand,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
context
.read<OperationsCubit>()
.updateOperationFields(
modelId: deviceModel.id,
modelDisplayName: deviceModel.nameWithBrand,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// PROVIDER (Mostrato quasi sempre)
ListTile(
title: const Text('Seleziona Gestore'),
subtitle: Text(
(currentOp?.providerDisplayName != null &&
currentOp!.providerDisplayName!.isNotEmpty)
? currentOp!.providerDisplayName!
: 'Nessun gestore selezionato',
style: TextStyle(
color:
(currentOp?.providerId == null ||
currentOp!.providerId!.isEmpty)
? Colors.grey
: null,
fontWeight:
(currentOp?.providerId == null ||
currentOp!.providerId!.isEmpty)
? FontWeight.normal
: FontWeight.bold,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showProviderModal(context, currentType),
),
const SizedBox(height: 16),
// 1. SCENARIO ENERGY (Dropdown Fisso)
if (currentType == 'Energy') ...[
DropdownButtonFormField<String>(
initialValue:
(currentOp?.subtype != null && currentOp!.subtype!.isNotEmpty)
? currentOp!.subtype
: null,
decoration: const InputDecoration(labelText: 'Dettaglio Fornitura'),
items: [
'Luce',
'Gas',
'Dual',
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) {
if (val != null) {
context.read<OperationsCubit>().updateOperationFields(
subtype: val,
);
}
},
),
const SizedBox(height: 16),
],
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
if (currentType == 'Fin') ...[
ListTile(
title: const Text('Seleziona Dispositivo/Prodotto'),
subtitle: Text(
(currentOp?.modelDisplayName != null &&
currentOp!.modelDisplayName!.isNotEmpty)
? currentOp!.modelDisplayName!
: 'Nessun modello selezionato',
style: TextStyle(
color:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? Colors.grey
: null,
fontWeight:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? FontWeight.normal
: FontWeight.bold,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showModelModal(context),
),
const SizedBox(height: 16),
],
// 3. SCENARIO ENTERTAINMENT O CUSTOM (Testo libero)
if (currentType == 'Entertainment' || currentType == 'Custom') ...[
TextFormField(
controller: freeTextSubtypeController,
decoration: InputDecoration(
labelText: currentType == 'Entertainment'
? 'Piattaforma (es. Netflix, DAZN, Spotify...)'
: 'Specifica il servizio (es. Monopattino)',
),
),
const SizedBox(height: 16),
],
// SCADENZA (Reattivo per tipi complessi)
if ([
'Energy',
'Fin',
'Entertainment',
'Custom',
].contains(currentType)) ...[
const SizedBox(height: 8),
durationQuickPicks, // Passiamo i chips dall'esterno
const SizedBox(height: 16),
ListTile(
title: const Text('Data di Scadenza Effettiva'),
subtitle: Text(
currentOp?.expirationDate != null
? "${currentOp!.expirationDate!.day}/${currentOp!.expirationDate!.month}/${currentOp!.expirationDate!.year}"
: 'Nessuna scadenza impostata',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
trailing: const Icon(Icons.calendar_month, color: Colors.blue),
tileColor: Colors.blue.withValues(alpha: 0.05),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Colors.blue, width: 0.5),
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate:
currentOp?.expirationDate ??
DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 3650)),
);
if (date != null && context.mounted) {
context.read<OperationsCubit>().updateOperationFields(
expirationDate: date,
);
}
},
),
const SizedBox(height: 16),
],
],
);
}
}