Files
flux/lib/features/operations/ui/operation_form_screen.dart
2026-05-19 16:00:40 +02:00

706 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/operations/blocs/operation_form_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/core/widgets/shared_forms/customer_section.dart';
import 'package:flux/features/operations/ui/widgets/details_section.dart';
import 'package:flux/core/widgets/shared_forms/shared_files_section.dart'; // <- Cambiato ad un file unico per coerenza col ticket
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>();
final _referenceController = TextEditingController();
final _noteController = TextEditingController();
final _freeTextSubtypeController = TextEditingController();
final _freeTextDescriptionController = TextEditingController();
final List<String> _availableTypes = [
'AL',
'MNP',
'NIP',
'UNICA',
'FWA',
'TELEPASS',
'Energy',
'Fin',
'Entertainment',
'Altro',
];
bool _isInitialized = false;
@override
void initState() {
super.initState();
// 1. Lanciamo l'inizializzazione sincrona/asincrona
context.read<OperationFormCubit>().initForm(
existingOperation: widget.existingOperation,
operationId: widget.operationId,
);
// 2. Lettura immediata dello stato (come fatto per il customer!)
final currentState = context.read<OperationFormCubit>().state;
if (currentState.status == OperationFormStatus.ready && !_isInitialized) {
_syncTextControllers(currentState.operation);
}
}
@override
void dispose() {
_referenceController.dispose();
_noteController.dispose();
_freeTextSubtypeController.dispose();
_freeTextDescriptionController.dispose();
super.dispose();
}
void _syncTextControllers(OperationModel model) {
if (_referenceController.text.isEmpty) {
_referenceController.text = model.reference;
}
if (_noteController.text.isEmpty) {
_noteController.text = model.note;
}
if (_freeTextSubtypeController.text.isEmpty) {
_freeTextSubtypeController.text = model.subtype ?? '';
}
if (_freeTextDescriptionController.text.isEmpty) {
_freeTextDescriptionController.text = model.description ?? '';
}
_isInitialized = true;
}
void _flushControllersToCubit() {
context.read<OperationFormCubit>().updateFields(
reference: _referenceController.text,
note: _noteController.text,
subtype: _freeTextSubtypeController.text,
description: _freeTextDescriptionController.text,
);
}
void _saveOperation({
required OperationStatus targetStatus,
required bool keepAdding,
}) {
if (_formKey.currentState!.validate()) {
_flushControllersToCubit();
// Aggiorniamo prima lo stato bersaglio nel cubit
context.read<OperationFormCubit>().updateFields(status: targetStatus);
// Poi chiamiamo il salvataggio
context.read<OperationFormCubit>().saveOperation(
targetStatus: targetStatus,
keepAdding: keepAdding,
);
}
}
Future<String?> _generateIdForQr() async {
if (!_formKey.currentState!.validate()) return null;
_flushControllersToCubit();
// Lo leggiamo pulito pulito dal context, perché c'è!
final attachmentsBloc = context.read<AttachmentsBloc>();
final newId = await context.read<OperationFormCubit>().saveOperationDraft();
if (newId != null && context.mounted) {
attachmentsBloc.add(ParentEntitySavedEvent(newId));
}
return newId;
}
Color _getStatusColor(OperationStatus status) {
switch (status) {
case OperationStatus.success:
return Colors.green;
case OperationStatus.waitingForAction:
return Colors.orange;
case OperationStatus.waitingForSupport:
return Colors.blue;
case OperationStatus.failure:
return Colors.red;
case OperationStatus.draft:
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocConsumer<OperationFormCubit, OperationFormState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == OperationFormStatus.ready && !_isInitialized) {
_syncTextControllers(state.operation);
}
if (state.status == OperationFormStatus.success) {
Navigator.of(context).pop();
} else if (state.status == OperationFormStatus.successAndAddAnother) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Operazione salvata! Inserisci la prossima'),
),
);
_freeTextSubtypeController.clear();
_freeTextDescriptionController.clear();
_referenceController.clear();
} else if (state.status == OperationFormStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
if (!_isInitialized &&
(widget.operationId != null || widget.existingOperation != null) &&
state.status == OperationFormStatus.loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final displayStatus =
state.operation.status == OperationStatus.draft &&
state.operation.id == null
? OperationStatus.success
: state.operation.status;
return Scaffold(
appBar: AppBar(
title: Text(
state.operation.id == null
? 'Nuova Pratica - Operatore: ${state.operation.staffDisplayName}'
: 'Modifica Pratica - Operatore: ${state.operation.staffDisplayName}',
),
actions:
displayStatus != OperationStatus.success &&
displayStatus != OperationStatus.draft
? [
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Chip(
label: Text(
displayStatus.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
backgroundColor: _getStatusColor(displayStatus),
),
),
]
: null,
),
body: Form(
key: _formKey,
child: FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(),
child: LayoutBuilder(
builder: (context, constraints) {
final isUltraWide = constraints.maxWidth > 1400;
final isDesktop = constraints.maxWidth > 900;
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isUltraWide
? 1600
: (isDesktop ? 1200 : 800),
),
child: _buildResponsiveLayout(
isUltraWide,
isDesktop,
state,
displayStatus,
),
),
),
);
},
),
),
),
bottomNavigationBar: SafeArea(
child: Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, -3),
),
],
),
child: Row(
children: [
Expanded(
flex: 1,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
displayStatus != OperationStatus.success &&
displayStatus != OperationStatus.draft
? _getStatusColor(displayStatus)
: null,
foregroundColor:
displayStatus != OperationStatus.success &&
displayStatus != OperationStatus.draft
? Colors.white
: null,
),
onPressed: state.status == OperationFormStatus.saving
? null
: () => _saveOperation(
keepAdding: false,
targetStatus: displayStatus,
),
child: state.status == OperationFormStatus.saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Salva ed Esci'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: state.status == OperationFormStatus.saving
? null
: () => _saveOperation(
keepAdding: true,
targetStatus: displayStatus,
),
child: const Text(
'Salva e Aggiungi Altro',
textAlign: TextAlign.center,
),
),
),
],
),
),
),
);
},
);
}
// --- LOGICA DI IMPAGINAZIONE RESPONSIVE ---
Widget _buildResponsiveLayout(
bool isUltraWide,
bool isDesktop,
OperationFormState state,
OperationStatus displayStatus,
) {
if (isUltraWide) {
// 3 COLONNE
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
children: [_cardAnagrafica(state), _cardEsito(state)],
),
),
const SizedBox(width: 24),
Expanded(child: Column(children: [_cardDettagli(state)])),
const SizedBox(width: 24),
Expanded(child: Column(children: [_cardNote(state)])),
],
),
_cardAllegati(state),
],
);
} else if (isDesktop) {
// 2 COLONNE
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
children: [
_cardAnagrafica(state),
_cardEsito(state),
_cardAllegati(state),
],
),
),
const SizedBox(width: 24),
Expanded(
child: Column(children: [_cardDettagli(state), _cardNote(state)]),
),
],
);
} else {
// 1 COLONNA (Mobile)
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_cardAnagrafica(state),
_cardEsito(state),
_cardDettagli(state),
_cardNote(state),
_cardAllegati(state),
],
);
}
}
// --- LE CARD MODULARIZZATE E COLORATE ---
Widget _cardAnagrafica(OperationFormState state) {
return _buildCard(
title: 'Cliente e Riferimento',
icon: Icons.person,
themeColor: Colors.indigo,
children: [
SharedCustomerSection(
customer: state.operation.customer,
onCustomerSelected: (customer) =>
context.read<OperationFormCubit>().updateCustomer(customer),
),
const SizedBox(height: 16),
TextFormField(
controller: _referenceController,
decoration: const InputDecoration(
labelText: 'Riferimento (es. Telefono, Targa...)',
prefixIcon: Icon(Icons.tag),
),
validator: (v) =>
v == null || v.isEmpty ? 'Inserisci un riferimento' : null,
),
],
);
}
Widget _cardEsito(OperationFormState state) {
return _buildCard(
title: 'Esito Pratica',
icon: Icons.fact_check,
themeColor: _getStatusColor(state.operation.status),
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: _getStatusColor(
state.operation.status,
).withValues(alpha: 0.1),
border: Border.all(
color: _getStatusColor(
state.operation.status,
).withValues(alpha: 0.3),
),
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<OperationStatus>(
isExpanded: true,
value: state.operation.status,
icon: Icon(
Icons.arrow_drop_down,
color: _getStatusColor(state.operation.status),
),
items: OperationStatus.values.map((status) {
return DropdownMenuItem(
value: status,
child: Row(
children: [
Icon(
status == OperationStatus.success
? Icons.check_circle
: Icons.error_outline,
color: _getStatusColor(status),
size: 20,
),
const SizedBox(width: 12),
Text(
status.displayName,
style: TextStyle(
fontWeight: FontWeight.w600,
color: _getStatusColor(status),
),
),
],
),
);
}).toList(),
onChanged: (newStatus) {
if (newStatus != null) {
context.read<OperationFormCubit>().updateFields(
status: newStatus,
);
}
},
),
),
),
const SizedBox(height: 8),
Text(
state.operation.status == OperationStatus.success
? 'Lascia OK se caricata con successo.'
: 'Attenzione: pratica salvata in stato anomalo.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
);
}
Widget _cardDettagli(OperationFormState state) {
return _buildCard(
title: 'Dettagli Servizio',
icon: Icons.design_services,
themeColor: Colors.deepOrange,
children: [
Row(
children: [
ChoiceChip(
label: const Text('Privato (Domestico)'),
selected: !state.operation.isBusiness,
selectedColor: Colors.blue.withValues(alpha: 0.2),
checkmarkColor: Colors.blue.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: false,
);
}
},
),
const SizedBox(width: 12),
ChoiceChip(
label: const Text('Business (P.IVA)'),
selected: state.operation.isBusiness,
selectedColor: Colors.orange.withValues(alpha: 0.2),
checkmarkColor: Colors.orange.shade700,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().updateFields(
isBusiness: true,
);
}
},
),
],
),
const Divider(height: 32),
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: _availableTypes.map((type) {
return ChoiceChip(
label: Text(type),
selected: state.operation.type == type,
onSelected: (selected) {
if (selected) {
context.read<OperationFormCubit>().setTypeWithSmartDefault(
type,
);
}
},
);
}).toList(),
),
const Divider(height: 32),
Row(
children: [
const Text(
'Quantità:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: state.operation.quantity > 1
? () => context.read<OperationFormCubit>().updateFields(
quantity: state.operation.quantity - 1,
)
: null,
),
Text(
'${state.operation.quantity}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => context.read<OperationFormCubit>().updateFields(
quantity: state.operation.quantity + 1,
),
),
],
),
const Divider(height: 32),
OperationDetailsSection(
currentOp: state.operation,
currentType: state.operation.type,
freeTextSubtypeController: _freeTextSubtypeController,
freeTextDescriptionController: _freeTextDescriptionController,
durationQuickPicks: _buildDurationQuickPicks(),
),
],
);
}
Widget _cardNote(OperationFormState state) {
return _buildCard(
title: 'Note Interne',
icon: Icons.notes,
themeColor: Colors.teal,
children: [
TextFormField(
controller: _noteController,
minLines: 14,
maxLines: 500,
decoration: InputDecoration(
hintText: 'Incolla seriali, ICCID, IBAN...',
alignLabelWithHint: true,
fillColor: Colors.teal.withValues(alpha: 0.05),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
],
);
}
Widget _cardAllegati(OperationFormState state) {
return _buildCard(
title: 'Allegati e Documenti',
icon: Icons.attach_file,
themeColor: Colors.deepPurple,
children: [
SharedAttachmentsSection(
parentType: AttachmentParentType.operation,
parentId: state.operation.id,
titleForUpload: state.operation.customer?.name ?? 'Nuova Pratica',
onGenerateIdForQr: _generateIdForQr,
),
],
);
}
// --- WIDGET BASE PER LA CARD ---
Widget _buildCard({
required String title,
required IconData icon,
required Color themeColor,
required List<Widget> children,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 24),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: themeColor.withValues(alpha: 0.3), width: 1),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: themeColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: themeColor),
),
const SizedBox(width: 12),
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: themeColor,
),
),
],
),
const Divider(height: 32),
...children,
],
),
),
);
}
Widget _buildDurationQuickPicks() {
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();
context.read<OperationFormCubit>().updateFields(
expirationDate: DateTime(
now.year,
now.month + months,
now.day,
),
);
},
),
);
}).toList(),
),
),
],
);
}
}