maremma maiala impestata, buonissima base dopo ultra refactor

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-02 12:19:04 +02:00
parent 1721b2ff89
commit 67e8b8b654
23 changed files with 706 additions and 2730 deletions

View File

@@ -0,0 +1,624 @@
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/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
// import 'package:flux/features/attachments/ui/operation_files_section.dart';
class OperationFormScreen extends StatefulWidget {
final String? operationId;
final OperationModel? existingOperation;
const OperationFormScreen({
super.key,
this.operationId,
this.existingOperation,
});
@override
State<OperationFormScreen> createState() => _OperationFormScreenState();
}
class _OperationFormScreenState extends State<OperationFormScreen> {
final _formKey = GlobalKey<FormState>();
// TEXT CONTROLLERS (Unici detentori di stato locale per evitare lag)
final _referenceController = TextEditingController();
final _noteController = TextEditingController();
final _customSubtypeController = TextEditingController();
final List<String> _availableTypes = [
'AL',
'MNP',
'NIP',
'UNICA',
'TELEPASS',
'Energy',
'Fin',
'Entertainment',
'Custom',
];
bool _isInitialized = false;
@override
void initState() {
super.initState();
// Inizializziamo il form nel Cubit
context.read<OperationsCubit>().initOperationForm(
existingOperation: widget.existingOperation,
operationId: widget.operationId,
);
}
@override
void dispose() {
_referenceController.dispose();
_noteController.dispose();
_customSubtypeController.dispose();
super.dispose();
}
// Sincronizza SOLO i testi liberi quando il Cubit ha caricato da DB
void _syncTextControllers(OperationModel model) {
if (_referenceController.text.isEmpty && model.reference.isNotEmpty) {
_referenceController.text = model.reference;
}
if (_noteController.text.isEmpty && model.note.isNotEmpty) {
_noteController.text = model.note;
}
_isInitialized = true;
}
// --- LOGICA DI SALVATAGGIO ---
void _saveOperation({required bool keepAdding}) {
if (_formKey.currentState!.validate()) {
final cubit = context.read<OperationsCubit>();
final currentOperation = cubit.state.currentOperation!;
// 1. "Travasiamo" i testi liberi dai controller al Modello prima di salvare
final operationToSave = currentOperation.copyWith(
reference: _referenceController.text,
note: _noteController.text,
// subtype: currentOperation.type == 'Custom' ? _customSubtypeController.text : currentOperation.subtype, // <-- Scommenta quando aggiungi subtype
);
// 2. Aggiorniamo il Cubit con i testi
cubit.initOperationForm(existingOperation: operationToSave);
// 3. Salviamo!
cubit.saveCurrentOperation(
targetStatus: OperationStatus.ok,
shouldPop: !keepAdding,
);
}
}
// --- MODALE SELEZIONE CLIENTE ---
void _showCustomerModal() {
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(
decoration: InputDecoration(
hintText: 'Cerca per nome, telefono o email...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) {
// Evento di ricerca (usa debouncer nel cubit!)
// 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: () {
// Apri form nuovo cliente...
},
),
),
const Divider(),
// Lista Clienti dal Bloc
Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>(
builder: (context, state) {
/* Decommenta e adatta al tuo CustomersState
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: 10, // Sostituisci con state.customers.length
itemBuilder: (context, index) {
// final customer = state.customers[index];
return ListTile(
leading: const CircleAvatar(
child: Icon(Icons.person),
),
title: Text(
'Cliente $index',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
), // Sostituisci con customer.name
subtitle: const Text(
'333 1234567',
), // Sostituisci con customer.phoneNumber
onTap: () {
// Aggiorniamo il form tramite il Cubit delle operazioni
context
.read<OperationsCubit>()
.updateOperationFields(
customerId:
'id_del_cliente_$index', // customer.id
customerDisplayName:
'Cliente $index', // customer.name
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocConsumer<OperationsCubit, OperationsState>(
listenWhen: (previous, current) =>
previous.status != current.status ||
previous.currentOperation?.id != current.currentOperation?.id,
listener: (context, state) {
// Sincronizzazione iniziale
if (state.status == OperationsStatus.ready &&
state.currentOperation != null &&
!_isInitialized) {
_syncTextControllers(state.currentOperation!);
}
if (state.status == OperationsStatus.saved) {
Navigator.of(context).pop();
} else if (state.status == OperationsStatus.savedNoPop) {
context.read<OperationsCubit>().prepareNextOperationInBatch();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Servizio aggiunto! Inserisci il prossimo.'),
),
);
// Ripuliamo SOLO i testi liberi (il Cubit gestisce già i suoi reset)
_referenceController.clear();
_noteController.clear();
_customSubtypeController.clear();
} else if (state.status == OperationsStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore'),
backgroundColor: theme.colorScheme.error,
),
);
}
},
builder: (context, state) {
// Loader iniziale
if (!_isInitialized &&
(widget.operationId != null || widget.existingOperation != null) &&
state.status == OperationsStatus.loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: Text(
state.currentOperation?.id == null
? 'Nuova Pratica'
: 'Modifica Pratica',
),
),
body: Form(
key: _formKey,
child: LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 900;
if (isDesktop) {
// --- LAYOUT DESKTOP ---
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 7,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: _buildMainFormContent(theme, state),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildNotesSection(isDesktop: true),
),
),
],
);
} else {
// --- LAYOUT MOBILE ---
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMainFormContent(theme, state),
const Divider(height: 32),
_buildNotesSection(isDesktop: false),
const SizedBox(height: 80),
],
),
);
}
},
),
),
// --- LA CASSA ---
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: state.status == OperationsStatus.saving
? null
: () => _saveOperation(keepAdding: true),
child: const Text(
'Salva e Aggiungi Altro',
textAlign: TextAlign.center,
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: ElevatedButton(
onPressed: state.status == OperationsStatus.saving
? null
: () => _saveOperation(keepAdding: false),
child: state.status == OperationsStatus.saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Salva ed Esci'),
),
),
],
),
),
),
);
},
);
}
// --- COSTRUTTORI UI COMPONENTI ---
Widget _buildMainFormContent(ThemeData theme, OperationsState state) {
final currentOp = state.currentOperation;
final currentType = currentOp?.type ?? 'AL';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- BLOCCO 1: CONTESTO ---
_buildSectionTitle('Cliente & Riferimento'),
_buildCustomerSelector(currentOp),
const SizedBox(height: 16),
TextFormField(
controller: _referenceController,
decoration: const InputDecoration(
labelText: 'Riferimento (es. numero di telefono, targa...)',
prefixIcon: Icon(Icons.tag),
),
),
const Divider(height: 32),
// --- BLOCCO 2: TIPO DI OPERAZIONE ---
_buildSectionTitle('Cosa stiamo facendo?'),
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: _availableTypes.map((type) {
return ChoiceChip(
label: Text(type),
selected: currentType == type,
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,
);
}
},
);
}).toList(),
),
const Divider(height: 32),
// --- BLOCCO 3: DETTAGLI REATTIVI ---
_buildSectionTitle('Dettagli Servizio'),
// PROVIDER (Mostrato quasi sempre)
ListTile(
title: const Text('Seleziona Gestore'),
subtitle: Text(
currentOp?.providerId ?? 'Nessun gestore selezionato',
), // Adatta se hai displayName
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () {
// TODO: Modale o Dropdown Provider
},
),
const SizedBox(height: 16),
// SOTTO-TIPO (Reattivo)
if (['Energy', 'Fin', 'Entertainment'].contains(currentType)) ...[
DropdownButtonFormField<String>(
value:
null, // Sostituisci con currentOp?.subtype quando lo aggiungi
decoration: const InputDecoration(
labelText: 'Dettaglio (es. Luce, Gas...)',
),
items: [
'Luce',
'Gas',
'Dual',
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) {
// context.read<OperationsCubit>().updateOperationFields(subtype: val);
},
),
const SizedBox(height: 16),
],
// SOTTO-TIPO CUSTOM (Reattivo)
if (currentType == 'Custom') ...[
TextFormField(
controller: _customSubtypeController,
decoration: const InputDecoration(
labelText: 'Specifica il servizio (es. Monopattino)',
),
),
const SizedBox(height: 16),
],
// SCADENZA (Reattivo)
if ([
'Energy',
'Fin',
'Entertainment',
'Custom',
].contains(currentType)) ...[
ListTile(
title: const Text('Data di Scadenza'),
subtitle: Text(
currentOp?.expirationDate?.toLocal().toString().split(' ')[0] ??
'Nessuna scadenza',
),
trailing: const Icon(Icons.calendar_today),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 3650)),
);
if (date != null) {
context.read<OperationsCubit>().updateOperationFields(
expirationDate: date,
);
}
},
),
const SizedBox(height: 16),
],
// QUANTITÀ
Row(
children: [
const Text('Quantità: '),
IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
final q = currentOp?.quantity ?? 1;
if (q > 1)
context.read<OperationsCubit>().updateOperationFields(
quantity: q - 1,
);
},
),
Text(
'${currentOp?.quantity ?? 1}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final q = currentOp?.quantity ?? 1;
context.read<OperationsCubit>().updateOperationFields(
quantity: q + 1,
);
},
),
],
),
const Divider(height: 32),
// --- BLOCCO 5: ALLEGATI ---
_buildSectionTitle('Documenti & Foto'),
const Center(
child: Text(
"Widget File in arrivo...",
style: TextStyle(color: Colors.grey),
),
),
],
);
}
Widget _buildNotesSection({required bool isDesktop}) {
final title = _buildSectionTitle('Note Interne');
final noteField = TextFormField(
controller: _noteController,
keyboardType: TextInputType.multiline,
minLines: isDesktop ? null : 5,
maxLines: null,
expands: isDesktop,
textAlignVertical: TextAlignVertical.top,
decoration: const InputDecoration(
hintText: 'Incolla qui seriali, ICCID, IBAN, indirizzi...',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
);
if (isDesktop) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
const SizedBox(height: 8),
Expanded(child: noteField),
],
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [title, const SizedBox(height: 8), noteField],
);
}
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
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),
],
),
),
);
}
}