new operation form almost ready

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-03 10:08:57 +02:00
parent 40ca1a9160
commit 4580173edf
15 changed files with 578 additions and 61 deletions

View File

@@ -213,14 +213,19 @@ class OperationsCubit extends Cubit<OperationsState> {
String? customerDisplayName,
String? type,
String? providerId,
String? providerDisplayName,
String? subtype,
DateTime? expirationDate,
int? quantity,
String? modelId,
String? modelDisplayName,
// Aggiungiamo questi flag per forzare la pulizia dei campi quando cambi tipo
bool clearProvider = false,
bool clearType = false,
bool clearSubtype = false,
bool clearExpiration = false,
bool clearQuantity = false,
bool clearModel = false,
}) {
if (state.currentOperation == null) return;
@@ -231,16 +236,48 @@ class OperationsCubit extends Cubit<OperationsState> {
final updated = current.copyWith(
customerId: customerId,
customerDisplayName: customerDisplayName,
type: clearType ? null : type,
subtype: clearSubtype ? null : subtype,
expirationDate: clearExpiration ? null : expirationDate,
// Se clearProvider è true, forziamo una stringa vuota (o null se il tuo modello lo supporta)
providerId: clearProvider ? null : (providerId ?? current.providerId),
// Idem per subtype e date.
// Se expirationDate è nullabile nel copyWith, dovresti poterlo gestire
quantity: quantity ?? current.quantity,
providerDisplayName: clearProvider
? null
: (providerDisplayName ?? current.providerDisplayName),
quantity: clearQuantity ? 1 : (quantity ?? current.quantity),
type: clearType ? null : (type ?? current.type),
subtype: clearSubtype ? null : (subtype ?? current.subtype),
expirationDate: clearExpiration
? null
: (expirationDate ?? current.expirationDate),
modelId: clearModel ? null : (modelId ?? current.modelId),
modelDisplayName: clearModel
? null
: (modelDisplayName ?? current.modelDisplayName),
);
emit(state.copyWith(currentOperation: updated));
}
// Metodo di utilità per calcolare la data X mesi da oggi
DateTime _calculateMonths(int months) {
final now = DateTime.now();
return DateTime(now.year, now.month + months, now.day);
}
// Quando l'utente seleziona un tipo, impostiamo il default
void setTypeWithSmartDefault(String type) {
DateTime? defaultDate;
if (type == 'Energy') defaultDate = _calculateMonths(24);
if (type == 'Fin') defaultDate = _calculateMonths(30);
if (type == 'Entertainment') defaultDate = _calculateMonths(12);
updateOperationFields(
type: type,
expirationDate: defaultDate,
clearProvider: true,
clearSubtype: true,
clearModel: true,
clearQuantity: true,
);
}
}

View File

@@ -1,6 +1,10 @@
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/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';
// import 'package:flux/features/attachments/ui/operation_files_section.dart';
@@ -25,7 +29,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
// TEXT CONTROLLERS (Unici detentori di stato locale per evitare lag)
final _referenceController = TextEditingController();
final _noteController = TextEditingController();
final _customSubtypeController = TextEditingController();
final _freeTextSubtypeController = TextEditingController();
final List<String> _availableTypes = [
'AL',
@@ -39,6 +43,34 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
'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;
@override
@@ -55,7 +87,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
void dispose() {
_referenceController.dispose();
_noteController.dispose();
_customSubtypeController.dispose();
_freeTextSubtypeController.dispose();
super.dispose();
}
@@ -67,6 +99,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
if (_noteController.text.isEmpty && model.note.isNotEmpty) {
_noteController.text = model.note;
}
if (_freeTextSubtypeController.text.isEmpty &&
model.subtype != null &&
model.subtype!.isNotEmpty) {
_freeTextSubtypeController.text = model.subtype!;
}
_isInitialized = true;
}
@@ -96,6 +133,8 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
// --- MODALE SELEZIONE CLIENTE ---
void _showCustomerModal() {
String currentSearchQuery = '';
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -132,6 +171,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
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),
@@ -140,8 +180,8 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
),
),
onChanged: (query) {
// Evento di ricerca (usa debouncer nel cubit!)
// context.read<CustomersCubit>().searchCustomers(query);
currentSearchQuery = query;
context.read<CustomersCubit>().searchCustomers(query);
},
),
),
@@ -154,8 +194,37 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
),
icon: const Icon(Icons.person_add),
label: const Text('Crea Nuovo Cliente'),
onPressed: () {
// Apri form 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);
}
}
},
),
),
@@ -249,7 +318,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
// Ripuliamo SOLO i testi liberi (il Cubit gestisce già i suoi reset)
_referenceController.clear();
_noteController.clear();
_customSubtypeController.clear();
_freeTextSubtypeController.clear();
} else if (state.status == OperationsStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -403,12 +472,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
onSelected: (selected) {
if (selected) {
// Diciamo al Cubit di cambiare tipo e spianare i campi dipendenti
context.read<OperationsCubit>().updateOperationFields(
type: type,
clearProvider: true,
clearSubtype: true,
clearExpiration: true,
);
context.read<OperationsCubit>().setTypeWithSmartDefault(type);
}
},
);
@@ -423,73 +487,138 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
ListTile(
title: const Text('Seleziona Gestore'),
subtitle: Text(
currentOp?.providerId ?? 'Nessun gestore selezionato',
), // Adatta se hai displayName
(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: () {
// TODO: Modale o Dropdown Provider
_showProviderModal(currentType);
},
),
const SizedBox(height: 16),
// SOTTO-TIPO (Reattivo)
if (['Energy', 'Fin', 'Entertainment'].contains(currentType)) ...[
DropdownButtonFormField<String?>(
// 1. SCENARIO ENERGY (Dropdown Fisso)
if (currentType == 'Energy') ...[
DropdownButtonFormField<String>(
initialValue:
null, // Sostituisci con currentOp?.subtype quando lo aggiungi
decoration: const InputDecoration(
labelText: 'Dettaglio (es. Luce, Gas...)',
),
(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) {
// context.read<OperationsCubit>().updateOperationFields(subtype: val);
if (val != null) {
context.read<OperationsCubit>().updateOperationFields(
subtype: val,
);
}
},
),
const SizedBox(height: 16),
],
// SOTTO-TIPO CUSTOM (Reattivo)
if (currentType == 'Custom') ...[
// 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: _customSubtypeController,
decoration: const InputDecoration(
labelText: 'Specifica il servizio (es. Monopattino)',
controller: _freeTextSubtypeController,
decoration: InputDecoration(
labelText: currentType == 'Entertainment'
? 'Piattaforma (es. Netflix, DAZN, Spotify...)'
: 'Specifica il servizio (es. Monopattino)',
),
),
const SizedBox(height: 16),
],
// SCADENZA (Reattivo)
// 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'),
title: const Text('Data di Scadenza Effettiva'),
subtitle: Text(
currentOp?.expirationDate?.toLocal().toString().split(' ')[0] ??
'Nessuna scadenza',
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_today),
trailing: const Icon(Icons.calendar_month, color: Colors.blue),
tileColor: Colors.blue.withValues(alpha: 0.05),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Colors.blue, width: 0.5),
),
onTap: () async {
final operationsCubit = context.read<OperationsCubit>();
final OperationsCubit operationsCubit = context
.read<OperationsCubit>();
final date = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 365)),
initialDate:
currentOp?.expirationDate ??
DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 3650)),
);
@@ -545,6 +674,50 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
);
}
Widget _buildDurationQuickPicks(OperationModel? currentOp) {
final durations = [3, 6, 12, 24, 30, 36, 48];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Imposta durata rapida (mesi):",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: durations.map((months) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ActionChip(
label: Text("$months m"),
backgroundColor: Colors.blue.withValues(alpha: 0.05),
onPressed: () {
final now = DateTime.now();
final newDate = DateTime(
now.year,
now.month + months,
now.day,
);
context.read<OperationsCubit>().updateOperationFields(
expirationDate: newDate,
);
},
),
);
}).toList(),
),
),
],
);
}
Widget _buildNotesSection({required bool isDesktop}) {
final title = _buildSectionTitle('Note Interne');
final noteField = TextFormField(
@@ -626,4 +799,247 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
),
);
}
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:
'id_del_modello_$index', // deviceModel.id
// Assicurati di avere questo campo in _updateOperationFields nel Cubit!
// modelDisplayName: deviceModel.nameWithBrand,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}