Files
flux/lib/features/tickets/ui/ticket_form_screen.dart

1159 lines
41 KiB
Dart
Raw Normal View History

2026-05-11 20:44:17 +02:00
import 'package:flutter/foundation.dart'; // Per kIsWeb
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
2026-05-11 11:44:14 +02:00
import 'package:flux/core/blocs/session/session_cubit.dart';
2026-05-14 15:59:46 +02:00
import 'package:flux/core/routes/routes.dart';
import 'package:flux/core/widgets/shared_forms/customer_section.dart';
import 'package:flux/core/widgets/shared_forms/model_section.dart';
import 'package:flux/core/widgets/shared_forms/shared_files_section.dart';
2026-05-14 12:07:05 +02:00
import 'package:flux/core/widgets/staff_selector_modal.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
2026-05-11 11:44:14 +02:00
import 'package:flux/features/company/models/company_model.dart';
2026-05-14 12:07:05 +02:00
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
import 'package:flux/features/tickets/blocs/ticket_form_state.dart';
2026-05-14 12:07:05 +02:00
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/core/widgets/shared_forms/staff_section.dart';
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
2026-05-12 12:36:50 +02:00
import 'package:flux/features/tickets/ui/ticket_timeline_section.dart';
2026-05-11 11:44:14 +02:00
import 'package:flux/features/tickets/utils/ticket_pdf_service.dart';
2026-05-12 12:36:50 +02:00
import 'package:flux/features/tracking/blocs/tracking_cubit.dart';
import 'package:flux/features/tracking/models/tracking_model.dart';
2026-05-11 11:44:14 +02:00
import 'package:get_it/get_it.dart';
2026-05-14 15:59:46 +02:00
import 'package:go_router/go_router.dart';
2026-05-11 11:44:14 +02:00
import 'package:printing/printing.dart';
class TicketFormScreen extends StatefulWidget {
final TicketModel? existingTicket;
final String? ticketId;
const TicketFormScreen({super.key, this.existingTicket, this.ticketId});
@override
State<TicketFormScreen> createState() => _TicketFormScreenState();
}
class _TicketFormScreenState extends State<TicketFormScreen> {
final _formKey = GlobalKey<FormState>();
final _altPhoneCtrl = TextEditingController();
2026-05-12 11:14:48 +02:00
final _targetSerialCtrl = TextEditingController();
2026-05-14 12:07:05 +02:00
final _targetPasswordCtrl = TextEditingController();
2026-05-12 11:14:48 +02:00
final _sourceSerialCtrl = TextEditingController();
2026-05-14 12:07:05 +02:00
final _sourcePasswordCtrl = TextEditingController();
final _requestCtrl = TextEditingController();
final _accessoriesCtrl = TextEditingController();
final _publicNotesCtrl = TextEditingController();
final _internalNotesCtrl = TextEditingController();
final _priceCtrl = TextEditingController();
final _costCtrl = TextEditingController();
bool _isInitialized = false;
@override
void initState() {
super.initState();
2026-05-21 19:29:46 +02:00
// TRUCCO ANTI-RACE-CONDITION:
// Se il ticket arriva già "pronto" (via extra), popoliamo i controller SUBITO,
// senza aspettare il listener del BLoC che si perderebbe l'emissione sincrona.
if (widget.existingTicket != null) {
_syncTextControllers(widget.existingTicket!);
}
context.read<TicketFormCubit>().initForm(
existingTicket: widget.existingTicket,
id: widget.ticketId,
);
}
@override
void dispose() {
_altPhoneCtrl.dispose();
2026-05-12 11:14:48 +02:00
_targetSerialCtrl.dispose();
2026-05-14 12:07:05 +02:00
_targetPasswordCtrl.dispose();
_sourcePasswordCtrl.dispose();
2026-05-12 11:14:48 +02:00
_sourceSerialCtrl.dispose();
_requestCtrl.dispose();
_accessoriesCtrl.dispose();
_publicNotesCtrl.dispose();
_internalNotesCtrl.dispose();
_priceCtrl.dispose();
_costCtrl.dispose();
super.dispose();
}
void _syncTextControllers(TicketModel model) {
if (_altPhoneCtrl.text.isEmpty) {
_altPhoneCtrl.text = model.alternativePhoneNumber ?? '';
}
2026-05-12 11:14:48 +02:00
if (_targetSerialCtrl.text.isEmpty) {
_targetSerialCtrl.text = model.targetSn ?? '';
}
2026-05-14 12:07:05 +02:00
if (_targetPasswordCtrl.text.isEmpty) {
_targetPasswordCtrl.text = model.targetPassword ?? '';
}
2026-05-12 11:14:48 +02:00
if (_sourceSerialCtrl.text.isEmpty) {
_sourceSerialCtrl.text = model.sourceSn ?? '';
}
2026-05-14 12:07:05 +02:00
if (_sourcePasswordCtrl.text.isEmpty) {
_sourcePasswordCtrl.text = model.sourcePassword ?? '';
}
if (_requestCtrl.text.isEmpty) _requestCtrl.text = model.request;
if (_accessoriesCtrl.text.isEmpty) {
_accessoriesCtrl.text = model.includedAccessories ?? '';
}
if (_publicNotesCtrl.text.isEmpty) {
_publicNotesCtrl.text = model.publicNotes ?? '';
}
if (_internalNotesCtrl.text.isEmpty) {
_internalNotesCtrl.text = model.internalNotes ?? '';
}
if (_priceCtrl.text.isEmpty && model.customerPrice > 0) {
_priceCtrl.text = model.customerPrice.toString();
}
if (_costCtrl.text.isEmpty && model.internalCost > 0) {
_costCtrl.text = model.internalCost.toString();
}
_isInitialized = true;
}
void _flushControllersToCubit() {
context.read<TicketFormCubit>().updateFields(
alternativePhoneNumber: _altPhoneCtrl.text,
2026-05-12 11:14:48 +02:00
targetSn: _targetSerialCtrl.text,
2026-05-14 12:07:05 +02:00
targetPassword: _targetPasswordCtrl.text,
sourcePassword: _sourcePasswordCtrl.text,
2026-05-12 11:14:48 +02:00
sourceSn: _sourceSerialCtrl.text,
request: _requestCtrl.text,
includedAccessories: _accessoriesCtrl.text,
publicNotes: _publicNotesCtrl.text,
internalNotes: _internalNotesCtrl.text,
customerPrice: double.tryParse(_priceCtrl.text) ?? 0.0,
internalCost: double.tryParse(_costCtrl.text) ?? 0.0,
);
}
2026-05-11 11:44:14 +02:00
void _saveTicket() {
if (_formKey.currentState!.validate()) {
_flushControllersToCubit();
2026-05-11 11:44:14 +02:00
context.read<TicketFormCubit>().saveTicket();
}
}
2026-05-25 14:29:48 +02:00
// Formatta in "GG/MM/AAAA HH:MM"
String _formatDateTime(DateTime dt) {
return "${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}";
}
// Lancia i popup di Data e poi di Ora
Future<void> _selectDeliveryDate(
BuildContext context,
TicketModel ticket,
) async {
final initialDate = ticket.estimatedDeliveryAt ?? DateTime.now();
// 1. Chiediamo la Data
final pickedDate = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: DateTime(
2020,
), // Oppure DateTime.now() se non vuoi date passate
lastDate: DateTime(2100),
);
if (pickedDate == null) return; // L'utente ha annullato
// 2. Chiediamo l'Ora
if (!context.mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(initialDate),
);
if (pickedTime == null) return; // L'utente ha annullato
// 3. Fondiamo Data e Ora in un unico DateTime
final finalDateTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
// 4. Aggiorniamo il Cubit
if (!context.mounted) return;
context.read<TicketFormCubit>().updateFields(
estimatedDeliveryAt: finalDateTime,
);
}
Future<String?> _generateIdForQr() async {
// 1. Validiamo i campi obbligatori (es. il cliente)
if (!_formKey.currentState!.validate()) return null;
// 2. Sincronizziamo i testi scritti a mano nel Cubit
_flushControllersToCubit();
2026-05-08 12:28:14 +02:00
final attachmentsBloc = context.read<AttachmentsBloc>();
// 3. Facciamo il salvataggio silenzioso
final newId = await context.read<TicketFormCubit>().saveTicketDraft();
if (newId != null && context.mounted) {
// 4. IL TOCCO DI CLASSE: Diciamo all'AttachmentsBloc che ora la pratica ha un ID!
// Questo farà partire l'upload automatico di eventuali file "in bozza"
2026-05-08 12:28:14 +02:00
attachmentsBloc.add(ParentEntitySavedEvent(newId));
}
return newId;
}
2026-05-11 11:44:14 +02:00
void _showSuccessActions(
BuildContext context,
TicketModel ticket,
CompanyModel company,
) {
showModalBottomSheet(
context: context,
isDismissible: false, // Costringiamo l'operatore a una scelta conscia
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(10),
),
),
const SizedBox(height: 24),
const Icon(Icons.check_circle, color: Colors.green, size: 64),
const SizedBox(height: 16),
Text(
"Ticket Salvato!",
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
"Rif: ${ticket.referenceId}",
style: TextStyle(color: Colors.grey[600], fontSize: 18),
),
const SizedBox(height: 32),
// Griglia delle Azioni
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.5,
children: [
_ActionButton(
icon: Icons.print,
label: "Ricevuta A4",
2026-05-11 20:44:17 +02:00
onTap: () async {
final doc = await TicketPdfService()
.generateTicketReceipt(ticket);
2026-05-11 20:44:17 +02:00
final bytes = await doc.save();
final fileName = 'Ricevuta_${ticket.referenceId}.pdf';
2026-05-16 14:30:23 +02:00
await Printing.layoutPdf(
onLayout: (format) async => bytes,
name: fileName,
);
2026-05-11 11:44:14 +02:00
2026-05-16 14:30:23 +02:00
/* if (kIsWeb || Platform.isMacOS) {
2026-05-11 20:44:17 +02:00
// Forza il download/salvataggio senza passare per il print spooler
await Printing.sharePdf(
bytes: bytes,
filename: fileName,
);
} else {
// Su Android/iOS continuiamo a usare la stampa diretta che funziona
2026-05-16 14:30:23 +02:00
} */
2026-05-11 11:44:14 +02:00
},
),
if (company.labelFormat != LabelFormat.none)
_ActionButton(
icon: Icons.label,
label: "Etichetta",
onTap: () async {
final doc = await TicketPdfService().generateLabelPdf(
ticket,
);
2026-05-11 20:44:17 +02:00
final bytes = await doc.save();
final fileName = 'Ricevuta_${ticket.referenceId}.pdf';
if (kIsWeb || Platform.isMacOS) {
// Forza il download/salvataggio senza passare per il print spooler
await Printing.sharePdf(
bytes: bytes,
filename: fileName,
);
} else {
// Su Android/iOS continuiamo a usare la stampa diretta che funziona
await Printing.layoutPdf(
onLayout: (format) async => bytes,
name: fileName,
);
}
2026-05-11 11:44:14 +02:00
},
),
_ActionButton(
icon: Icons.email,
label: "Invia Email",
2026-05-12 11:14:48 +02:00
onTap: ticket.customer!.email.isNotEmpty ? () {} : null,
2026-05-11 11:44:14 +02:00
),
_ActionButton(
icon: Icons.close,
label: "Chiudi",
color: Colors.blue[900],
textColor: Colors.white,
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).pop();
},
),
],
),
],
),
),
),
);
}
2026-05-14 15:59:46 +02:00
void _navigateToWorkspace(String ticketId) async {
final formCubit = context.read<TicketFormCubit>();
final trackingCubit = context.read<TrackingCubit>();
_flushControllersToCubit();
await context.pushNamed(
Routes.ticketWorkspace, // Assicurati di aver definito questo nome!
pathParameters: {'id': ticketId},
extra: formCubit, // Passiamo l'intero Cubit come extra!
);
if (!context.mounted) return;
trackingCubit.loadTrackings(ticketId, TrackingParentType.ticket);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocConsumer<TicketFormCubit, TicketFormState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == TicketFormStatus.ready && !_isInitialized) {
_syncTextControllers(state.ticket);
}
if (state.status == TicketFormStatus.success) {
2026-05-15 13:32:34 +02:00
context.read<TicketListCubit>().loadTickets(refresh: true);
2026-05-11 11:44:14 +02:00
_showSuccessActions(
context,
state.ticket,
GetIt.I.get<SessionCubit>().state.company!,
);
2026-05-11 11:44:14 +02:00
} else if (state.status == TicketFormStatus.pop) {
Navigator.of(context).pop();
} else if (state.status == TicketFormStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
backgroundColor: theme.colorScheme.error,
),
);
}
},
builder: (context, state) {
final ticket = state.ticket;
return Scaffold(
appBar: AppBar(
title: Text(
ticket.id == null
? 'Nuovo Ticket - Operatore: ${state.ticket.createdByName}'
: 'Modifica Ticket - Operatore: ${state.ticket.createdByName}',
),
actions: [
2026-05-13 12:41:07 +02:00
BlocBuilder<TicketFormCubit, TicketFormState>(
builder: (context, state) {
final ticket = state.ticket;
// Se il ticket non è ancora salvato, niente azioni rapide
if (ticket.id == null || ticket.id!.isEmpty) {
return const SizedBox.shrink();
}
// CONDIZIONE A: Da iniziare
if (ticket.ticketStatus == TicketStatus.open ||
2026-05-14 12:07:05 +02:00
ticket.ticketStatus == TicketStatus.waitingForParts) {
2026-05-13 12:41:07 +02:00
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor:
Colors.amber.shade700, // Colore Action
),
2026-05-14 12:07:05 +02:00
onPressed: () async {
StaffMemberModel? takenBy = await getStaffMember(
context,
);
if (takenBy == null || !context.mounted) return;
2026-05-13 12:41:07 +02:00
context.read<TicketFormCubit>().takeInCharge(
2026-05-14 12:07:05 +02:00
staffId: takenBy.id!,
staffName: takenBy.name,
2026-05-13 12:41:07 +02:00
);
2026-05-14 15:59:46 +02:00
_navigateToWorkspace(ticket.id!);
2026-05-13 12:41:07 +02:00
},
icon: const Icon(Icons.play_arrow, color: Colors.white),
label: const Text(
'Prendi in Carico',
style: TextStyle(color: Colors.white),
),
),
);
}
// CONDIZIONE B: Già in lavorazione
else if (ticket.ticketStatus == TicketStatus.inProgress) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: FilledButton.icon(
2026-05-14 15:59:46 +02:00
onPressed: () => _navigateToWorkspace(ticket.id!),
2026-05-13 12:41:07 +02:00
icon: const Icon(Icons.handyman),
label: const Text('Vai a Lavorazione'),
),
);
}
// Se è chiuso o in altri stati strani, nascondiamo il bottone
return const SizedBox.shrink();
},
),
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Chip(
label: Text(
ticket.ticketStatus.name.toUpperCase(),
style: const TextStyle(color: Colors.white, fontSize: 10),
),
backgroundColor: ticket.ticketStatus.color,
),
),
],
),
body: Form(
key: _formKey,
// IL TRUCCO PER LA TASTIERA: Obblighiamo il tab a seguire il DOM
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,
ticket,
),
),
),
);
},
),
),
),
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: FocusTraversalGroup(
// Un gruppo a parte per il footer, così viene visitato per ultimo
child: Row(
children: [
Expanded(
flex: 1,
2026-05-11 11:44:14 +02:00
child: ElevatedButton(
onPressed: state.ticket.id == null
? null
2026-05-11 11:44:14 +02:00
: () => _showSuccessActions(
context,
ticket,
GetIt.I.get<SessionCubit>().state.company!,
),
child: const Text('Ricevuta'),
),
),
2026-05-12 11:14:48 +02:00
const SizedBox(width: 12),
Expanded(
flex: 1,
child: ElevatedButton(
onPressed: state.status == TicketFormStatus.saving
? null
2026-05-11 11:44:14 +02:00
: () => _saveTicket(),
child: state.status == TicketFormStatus.saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Salva ed Esci'),
),
),
],
),
),
),
),
);
},
);
}
// --- LOGICA DI IMPAGINAZIONE RESPONSIVE ---
Widget _buildResponsiveLayout(
bool isUltraWide,
bool isDesktop,
TicketModel ticket,
) {
if (isUltraWide) {
// 3 COLONNE
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2026-05-12 12:36:50 +02:00
Expanded(
child: Column(
children: [_cardTimeline(ticket), _cardAnagrafica(ticket)],
),
),
2026-05-12 11:14:48 +02:00
const SizedBox(width: 24),
Expanded(
child: Column(
2026-05-12 11:14:48 +02:00
children: [_cardDettagli(ticket), _cardCosti(ticket)],
),
),
const SizedBox(width: 24),
Expanded(
child: Column(
2026-05-12 11:14:48 +02:00
children: [_cardDispositivi(ticket), _cardAssegnazione(ticket)],
),
),
],
);
} else if (isDesktop) {
// 2 COLONNE
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
children: [
_cardAnagrafica(ticket),
2026-05-12 11:14:48 +02:00
_cardDispositivi(ticket),
_cardAssegnazione(ticket),
],
),
),
const SizedBox(width: 24),
Expanded(
child: Column(
2026-05-12 12:36:50 +02:00
children: [
_cardTimeline(ticket),
_cardDettagli(ticket),
_cardCosti(ticket),
],
),
),
],
);
} else {
// 1 COLONNA (Mobile)
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
2026-05-12 12:36:50 +02:00
_cardTimeline(ticket),
_cardAnagrafica(ticket),
2026-05-12 11:14:48 +02:00
_cardDispositivi(ticket),
_cardDettagli(ticket),
_cardCosti(ticket),
_cardAssegnazione(ticket),
],
);
}
}
2026-05-12 12:36:50 +02:00
// --- LE 6 CARD (MODULARIZZATE E COLORATE) ---
Widget _cardAnagrafica(TicketModel ticket) {
return _buildCard(
title: 'Anagrafica',
icon: Icons.person,
themeColor: Colors.indigo,
children: [
/* StaffSection(
label: 'Creato Da',
staffId: ticket.createdById,
staffName: ticket.createdByName,
onStaffSelected: (staff) => context
.read<TicketFormCubit>()
.updateCreator(staffId: staff.id!, staffName: staff.name),
),
const Divider(height: 32), */
SharedCustomerSection(
2026-05-12 11:14:48 +02:00
customer: ticket.customer,
onCustomerSelected: (customer) =>
context.read<TicketFormCubit>().updateCustomer(customer),
),
const SizedBox(height: 16),
TextFormField(
controller: _altPhoneCtrl,
decoration: const InputDecoration(
labelText: 'Recapito Alternativo',
prefixIcon: Icon(Icons.phone),
),
),
],
);
}
2026-05-12 11:14:48 +02:00
Widget _cardDispositivi(TicketModel ticket) {
final bool isDataTransfer = ticket.ticketType == TicketType.dataTransfer;
return _buildCard(
2026-05-12 11:14:48 +02:00
title: isDataTransfer ? 'Dispositivi' : 'Dispositivo',
icon: Icons.devices,
themeColor: Colors.deepOrange,
children: [
2026-05-12 11:14:48 +02:00
// --- DISPOSITIVO TARGET (Nuovo/Ricevente) ---
SharedModelSection(
2026-05-12 11:14:48 +02:00
label: isDataTransfer
? 'Dispositivo Target (Nuovo/Ricevente)'
: 'Modello da Riparare',
modelId: ticket.targetModelId,
modelName: ticket.targetModelName,
onModelSelected: (id, name) => context
.read<TicketFormCubit>()
2026-05-11 11:44:14 +02:00
.updateTargetModel(modelId: id, modelName: name),
),
const SizedBox(height: 16),
TextFormField(
2026-05-12 11:14:48 +02:00
controller: _targetSerialCtrl, // Controller per il seriale TARGET
decoration: const InputDecoration(
labelText: 'Seriale / IMEI',
prefixIcon: Icon(Icons.qr_code),
),
),
2026-05-14 12:07:05 +02:00
const SizedBox(height: 16),
TextFormField(
controller: _targetPasswordCtrl, // Controller per il seriale TARGET
decoration: const InputDecoration(
labelText: 'PIN / Password',
prefixIcon: Icon(Icons.qr_code),
),
),
2026-05-12 11:14:48 +02:00
// --- DISPOSITIVO SORGENTE (Animato per Passaggio Dati) ---
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: isDataTransfer
? Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
// Bordo trasparente e delicato per definire la card
border: Border.all(
color: Colors.orange.shade300.withValues(alpha: 0.2),
),
borderRadius: BorderRadius.circular(8),
// SFONDO RIMOSSO: vedi direttamente il tema scuro sotto!
// color: Colors.transparent, // opzionale
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.devices_fold,
color: Colors.orange.shade700,
),
const SizedBox(width: 12),
Text(
'Dispositivo Sorgente',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.orange.shade900,
),
),
],
),
const SizedBox(height: 16),
// LA SHARED SECTION "SOFT"
SharedModelSection(
label: 'Modello Sorgente (Da cui copiare)',
modelId: ticket.sourceModelId,
modelName: ticket.sourceModelName,
// Sfondo quasi trasparente per non appesantire
backgroundColor: Colors.white.withValues(alpha: 0.1),
// Bordo delicato
borderColor: Colors.orange.shade300.withValues(
alpha: 0.2,
),
onModelSelected: (id, name) => context
.read<TicketFormCubit>()
.updateSourceModel(modelId: id, modelName: name),
),
const SizedBox(height: 16),
TextFormField(
controller: _sourceSerialCtrl,
decoration: InputDecoration(
labelText: 'Seriale / IMEI Sorgente',
prefixIcon: Icon(
Icons.qr_code,
color: Colors.orange.shade700.withValues(
alpha: 0.7,
),
),
// Usiamo lo stesso riempimento tenue per coerenza
fillColor: Colors.white.withValues(alpha: 0.1),
filled: true,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.orange.shade300.withValues(
alpha: 0.2,
),
),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.orange.shade500.withValues(
alpha: 0.5,
),
),
borderRadius: BorderRadius.circular(8),
),
),
),
2026-05-14 12:07:05 +02:00
const SizedBox(height: 16),
TextFormField(
controller: _sourcePasswordCtrl,
decoration: InputDecoration(
labelText: 'PIN / Password Sorgente',
prefixIcon: Icon(
Icons.qr_code,
color: Colors.orange.shade700.withValues(
alpha: 0.7,
),
),
// Usiamo lo stesso riempimento tenue per coerenza
fillColor: Colors.white.withValues(alpha: 0.1),
filled: true,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.orange.shade300.withValues(
alpha: 0.2,
),
),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.orange.shade500.withValues(
alpha: 0.5,
),
),
borderRadius: BorderRadius.circular(8),
),
),
),
2026-05-12 11:14:48 +02:00
],
),
),
)
: const SizedBox.shrink(),
),
],
);
}
Widget _cardDettagli(TicketModel ticket) {
return _buildCard(
title: 'Dettagli Riparazione',
icon: Icons.build,
themeColor: Colors.pink,
children: [
Row(
children: [
Expanded(
child: DropdownButtonFormField<TicketType>(
2026-05-25 12:49:04 +02:00
isExpanded: true,
initialValue: ticket.ticketType,
decoration: const InputDecoration(
labelText: 'Tipo Lavorazione',
),
items: TicketType.values
2026-05-12 11:14:48 +02:00
.map(
(t) => DropdownMenuItem(
value: t,
child: Text(t.displayValue),
),
)
.toList(),
2026-05-12 11:14:48 +02:00
onChanged: (val) {
context.read<TicketFormCubit>().updateFields(ticketType: val);
},
),
),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<TicketStatus>(
2026-05-25 12:49:04 +02:00
isExpanded: true,
initialValue: ticket.ticketStatus,
decoration: const InputDecoration(labelText: 'Stato Attuale'),
items: TicketStatus.values
.map((s) => DropdownMenuItem(value: s, child: Text(s.name)))
.toList(),
onChanged: (val) =>
context.read<TicketFormCubit>().updateFields(status: val),
),
),
],
),
2026-05-25 14:29:48 +02:00
const SizedBox(height: 16),
TextFormField(
readOnly: true, // MAGIA: Impedisce l'apertura della tastiera
// Creiamo un controller "al volo" solo per mostrargli la stringa
controller: TextEditingController(
text: ticket.estimatedDeliveryAt != null
? _formatDateTime(ticket.estimatedDeliveryAt!)
: '',
),
decoration: InputDecoration(
labelText: 'Riconsegna prevista (Data e Ora)',
prefixIcon: const Icon(Icons.event_available),
// Bottone con la X per rimuovere la data se il cliente ti dice "fai con calma"
suffixIcon: ticket.estimatedDeliveryAt != null
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
// NOTA: Dovrai assicurarti che il tuo Cubit gestisca il reset.
// O passi un flag come clearEstimatedDelivery: true,
// o gestisci il null se il tuo updateFields lo permette.
context.read<TicketFormCubit>().updateFields(
clearEstimatedDelivery:
true, // Esempio di flag da aggiungere nel Cubit
);
},
)
: null,
),
// Quando tappi il campo di testo, partono i calendari
onTap: () => _selectDeliveryDate(context, ticket),
),
2026-05-14 12:07:05 +02:00
if (ticket.ticketType == TicketType.repair) ...[
const SizedBox(height: 16),
DropdownButtonFormField<WarrantyType>(
initialValue: ticket
.warrantyType, // Assicurati di avere questo campo nel TicketModel
decoration: const InputDecoration(
labelText: 'Tipo Garanzia',
prefixIcon: Icon(Icons.verified_user_outlined),
),
items: WarrantyType.values
.map(
(w) =>
DropdownMenuItem(value: w, child: Text(w.displayValue)),
)
.toList(),
onChanged: (val) {
context.read<TicketFormCubit>().updateFields(warrantyType: val);
},
),
],
const SizedBox(height: 16),
TextFormField(
controller: _requestCtrl,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Difetto dichiarato o Richiesta del cliente',
alignLabelWithHint: true,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _accessoriesCtrl,
decoration: const InputDecoration(
labelText: 'Accessori Consegnati',
prefixIcon: Icon(Icons.cable),
),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Prestato Telefono di Cortesia?'),
value: ticket.hasCourtesyDevice,
onChanged: (val) => context.read<TicketFormCubit>().updateFields(
hasCourtesyDevice: val,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Theme.of(context).dividerColor),
),
),
],
);
}
Widget _cardCosti(TicketModel ticket) {
return _buildCard(
title: 'Costi & Note',
icon: Icons.euro,
themeColor: Colors.teal,
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: _priceCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Preventivo Cliente (€)',
prefixIcon: Icon(Icons.sell_outlined),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _costCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Nostro Costo (€)',
prefixIcon: Icon(Icons.shopping_cart_outlined),
),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _publicNotesCtrl,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Note Pubbliche (Visibili su ricevuta)',
),
),
const SizedBox(height: 16),
TextFormField(
controller: _internalNotesCtrl,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Note Interne (Solo per lo Staff)',
fillColor: Colors.amber.withValues(alpha: 0.1),
filled: true,
),
),
],
);
}
Widget _cardAssegnazione(TicketModel ticket) {
return _buildCard(
title: 'Assegnazione e Allegati',
icon: Icons.engineering,
themeColor: Colors.deepPurple,
children: [
StaffSection(
label: 'Assegnato A',
staffId: ticket.assignedToId,
staffName: ticket.assignedToName,
onStaffSelected: (staff) => context
.read<TicketFormCubit>()
.updateFields(assignedToId: staff.id, assignedToName: staff.name),
),
const Divider(height: 32),
// ECCO LA MAGIA:
SharedFilesSection(
2026-05-12 11:14:48 +02:00
titleNameForUpload: ticket.customer?.name ?? 'Nuovo Ticket',
onGenerateIdForQr: _generateIdForQr,
),
/* SharedAttachmentsSection(
parentType: AttachmentParentType.ticket,
parentId: ticket.id,
), */
],
);
}
2026-05-12 12:36:50 +02:00
Widget _cardTimeline(TicketModel ticket) {
// Se il ticket è nuovo (non ha ancora un ID salvato a DB), nascondiamo la timeline
if (ticket.id == null || ticket.id!.isEmpty) {
return const SizedBox.shrink();
}
return _buildCard(
title: 'Timeline & Note',
icon: Icons.history,
themeColor: Colors.blueGrey,
2026-05-14 15:59:46 +02:00
children: [TicketTimelineSection(ticketId: ticket.id!)],
2026-05-12 12:36:50 +02:00
);
}
// --- 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, // Tolta l'ombra standard
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: themeColor.withValues(alpha: 0.3),
width: 1,
), // Bordo colorato delicato
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Pallino colorato con l'icona dentro
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),
2026-05-25 12:49:04 +02:00
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: themeColor,
),
),
),
],
),
const Divider(height: 32),
...children,
],
),
),
);
}
}
2026-05-11 11:44:14 +02:00
// Widget helper per i bottoni dell'Action Hub
class _ActionButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback? onTap;
final Color? color;
final Color? textColor;
const _ActionButton({
required this.icon,
required this.label,
this.onTap,
this.color,
this.textColor,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
color: onTap == null ? Colors.grey[100] : (color ?? Colors.grey[200]),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: onTap == null
? Colors.grey
: (textColor ?? Colors.blue[900]),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontWeight: FontWeight.bold,
color: onTap == null
? Colors.grey
: (textColor ?? Colors.blue[900]),
),
),
],
),
),
);
}
}