renamed services folder to operations
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ActionCard extends StatelessWidget {
|
||||
final String label;
|
||||
final int count;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ActionCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isActive = count > 0;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 110, // Larghezza fissa per avere una griglia ordinata
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? color.withValues(alpha: 0.15)
|
||||
: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isActive ? color : Colors.grey.withValues(alpha: 0.3),
|
||||
width: isActive ? 2 : 1,
|
||||
),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: isActive ? color : Colors.grey, size: 28),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? color : Colors.grey.shade700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (isActive) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/widgets/image_viewer_widget.dart';
|
||||
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
|
||||
import 'package:flux/core/widgets/qr_upload_dialog.dart';
|
||||
import 'package:flux/features/operations/blocs/service_files_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/models/service_file_model.dart';
|
||||
|
||||
class AttachmentsSection extends StatelessWidget {
|
||||
const AttachmentsSection({super.key});
|
||||
|
||||
Future<void> _pickFiles(BuildContext context) async {
|
||||
// Usiamo withData: true fondamentale per avere i bytes e caricare su Supabase Storage
|
||||
FilePickerResult? result = await FilePicker.pickFiles(
|
||||
allowMultiple: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
context.read<ServiceFilesBloc>().add(AddServiceFilesEvent(result.files));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ServiceFilesBloc serviceFilesBloc = BlocProvider.of<ServiceFilesBloc>(
|
||||
context,
|
||||
);
|
||||
|
||||
return BlocListener<ServicesCubit, ServicesState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.currentService?.id == null &&
|
||||
current.currentService?.id != null,
|
||||
listener: (context, state) {
|
||||
// FIGASSA! La pratica è stata salvata e ora ha un ID.
|
||||
// Diciamo al Bloc dei file di agganciarsi al database.
|
||||
final newId = state.currentService!.id!;
|
||||
context.read<ServiceFilesBloc>().add(ServiceSavedEvent(newId));
|
||||
},
|
||||
child: BlocBuilder<ServiceFilesBloc, ServiceFilesState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER SEZIONE ---
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"DOCUMENTI ALLEGATI",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.attach_file),
|
||||
label: const Text("Aggiungi File"),
|
||||
onPressed: () => _pickFiles(context),
|
||||
),
|
||||
if (!context
|
||||
.read<SessionCubit>()
|
||||
.state
|
||||
.isMobileDevice) ...[
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _handleGenerateQr(context),
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text("GENERA QR"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// --- LISTA VUOTA ---
|
||||
if (state.allFiles.isEmpty) // Furbata: usiamo allFiles.isEmpty!
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade300,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade50,
|
||||
),
|
||||
child: const Text(
|
||||
"Nessun documento allegato alla bozza.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
// --- LISTA PIENA ---
|
||||
else ...[
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: state.allFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = state.allFiles[index];
|
||||
final sizeMb = (file.fileSize / (1024 * 1024))
|
||||
.toStringAsFixed(2);
|
||||
final isPdf = file.extension.toLowerCase() == 'pdf';
|
||||
final isSelected = state.selectedFiles.contains(file);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => serviceFilesBloc.add(
|
||||
ToggleServiceFileSelectionEvent(file),
|
||||
),
|
||||
onDoubleTap: () => _handleDoubleClick(context, file),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 0,
|
||||
// UX Fina: cambiamo colore del bordo se selezionato
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
// UX Fina: Sfondo leggermente colorato se selezionato
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.05)
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isSelected
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(
|
||||
file.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
file.isLocal ? "$sizeMb MB • (Nuovo)" : " MB",
|
||||
),
|
||||
trailing: Icon(
|
||||
isPdf ? Icons.picture_as_pdf : Icons.image,
|
||||
color: isPdf ? Colors.red : Colors.blue,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// --- PANNELLO AZIONI CONTESTUALI (LA MAGIA) ---
|
||||
// Appare SOLO se c'è almeno un file selezionato
|
||||
if (state.selectedFiles.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Contatore
|
||||
Text(
|
||||
"${state.selectedFiles.length} file selezionati",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// Bottone Elimina
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text("Elimina"),
|
||||
onPressed: () {
|
||||
// Qui lancerai l'evento per eliminare i file selezionati!
|
||||
// Es: serviceFilesBloc.add(DeleteSelectedFilesEvent());
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Bottone Copia
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text("Copia in Cliente"),
|
||||
onPressed: () => saveAndCopyFilesToCustomer(
|
||||
context,
|
||||
state.selectedFiles,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleGenerateQr(BuildContext context) async {
|
||||
final cubit = context.read<ServicesCubit>();
|
||||
var currentService = cubit.state.currentService;
|
||||
|
||||
// 1. CATTURIAMO IL BLOC MENTRE SIAMO ANCORA NELLA PAGINA
|
||||
final serviceFilesBloc = context.read<ServiceFilesBloc>();
|
||||
|
||||
// 2. SE LA PRATICA E' NUOVA (Manca l'ID)
|
||||
if (currentService == null || currentService.id == null) {
|
||||
// NIENTE BlocListener qui! Solo un semplice Dialog di conferma
|
||||
final bool? confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Salvataggio Necessario"),
|
||||
content: const Text(
|
||||
"Per generare il QR Code e caricare file dal telefono, la pratica deve essere prima salvata in BOZZA.\n\nVuoi salvare ora?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text("Salva in Bozza"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return; // Utente ha annullato
|
||||
|
||||
// Salviamo forzatamente in bozza
|
||||
await cubit.saveCurrentService(
|
||||
isBozza: true,
|
||||
shouldPop: false,
|
||||
files: serviceFilesBloc.state.localFiles,
|
||||
);
|
||||
|
||||
// Recuperiamo il servizio aggiornato con l'ID!
|
||||
currentService = cubit.state.currentService;
|
||||
|
||||
if (currentService?.id == null) return;
|
||||
}
|
||||
|
||||
// 3. MOSTRIAMO IL QR CODE (Con il Ponte e l'Auto-Chiusura!)
|
||||
if (context.mounted) {
|
||||
final nomePratica = "Pratica ${currentService?.customerDisplayName ?? ''}"
|
||||
.trim();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => BlocProvider.value(
|
||||
// INIETTIAMO IL BLOC NEL CONTESTO DEL DIALOG ALIENO
|
||||
value: serviceFilesBloc,
|
||||
|
||||
// ORA METTIAMO L'AUTO-CHIUSURA SUL QR CODE!
|
||||
child: BlocListener<ServiceFilesBloc, ServiceFilesState>(
|
||||
listener: (context, state) {
|
||||
// Se arrivano file remoti e lo stato è success, chiudiamo il QR!
|
||||
// (Nota: usiamo dialogContext per assicurarci di chiudere il popup giusto)
|
||||
if (state.status == ServiceFilesStatus.success &&
|
||||
state.remoteFiles.isNotEmpty) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
},
|
||||
child: QrUploadDialog(
|
||||
deepLinkUrl:
|
||||
'fluxapp:///service/${currentService!.id}/upload?name=${Uri.encodeComponent(nomePratica)}',
|
||||
title: 'Scatta per\n$nomePratica',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- LOGICA DI COPIA AL CLIENTE ---
|
||||
void saveAndCopyFilesToCustomer(
|
||||
BuildContext context,
|
||||
List<ServiceFileModel> files,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Copia nei documenti Cliente"),
|
||||
content: const Text(
|
||||
"Vuoi copiare i file selezionati nell'anagrafica del cliente? \n\n"
|
||||
"Attenzione: per procedere, la pratica attuale verrà prima salvata in stato BOZZA.",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
// 1. Diciamo al Cubit di salvare in Bozza e fare la copia
|
||||
context.read<ServicesCubit>().saveAndCopyFileToCustomer(files);
|
||||
},
|
||||
child: const Text("Salva e Copia"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- LOGICA DI VISUALIZZAZIONE OVERLAY ---
|
||||
void _handleDoubleClick(BuildContext context, ServiceFileModel file) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) => Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: MediaQuery.of(context).size.height * 0.8,
|
||||
child: file.isPdf
|
||||
? PdfViewerWidget(
|
||||
storagePath: file.storagePath.isNotEmpty
|
||||
? file.storagePath
|
||||
: null,
|
||||
bytes: file.localBytes,
|
||||
)
|
||||
: ImageViewerWidget(
|
||||
storagePath: file.storagePath.isNotEmpty
|
||||
? file.storagePath
|
||||
: null,
|
||||
bytes: file.localBytes,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/features/customers/ui/customer_search_sheet.dart';
|
||||
import 'package:flux/features/operations/models/service_model.dart';
|
||||
|
||||
class CustomerSection extends StatelessWidget {
|
||||
final ServiceModel service;
|
||||
|
||||
const CustomerSection({super.key, required this.service});
|
||||
|
||||
void _openCustomerSearch(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(modalContext).viewInsets.bottom,
|
||||
),
|
||||
// La modale di ricerca
|
||||
child: const CustomerSearchSheet(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Niente BlocBuilder qui! Leggiamo solo la variabile 'service'
|
||||
final hasCustomer = service.customerId != null;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Dati Cliente",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (!hasCustomer)
|
||||
Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _openCustomerSearch(context),
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text("Seleziona o Crea Cliente"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
service.customerDisplayName ?? "Cliente Selezionato",
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _openCustomerSearch(context),
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text("Cambia"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/operations/models/energy_service_model.dart'; // Assicurati degli import
|
||||
|
||||
class EnergyServiceDialog extends StatefulWidget {
|
||||
final List<EnergyServiceModel> initialServices;
|
||||
final String
|
||||
currentStoreId; // Ci serve per sapere per quale negozio caricare i gestori
|
||||
|
||||
const EnergyServiceDialog({
|
||||
super.key,
|
||||
required this.initialServices,
|
||||
required this.currentStoreId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EnergyServiceDialog> createState() => _EnergyServiceDialogState();
|
||||
}
|
||||
|
||||
class _EnergyServiceDialogState extends State<EnergyServiceDialog> {
|
||||
// Lista temporanea per non "sporcare" il cubit finché non si preme Conferma
|
||||
late List<EnergyServiceModel> _tempList;
|
||||
bool _isAddingNew = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempList = List.from(widget.initialServices);
|
||||
// Al caricamento della modale, chiediamo al Cubit di recuperare i gestori veri!
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
widget.currentStoreId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.bolt, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isAddingNew ? "Nuovo Contratto" : "Servizi Energia"),
|
||||
],
|
||||
),
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: SizedBox(
|
||||
width: double.maxFinite,
|
||||
// Cambia vista in base al flag
|
||||
child: _isAddingNew
|
||||
? _EnergyForm(
|
||||
onSave: (newService) {
|
||||
setState(() {
|
||||
_tempList.add(newService);
|
||||
_isAddingNew = false; // Torna alla lista
|
||||
});
|
||||
},
|
||||
onCancel: () {
|
||||
setState(() => _isAddingNew = false);
|
||||
},
|
||||
)
|
||||
: _EnergyList(
|
||||
services: _tempList,
|
||||
onDelete: (index) {
|
||||
setState(() => _tempList.removeAt(index));
|
||||
},
|
||||
onAddTap: () {
|
||||
setState(() => _isAddingNew = true); // Passa al form
|
||||
},
|
||||
activeProviders: [
|
||||
// Passiamo i provider attivi filtrati per tipo Energia
|
||||
...context
|
||||
.read<ProvidersCubit>()
|
||||
.state
|
||||
.activeProviders
|
||||
.where((p) => p.energia == true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (!_isAddingNew) ...[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _tempList),
|
||||
child: const Text("Conferma Tutti"),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VISTA 1: LA LISTA DEI CONTRATTI
|
||||
// ==========================================
|
||||
class _EnergyList extends StatelessWidget {
|
||||
final List<EnergyServiceModel> services;
|
||||
final List<ProviderModel>
|
||||
activeProviders; // <--- NUOVO: La lista vera dal Cubit
|
||||
final Function(int) onDelete;
|
||||
final VoidCallback onAddTap;
|
||||
|
||||
const _EnergyList({
|
||||
required this.services,
|
||||
required this.activeProviders, // <--- Richiesto
|
||||
required this.onDelete,
|
||||
required this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (services.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text(
|
||||
"Nessun contratto energia inserito.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: services.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final s = services[index];
|
||||
final isLuce = s.type == EnergyType.luce;
|
||||
|
||||
// LA MAGIA: Troviamo il nome partendo dall'ID salvato nel servizio
|
||||
final providerIndex = activeProviders.indexWhere(
|
||||
(p) => p.id == s.providerId,
|
||||
);
|
||||
final providerName = providerIndex >= 0
|
||||
? (activeProviders[providerIndex].nome)
|
||||
: 'Gestore Rimosso/Sconosciuto';
|
||||
|
||||
// Formattazione data pulita (es. 04/09/2025)
|
||||
final day = s.expiration.day.toString().padLeft(2, '0');
|
||||
final month = s.expiration.month.toString().padLeft(2, '0');
|
||||
final formattedDate = "$day/$month/${s.expiration.year}";
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isLuce
|
||||
? Colors.orange.shade100
|
||||
: Colors.blue.shade100,
|
||||
child: Icon(
|
||||
isLuce
|
||||
? Icons.lightbulb_outline
|
||||
: Icons.local_fire_department,
|
||||
color: isLuce ? Colors.orange : Colors.blue,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
providerName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text("Scadenza: $formattedDate"),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onDelete(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi Contratto"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VISTA 2: IL FORM DI INSERIMENTO
|
||||
// ==========================================
|
||||
class _EnergyForm extends StatefulWidget {
|
||||
final Function(EnergyServiceModel) onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _EnergyForm({required this.onSave, required this.onCancel});
|
||||
|
||||
@override
|
||||
State<_EnergyForm> createState() => _EnergyFormState();
|
||||
}
|
||||
|
||||
class _EnergyFormState extends State<_EnergyForm> {
|
||||
EnergyType _selectedType = EnergyType.luce;
|
||||
String? _selectedProviderId;
|
||||
DateTime? _selectedExpiration;
|
||||
int? _selectedMonthsPreset;
|
||||
|
||||
void _applyPreset(int? months) {
|
||||
if (months == null) return;
|
||||
setState(() {
|
||||
_selectedMonthsPreset = months;
|
||||
// Calcoliamo la data: oggi + X mesi
|
||||
final now = DateTime.now();
|
||||
_selectedExpiration = DateTime(now.year, now.month + months, now.day);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now().add(
|
||||
const Duration(days: 365),
|
||||
), // Default 1 anno
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _selectedExpiration = picked);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. Tipo (Luce o Gas) - Segmented Button stile M3
|
||||
SegmentedButton<EnergyType>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: EnergyType.luce,
|
||||
label: Text("Luce"),
|
||||
icon: Icon(Icons.lightbulb_outline),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: EnergyType.gas,
|
||||
label: Text("Gas"),
|
||||
icon: Icon(Icons.local_fire_department),
|
||||
),
|
||||
],
|
||||
selected: {_selectedType},
|
||||
onSelectionChanged: (Set<EnergyType> newSelection) {
|
||||
setState(() => _selectedType = newSelection.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 2. SCADENZA INTELLIGENTE (La parte PRO)
|
||||
const Text(
|
||||
"Scadenza Contratto",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
SegmentedButton<int?>(
|
||||
showSelectedIcon: false, // Per un look più pulito
|
||||
segments: const [
|
||||
ButtonSegment(value: 12, label: Text("12m")),
|
||||
ButtonSegment(value: 24, label: Text("24m")),
|
||||
ButtonSegment(value: 36, label: Text("36m")),
|
||||
ButtonSegment(
|
||||
value: null,
|
||||
label: Icon(Icons.calendar_month, size: 20),
|
||||
),
|
||||
],
|
||||
selected: {_selectedMonthsPreset},
|
||||
onSelectionChanged: (Set<int?> newSelection) {
|
||||
final val = newSelection.first;
|
||||
if (val == null) {
|
||||
_pickDate(); // Se clicca l'icona calendario, apre il picker
|
||||
} else {
|
||||
_applyPreset(val); // Altrimenti applica 12, 24 o 36
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Visualizzazione della data calcolata (o scelta)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _selectedExpiration != null
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event,
|
||||
size: 18,
|
||||
color: _selectedExpiration != null
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_selectedExpiration != null
|
||||
? "Scade il: ${_selectedExpiration!.day.toString().padLeft(2, '0')}/${_selectedExpiration!.month.toString().padLeft(2, '0')}/${_selectedExpiration!.year}"
|
||||
: "Seleziona una scadenza",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _selectedExpiration != null
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 2. Provider Dropdown
|
||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(
|
||||
child: LinearProgressIndicator(),
|
||||
); // Mostra una barretta di caricamento
|
||||
}
|
||||
|
||||
if (state.activeProviders.isEmpty) {
|
||||
return const Text(
|
||||
"Nessun gestore associato a questo negozio.",
|
||||
style: TextStyle(color: Colors.red),
|
||||
);
|
||||
}
|
||||
// Filtra solo i provider di tipo Energia (Se hai una categoria nel modello)
|
||||
// Se non hai una categoria nel ProviderModel, puoi rimuovere il .where
|
||||
final energyProviders = state.activeProviders;
|
||||
return DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Gestore / Provider",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
initialValue: _selectedProviderId,
|
||||
items: energyProviders.map((p) {
|
||||
return DropdownMenuItem(value: p.id, child: Text(p.nome));
|
||||
}).toList(),
|
||||
onChanged: (val) => setState(() => _selectedProviderId = val),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. Scadenza (DatePicker integrato in un TextField)
|
||||
TextFormField(
|
||||
readOnly: true,
|
||||
onTap: _pickDate,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Data Scadenza",
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: const Icon(Icons.calendar_month),
|
||||
),
|
||||
// Mostra la data se selezionata, altrimenti vuoto
|
||||
controller: TextEditingController(
|
||||
text: _selectedExpiration != null
|
||||
? "${_selectedExpiration!.day}/${_selectedExpiration!.month}/${_selectedExpiration!.year}"
|
||||
: "",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 4. Pulsanti Interni al Form
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text("Indietro"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
(_selectedProviderId == null || _selectedExpiration == null)
|
||||
? null // Disabilitato se mancano dati obbligatori
|
||||
: () {
|
||||
final newService = EnergyServiceModel(
|
||||
type: _selectedType,
|
||||
expiration: _selectedExpiration!,
|
||||
providerId: _selectedProviderId!,
|
||||
);
|
||||
widget.onSave(newService);
|
||||
},
|
||||
child: const Text("Salva Contratto"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/operations/data/services_repository.dart';
|
||||
import 'package:flux/features/operations/models/entertainment_service_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class EntertainmentServiceDialog extends StatefulWidget {
|
||||
final List<EntertainmentServiceModel> initialServices;
|
||||
final String currentStoreId;
|
||||
|
||||
const EntertainmentServiceDialog({
|
||||
super.key,
|
||||
required this.initialServices,
|
||||
required this.currentStoreId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EntertainmentServiceDialog> createState() =>
|
||||
_EntertainmentServiceDialogState();
|
||||
}
|
||||
|
||||
class _EntertainmentServiceDialogState
|
||||
extends State<EntertainmentServiceDialog> {
|
||||
late List<EntertainmentServiceModel> _tempList;
|
||||
bool _isAddingNew = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempList = List.from(widget.initialServices);
|
||||
// Carichiamo i provider attivi per lo store corrente
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
widget.currentStoreId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.movie_filter_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isAddingNew ? "Nuovo Servizio" : "Servizi Intrattenimento"),
|
||||
],
|
||||
),
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
child: _isAddingNew
|
||||
? _EntertainmentForm(
|
||||
// Il form che abbiamo creato prima
|
||||
onSave: (newService) => setState(() {
|
||||
_tempList.add(newService);
|
||||
_isAddingNew = false;
|
||||
}),
|
||||
onCancel: () => setState(() => _isAddingNew = false),
|
||||
)
|
||||
: BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
// Passiamo allProviders per garantire la visione dello storico
|
||||
return _EntertainmentList(
|
||||
services: _tempList,
|
||||
allProviders: state.allProviders,
|
||||
onDelete: (index) =>
|
||||
setState(() => _tempList.removeAt(index)),
|
||||
onAddTap: () => setState(() => _isAddingNew = true),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: !_isAddingNew
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _tempList),
|
||||
child: const Text("Conferma Tutti"),
|
||||
),
|
||||
]
|
||||
: null, // I pulsanti del form sono interni al form stesso
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EntertainmentList extends StatelessWidget {
|
||||
final List<EntertainmentServiceModel> services;
|
||||
final List<ProviderModel> allProviders;
|
||||
final Function(int) onDelete;
|
||||
final VoidCallback onAddTap;
|
||||
|
||||
const _EntertainmentList({
|
||||
required this.services,
|
||||
required this.allProviders,
|
||||
required this.onDelete,
|
||||
required this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (services.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text(
|
||||
"Nessun servizio intrattenimento.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: services.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final s = services[index];
|
||||
|
||||
final providerName = allProviders
|
||||
.firstWhere(
|
||||
(p) => p.id == s.providerId,
|
||||
orElse: () => ProviderModel(
|
||||
id: '',
|
||||
nome: 'Fornitore Storico',
|
||||
companyId: '',
|
||||
isActive: false,
|
||||
energia: false,
|
||||
telefoniaFissa: false,
|
||||
telefoniaMobile: false,
|
||||
assicurazioni: false,
|
||||
finanziamenti: false,
|
||||
altro: false,
|
||||
intrattenimento: false,
|
||||
),
|
||||
)
|
||||
.nome;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.purple.shade100,
|
||||
child: const Icon(
|
||||
Icons.movie_creation_outlined,
|
||||
color: Colors.purple,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
"${s.type} • $providerName",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
s.constrained
|
||||
? "Vincolo fino al: ${s.constrainExpiration.day}/${s.constrainExpiration.month}/${s.constrainExpiration.year}"
|
||||
: "Senza vincoli",
|
||||
style: TextStyle(
|
||||
color: s.constrained
|
||||
? Colors.red.shade700
|
||||
: Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onDelete(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi Servizio"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---ENTERTAINMENT FORM (MODALE)---
|
||||
|
||||
class _EntertainmentForm extends StatefulWidget {
|
||||
final Function(EntertainmentServiceModel) onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _EntertainmentForm({required this.onSave, required this.onCancel});
|
||||
|
||||
@override
|
||||
State<_EntertainmentForm> createState() => _EntertainmentFormState();
|
||||
}
|
||||
|
||||
class _EntertainmentFormState extends State<_EntertainmentForm> {
|
||||
String? _selectedProviderId;
|
||||
final TextEditingController _typeController = TextEditingController();
|
||||
bool _isConstrained = false;
|
||||
DateTime _expirationDate = DateTime.now().add(
|
||||
const Duration(days: 365),
|
||||
); // Default 12 mesi
|
||||
|
||||
// Preset rapidi per il vincolo (es: 12, 24 mesi)
|
||||
int? _selectedPresetMonths;
|
||||
|
||||
void _applyPreset(int months) {
|
||||
setState(() {
|
||||
_selectedPresetMonths = months;
|
||||
_isConstrained = true;
|
||||
final now = DateTime.now();
|
||||
_expirationDate = DateTime(now.year, now.month + months, now.day);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _expirationDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_expirationDate = picked;
|
||||
_selectedPresetMonths = null;
|
||||
_isConstrained = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. GESTORE (Filtro intrattenimento)
|
||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
final filtered = state.activeProviders
|
||||
.where((p) => p.intrattenimento)
|
||||
.toList();
|
||||
return DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Fornitore (es: Sky, TIM)",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: filtered
|
||||
.map(
|
||||
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) => setState(() => _selectedProviderId = val),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. TIPO SERVIZIO (TextField con suggerimenti rapidi sotto)
|
||||
TextFormField(
|
||||
controller: _typeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Servizio",
|
||||
hintText: "es: Netflix, DAZN, Disney+",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (val) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Suggerimenti rapidi (Chip)
|
||||
FutureBuilder<List<String>>(
|
||||
future: GetIt.I<ServicesRepository>().fetchTopEntertainmentTypes(
|
||||
GetIt.I<SessionCubit>().state.company!.id!,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final suggestions = snapshot.data ?? ["Netflix", "DAZN", "Sky"];
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: suggestions.map((s) {
|
||||
return ActionChip(
|
||||
label: Text(s, style: const TextStyle(fontSize: 12)),
|
||||
onPressed: () => setState(() => _typeController.text = s),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. VINCOLO CONTRATTUALE
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Vincolo di permanenza",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Switch(
|
||||
value: _isConstrained,
|
||||
onChanged: (val) => setState(() {
|
||||
_isConstrained = val;
|
||||
if (!val) _selectedPresetMonths = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_isConstrained) ...[
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<int?>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 12, label: Text("12m")),
|
||||
ButtonSegment(value: 24, label: Text("24m")),
|
||||
ButtonSegment(
|
||||
value: null,
|
||||
label: Icon(Icons.calendar_month, size: 20),
|
||||
),
|
||||
],
|
||||
selected: {_selectedPresetMonths},
|
||||
onSelectionChanged: (val) {
|
||||
if (val.first == null) {
|
||||
_pickDate();
|
||||
} else {
|
||||
_applyPreset(val.first!);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Box data scadenza vincolo
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.event_busy, size: 18, color: Colors.redAccent),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Scadenza vincolo: ${_expirationDate.day.toString().padLeft(2, '0')}/${_expirationDate.month.toString().padLeft(2, '0')}/${_expirationDate.year}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// PULSANTI
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
(_selectedProviderId == null || _typeController.text.isEmpty)
|
||||
? null
|
||||
: () => widget.onSave(
|
||||
EntertainmentServiceModel(
|
||||
providerId: _selectedProviderId!,
|
||||
type: _typeController.text,
|
||||
constrained: _isConstrained,
|
||||
constrainExpiration: _expirationDate,
|
||||
),
|
||||
),
|
||||
child: const Text("Aggiungi"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/models/model_model.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/operations/models/fin_service_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
|
||||
// ===========================================================================
|
||||
// DIALOG PRINCIPALE
|
||||
// ===========================================================================
|
||||
class FinanceServiceDialog extends StatefulWidget {
|
||||
final List<FinServiceModel> initialServices;
|
||||
final String currentStoreId;
|
||||
final ProductCubit productCubit;
|
||||
|
||||
const FinanceServiceDialog({
|
||||
super.key,
|
||||
required this.initialServices,
|
||||
required this.currentStoreId,
|
||||
required this.productCubit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FinanceServiceDialog> createState() => _FinanceServiceDialogState();
|
||||
}
|
||||
|
||||
class _FinanceServiceDialogState extends State<FinanceServiceDialog> {
|
||||
late List<FinServiceModel> _tempList;
|
||||
bool _isAddingNew = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempList = List.from(widget.initialServices);
|
||||
// Carichiamo i dati necessari dai Cubit
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
widget.currentStoreId,
|
||||
);
|
||||
context.read<ProductCubit>().loadBrands();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: widget.productCubit,
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.payments_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isAddingNew ? "Dettagli Finanziamento" : "Finanziamenti"),
|
||||
],
|
||||
),
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
child: _isAddingNew
|
||||
? _FinanceForm(
|
||||
onSave: (newFin) => setState(() {
|
||||
_tempList.add(newFin);
|
||||
_isAddingNew = false;
|
||||
}),
|
||||
onCancel: () => setState(() => _isAddingNew = false),
|
||||
)
|
||||
: BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, provState) {
|
||||
return BlocBuilder<ProductCubit, ProductState>(
|
||||
builder: (context, prodState) {
|
||||
return _FinanceList(
|
||||
services: _tempList,
|
||||
allProviders:
|
||||
provState.allProviders, // Per vedere lo storico
|
||||
allModels: prodState.models,
|
||||
onDelete: (index) =>
|
||||
setState(() => _tempList.removeAt(index)),
|
||||
onAddTap: () => setState(() => _isAddingNew = true),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: !_isAddingNew
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _tempList),
|
||||
child: const Text("Conferma"),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// VISTA LISTA (STORICA)
|
||||
// ===========================================================================
|
||||
class _FinanceList extends StatelessWidget {
|
||||
final List<FinServiceModel> services;
|
||||
final List<ProviderModel> allProviders;
|
||||
final List<ModelModel> allModels;
|
||||
final Function(int) onDelete;
|
||||
final VoidCallback onAddTap;
|
||||
|
||||
const _FinanceList({
|
||||
required this.services,
|
||||
required this.allProviders,
|
||||
required this.allModels,
|
||||
required this.onDelete,
|
||||
required this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (services.isEmpty) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text(
|
||||
"Nessun finanziamento inserito.",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi primo"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: services.length,
|
||||
separatorBuilder: (_, _) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final s = services[index];
|
||||
|
||||
// Cerchiamo il nome del provider in TUTTI quelli caricati (storico)
|
||||
final providerName = allProviders
|
||||
.firstWhere(
|
||||
(p) => p.id == s.providerId,
|
||||
orElse: () => ProviderModel(
|
||||
id: '',
|
||||
nome: 'Operatore Storico',
|
||||
companyId: '',
|
||||
isActive: false,
|
||||
energia: false,
|
||||
telefoniaFissa: false,
|
||||
telefoniaMobile: false,
|
||||
assicurazioni: false,
|
||||
altro: false,
|
||||
intrattenimento: false,
|
||||
finanziamenti: false,
|
||||
),
|
||||
)
|
||||
.nome;
|
||||
|
||||
// Cerchiamo il nome del modello
|
||||
final modelName = allModels
|
||||
.firstWhere(
|
||||
(m) => m.id == s.modelId,
|
||||
orElse: () => ModelModel(
|
||||
id: '',
|
||||
name: 'Prodotto',
|
||||
nameWithBrand: 'Prodotto Storico',
|
||||
brandId: '',
|
||||
),
|
||||
)
|
||||
.nameWithBrand;
|
||||
|
||||
final dateStr =
|
||||
"${s.expiration.day.toString().padLeft(2, '0')}/${s.expiration.month.toString().padLeft(2, '0')}/${s.expiration.year}";
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
modelName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text("$providerName • Scade: $dateStr"),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onDelete(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi altro"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// FORM CON OMNI-SEARCH
|
||||
// ===========================================================================
|
||||
class _FinanceForm extends StatefulWidget {
|
||||
final Function(FinServiceModel) onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _FinanceForm({required this.onSave, required this.onCancel});
|
||||
|
||||
@override
|
||||
State<_FinanceForm> createState() => _FinanceFormState();
|
||||
}
|
||||
|
||||
class _FinanceFormState extends State<_FinanceForm> {
|
||||
String? _selectedProviderId;
|
||||
ModelModel? _selectedModel;
|
||||
int _selectedMonths = 30; // Default richiesto
|
||||
Timer? _debounce;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
late DateTime _selectedExpirationDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final now = DateTime.now();
|
||||
_selectedExpirationDate = DateTime(
|
||||
now.year,
|
||||
now.month + _selectedMonths,
|
||||
now.day,
|
||||
); // Inizialmente 30 mesi dalla data attuale
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||
context.read<ProductCubit>().searchModels(query);
|
||||
});
|
||||
}
|
||||
|
||||
// Funzione per aggiornare la data quando si clicca sui segmenti 24, 30, 48
|
||||
void _updateExpirationByMonths(int months) {
|
||||
setState(() {
|
||||
_selectedMonths = months;
|
||||
final now = DateTime.now();
|
||||
// Calcolo preciso: aggiungiamo i mesi alla data attuale
|
||||
_selectedExpirationDate = DateTime(now.year, now.month + months, now.day);
|
||||
});
|
||||
}
|
||||
|
||||
// Funzione per il picker manuale
|
||||
Future<void> _selectManualDate() async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedExpirationDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(
|
||||
const Duration(days: 365 * 10),
|
||||
), // Fino a 10 anni
|
||||
);
|
||||
if (picked != null && picked != _selectedExpirationDate) {
|
||||
setState(() {
|
||||
_selectedExpirationDate = picked;
|
||||
_selectedMonths = 0; // Resettiamo i segmenti perché è una data custom
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. SCELTA ISTITUTO (Solo attivi)
|
||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
final finProviders = state.activeProviders
|
||||
.where((p) => p.finanziamenti)
|
||||
.toList(); // Già filtrati dal caricamento della dialog
|
||||
return DropdownButtonFormField<String>(
|
||||
initialValue: _selectedProviderId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Gestore",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: finProviders
|
||||
.map(
|
||||
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) => setState(() => _selectedProviderId = val),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. RICERCA MODELLO
|
||||
if (_selectedModel == null) ...[
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Cerca modello (es: iPhone...)",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () => _showQuickCreate(context),
|
||||
),
|
||||
),
|
||||
onChanged: (val) {
|
||||
_onSearchChanged(val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSearchSuggestions(),
|
||||
] else
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.phone_android),
|
||||
title: Text(
|
||||
_selectedModel!.nameWithBrand,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => setState(() => _selectedModel = null),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. DURATA PRESET
|
||||
const Text(
|
||||
"Durata Rate",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 24, label: Text("24m")),
|
||||
ButtonSegment(value: 30, label: Text("30m")),
|
||||
ButtonSegment(value: 48, label: Text("48m")),
|
||||
],
|
||||
selected: {_selectedMonths},
|
||||
onSelectionChanged: (val) => _updateExpirationByMonths(val.first),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// RIEPILOGO DATA E PICKER MANUALE (Stile Energia)
|
||||
const Text(
|
||||
"Scadenza Finanziamento",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
InkWell(
|
||||
onTap: _selectManualDate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 18,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
"${_selectedExpirationDate.day.toString().padLeft(2, '0')}/${_selectedExpirationDate.month.toString().padLeft(2, '0')}/${_selectedExpirationDate.year}",
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Icon(Icons.edit, size: 18, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text("Indietro"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: (_selectedProviderId == null || _selectedModel == null)
|
||||
? null
|
||||
: () {
|
||||
final now = DateTime.now();
|
||||
widget.onSave(
|
||||
FinServiceModel(
|
||||
providerId: _selectedProviderId!,
|
||||
modelId: _selectedModel!.id!,
|
||||
expiration: DateTime(
|
||||
now.year,
|
||||
now.month + _selectedMonths,
|
||||
now.day,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text("Salva"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchSuggestions() {
|
||||
return BlocBuilder<ProductCubit, ProductState>(
|
||||
builder: (context, state) {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
if (query.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final filtered = state.models
|
||||
.where((m) => m.nameWithBrand.toLowerCase().contains(query))
|
||||
.take(3)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
children: filtered
|
||||
.map(
|
||||
(m) => ListTile(
|
||||
title: Text(m.nameWithBrand),
|
||||
onTap: () => setState(() => _selectedModel = m),
|
||||
dense: true,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showQuickCreate(BuildContext context) {
|
||||
// Implementazione rapida dialog creazione Brand/Modello come discusso prima
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/models/service_model.dart';
|
||||
|
||||
class GeneralInfoSection extends StatelessWidget {
|
||||
final ServiceModel service;
|
||||
const GeneralInfoSection({super.key, required this.service});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Info Generali",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Numero di Riferimento / Telefono
|
||||
TextFormField(
|
||||
initialValue: service.number,
|
||||
keyboardType: TextInputType
|
||||
.phone, // Fa aprire il tastierino numerico su mobile
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Numero di Telefono / Riferimento",
|
||||
hintText: "Es. 3331234567",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.phone),
|
||||
),
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(number: val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// I due Switch affiancati (Bozza e A buon fine)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: const Text("Bozza"),
|
||||
subtitle: const Text(
|
||||
"Pratica in lavorazione",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
value: service.isBozza,
|
||||
activeThumbColor: Colors.orange,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(isBozza: val);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: const Text("A buon fine"),
|
||||
subtitle: const Text(
|
||||
"Esito positivo",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
value: service.resultOk,
|
||||
activeThumbColor: Colors.green,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(resultOk: val);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Campo Note
|
||||
TextFormField(
|
||||
initialValue: service.note,
|
||||
maxLines: 4,
|
||||
minLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Note Operazione",
|
||||
hintText:
|
||||
"Scrivi qui eventuali dettagli o richieste del cliente...",
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(note: val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
158
lib/features/operations/ui/service_form_screen/int_dialogs.dart
Normal file
158
lib/features/operations/ui/service_form_screen/int_dialogs.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'dart:async'; // Necessario per il Timer
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> updateCountDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
int currentValue,
|
||||
Function(int) onSave,
|
||||
) async {
|
||||
int tempValue =
|
||||
currentValue; // Variabile locale per gestire il conteggio nella dialog
|
||||
|
||||
final result = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text("Imposta $title"),
|
||||
content: QuickCounter(
|
||||
initialValue: tempValue,
|
||||
onChanged: (val) => tempValue =
|
||||
val, // Aggiorna il valore locale quando il counter cambia
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, tempValue),
|
||||
child: const Text("Conferma"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
onSave(result);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Widget Interno Specifico per il Counter Veloce ---
|
||||
class QuickCounter extends StatefulWidget {
|
||||
final int initialValue;
|
||||
final ValueChanged<int>
|
||||
onChanged; // Callback per notificare il padre dei cambiamenti
|
||||
|
||||
const QuickCounter({
|
||||
super.key,
|
||||
required this.initialValue,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QuickCounter> createState() => _QuickCounterState();
|
||||
}
|
||||
|
||||
class _QuickCounterState extends State<QuickCounter> {
|
||||
late int _value;
|
||||
Timer? _longPressTimer; // Il timer per l'auto-incremento
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_value = widget.initialValue;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_longPressTimer
|
||||
?.cancel(); // IMPORTANTE: Annulla sempre il timer alla distruzione
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Logica comune per incremento/decremento singolo o rapido
|
||||
void _update(int delta) {
|
||||
setState(() {
|
||||
_value += delta;
|
||||
if (_value < 0) _value = 0; // Impedisci numeri negativi
|
||||
});
|
||||
widget.onChanged(_value); // Notifica il padre
|
||||
}
|
||||
|
||||
// Gestione dell'inizio della pressione prolungata
|
||||
void _startLongPress(int delta) {
|
||||
_update(delta); // Esegui subito il primo aggiornamento al tocco iniziale
|
||||
_longPressTimer = Timer.periodic(const Duration(milliseconds: 100), (
|
||||
timer,
|
||||
) {
|
||||
_update(delta); // Aggiorna velocemente finché la pressione continua
|
||||
});
|
||||
}
|
||||
|
||||
// Gestione della fine della pressione prolungata
|
||||
void _stopLongPress() {
|
||||
_longPressTimer?.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canDecrement = _value > 0;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// --- Pulsante MENO ---
|
||||
GestureDetector(
|
||||
onLongPressStart: canDecrement ? (_) => _startLongPress(-1) : null,
|
||||
onLongPressEnd: (_) => _stopLongPress(),
|
||||
onLongPressCancel: () => _stopLongPress(),
|
||||
onTap: canDecrement ? () => _update(-1) : null,
|
||||
child: Opacity(
|
||||
// Visivamente disabilitato se < 0
|
||||
opacity: canDecrement ? 1.0 : 0.4,
|
||||
child: const ActionButton(icon: Icons.remove, color: Colors.red),
|
||||
),
|
||||
),
|
||||
|
||||
// --- Valore Centrale ---
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
_value.toString(),
|
||||
style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// --- Pulsante PIU' ---
|
||||
GestureDetector(
|
||||
onLongPressStart: (_) => _startLongPress(1),
|
||||
onLongPressEnd: (_) => _stopLongPress(),
|
||||
onLongPressCancel: () => _stopLongPress(),
|
||||
onTap: () => _update(1),
|
||||
child: const ActionButton(icon: Icons.add, color: Colors.green),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Piccolo widget di utilità per l'aspetto del pulsante
|
||||
class ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const ActionButton({super.key, required this.icon, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color, width: 2),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 30),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/models/service_model.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/attachment_section.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/customer_section.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/general_info_section.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/services_grid.dart';
|
||||
|
||||
class ServiceFormScreen extends StatefulWidget {
|
||||
final String? serviceId;
|
||||
final ServiceModel? existingService; // <-- AGGIUNTO
|
||||
|
||||
const ServiceFormScreen({
|
||||
super.key,
|
||||
this.serviceId,
|
||||
this.existingService, // <-- AGGIUNTO
|
||||
});
|
||||
|
||||
@override
|
||||
State<ServiceFormScreen> createState() => _ServiceFormScreenState();
|
||||
}
|
||||
|
||||
class _ServiceFormScreenState extends State<ServiceFormScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Diamo in pasto al Cubit tutto quello che abbiamo!
|
||||
context.read<ServicesCubit>().initServiceForm(
|
||||
existingService: widget.existingService,
|
||||
serviceId: widget.serviceId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _performSave(BuildContext context, {required bool isBozza}) {
|
||||
FocusScope.of(context).unfocus();
|
||||
context.read<ServicesCubit>().saveCurrentService(isBozza: isBozza);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<ServicesCubit, ServicesState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == ServicesStatus.saved) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Pratica salvata con successo!"),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
if (state.status == ServicesStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Errore: ${state.errorMessage ?? ''}"),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.status == ServicesStatus.savedNoPop) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Pratica salvata con successo!"),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final service = state.currentService;
|
||||
final isSaving = state.status == ServicesStatus.saving;
|
||||
final isEditMode = widget.serviceId != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(isEditMode ? "Modifica Pratica" : "Nuova Pratica"),
|
||||
actions: [
|
||||
if (isSaving)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 20.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (service != null) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_note),
|
||||
tooltip: "Salva come Bozza",
|
||||
onPressed: () => _performSave(context, isBozza: true),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: Colors.green,
|
||||
),
|
||||
tooltip: "Conferma Pratica",
|
||||
onPressed: () => _performSave(context, isBozza: false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: (service == null)
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomerSection(service: service),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
GeneralInfoSection(service: service),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
ServicesGrid(service: service),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
AttachmentsSection(),
|
||||
const SizedBox(height: 32),
|
||||
_buildBottomActionButtons(context, isSaving: isSaving),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActionButtons(
|
||||
BuildContext context, {
|
||||
required bool isSaving,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
icon: const Icon(Icons.edit_note),
|
||||
label: const Text("Salva in Bozza"),
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => _performSave(context, isBozza: true),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green.shade600,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text(
|
||||
"CONFERMA PRATICA",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1),
|
||||
),
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => _performSave(context, isBozza: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flux/features/operations/blocs/service_files_bloc.dart';
|
||||
|
||||
class ServiceMobileUploadScreen extends StatefulWidget {
|
||||
final String serviceId;
|
||||
final String serviceName;
|
||||
|
||||
const ServiceMobileUploadScreen({
|
||||
super.key,
|
||||
required this.serviceId,
|
||||
required this.serviceName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ServiceMobileUploadScreen> createState() =>
|
||||
_ServiceMobileUploadScreenState();
|
||||
}
|
||||
|
||||
class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
|
||||
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
|
||||
final List<PlatformFile> _stagedFiles = [];
|
||||
|
||||
// 2. STATO DI CARICAMENTO GLOBALE
|
||||
bool _isUploading = false;
|
||||
|
||||
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
|
||||
bool _isImage(String path) {
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png', 'webp'].contains(ext);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ServiceFilesBloc, ServiceFilesState>(
|
||||
listener: (context, state) {
|
||||
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
|
||||
if (state.status == ServiceFilesStatus.success && _isUploading) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Tutti i file caricati con successo! ✅"),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
if (state.status == ServiceFilesStatus.failure) {
|
||||
setState(() => _isUploading = false);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text("Errore: ${state.error}")));
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Upload Pratica:\n${widget.serviceName}"),
|
||||
automaticallyImplyLeading: !_isUploading,
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isUploading ? null : _handleCamera,
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text("SCATTA"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isUploading ? null : _handleFilePicker,
|
||||
icon: const Icon(Icons.folder),
|
||||
label: const Text("GALLERIA"),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
|
||||
Expanded(
|
||||
child: _stagedFiles.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
3, // 3 colonne come la galleria dell'iPhone
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: _stagedFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = _stagedFiles[index];
|
||||
final isImg = _isImage(file.name);
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// L'ANTEPRIMA
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: isImg
|
||||
? Image.file(
|
||||
File(file.path!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: const Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.picture_as_pdf,
|
||||
color: Colors.red,
|
||||
size: 36,
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
"PDF",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// IL PULSANTE CESTINO (In alto a destra)
|
||||
Positioned(
|
||||
top: -8,
|
||||
right: -8,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_stagedFiles.removeAt(index);
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// --- SEZIONE INVIA E CHIUDI ---
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
// Il pulsante si accende SOLO se ci sono file nel carrello
|
||||
onPressed: _stagedFiles.isEmpty || _isUploading
|
||||
? null
|
||||
: _submitAllFiles,
|
||||
icon: const Icon(Icons.cloud_upload),
|
||||
label: Text(
|
||||
"INVIA ${_stagedFiles.length} FILE E CHIUDI",
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
|
||||
if (_isUploading)
|
||||
Container(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
child: const Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
"Caricamento in corso...",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- LOGICA FOTOCAMERA E LIBRERIA ---
|
||||
Future<void> _handleCamera() async {
|
||||
final picker = ImagePicker();
|
||||
final photo = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 80,
|
||||
);
|
||||
if (photo != null) {
|
||||
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
|
||||
final photoSize = await photo.length();
|
||||
|
||||
final platformFile = PlatformFile(
|
||||
name: photo.name,
|
||||
size: photoSize,
|
||||
path: photo.path,
|
||||
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
|
||||
);
|
||||
setState(() {
|
||||
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleFilePicker() async {
|
||||
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
|
||||
final result = await FilePicker.pickFiles(allowMultiple: true);
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
_stagedFiles.addAll(result.files);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- LOGICA DI INVIO AL BLoC ---
|
||||
void _submitAllFiles() {
|
||||
setState(() => _isUploading = true);
|
||||
|
||||
// Diciamo al BLoC di caricare tutti i file.
|
||||
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
|
||||
final bloc = context.read<ServiceFilesBloc>();
|
||||
bloc.add(UploadMultipleServiceFilesEvent(_stagedFiles));
|
||||
|
||||
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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/operations/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/operations/models/energy_service_model.dart';
|
||||
import 'package:flux/features/operations/models/entertainment_service_model.dart';
|
||||
import 'package:flux/features/operations/models/fin_service_model.dart';
|
||||
import 'package:flux/features/operations/models/service_model.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/action_card.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/energy_service_dialog.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/entertainment_service_card.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/finance_service_dialog.dart';
|
||||
import 'package:flux/features/operations/ui/service_form_screen/int_dialogs.dart'; // Assicurati di importare il modello
|
||||
|
||||
class ServicesGrid extends StatelessWidget {
|
||||
final ServiceModel service;
|
||||
|
||||
const ServicesGrid({super.key, required this.service});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.layers_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Servizi e Accessori",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
// --- CONTATORI SEMPLICI ---
|
||||
ActionCard(
|
||||
label: "AL",
|
||||
count: service.al,
|
||||
icon: Icons.sim_card,
|
||||
color: Colors.blue,
|
||||
onTap: () => updateCountDialog(
|
||||
context,
|
||||
"AL",
|
||||
service.al,
|
||||
(val) =>
|
||||
context.read<ServicesCubit>().updateField(al: val),
|
||||
),
|
||||
),
|
||||
ActionCard(
|
||||
label: "MNP",
|
||||
count: service.mnp,
|
||||
icon: Icons.phone_android,
|
||||
color: Colors.indigo,
|
||||
onTap: () => updateCountDialog(
|
||||
context,
|
||||
"MNP",
|
||||
service.mnp,
|
||||
(val) =>
|
||||
context.read<ServicesCubit>().updateField(mnp: val),
|
||||
),
|
||||
),
|
||||
ActionCard(
|
||||
label: "NIP",
|
||||
count: service.nip,
|
||||
icon: Icons.compare_arrows,
|
||||
color: Colors.cyan,
|
||||
onTap: () => updateCountDialog(
|
||||
context,
|
||||
"NIP",
|
||||
service.nip,
|
||||
(val) =>
|
||||
context.read<ServicesCubit>().updateField(nip: val),
|
||||
),
|
||||
),
|
||||
ActionCard(
|
||||
label: "Unica",
|
||||
count: service.unica,
|
||||
icon: Icons.all_inclusive,
|
||||
color: Colors.purple,
|
||||
onTap: () => updateCountDialog(
|
||||
context,
|
||||
"Unica",
|
||||
service.unica,
|
||||
(val) =>
|
||||
context.read<ServicesCubit>().updateField(unica: val),
|
||||
),
|
||||
),
|
||||
ActionCard(
|
||||
label: "Telepass",
|
||||
count: service.telepass,
|
||||
icon: Icons.directions_car,
|
||||
color: Colors.amber.shade700,
|
||||
onTap: () => updateCountDialog(
|
||||
context,
|
||||
"Telepass",
|
||||
service.telepass,
|
||||
(val) => context.read<ServicesCubit>().updateField(
|
||||
telepass: val,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// --- MODULI COMPLESSI (Le liste) ---
|
||||
ActionCard(
|
||||
label: "Energia",
|
||||
count: service.energyServices.length,
|
||||
icon: Icons.bolt,
|
||||
color: Colors.green,
|
||||
onTap: () async {
|
||||
// Apriamo la modale e aspettiamo il risultato
|
||||
final result = await showDialog<List<EnergyServiceModel>>(
|
||||
context: context,
|
||||
builder: (context) => EnergyServiceDialog(
|
||||
currentStoreId: service.storeId,
|
||||
initialServices: service
|
||||
.energyServices, // Passiamo la lista attuale
|
||||
),
|
||||
);
|
||||
|
||||
// Se l'utente ha premuto "Conferma" e non "Annulla" o tap fuori
|
||||
if (result != null && context.mounted) {
|
||||
context.read<ServicesCubit>().updateEnergyServices(
|
||||
result,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
ActionCard(
|
||||
label: "Finanziam.",
|
||||
count: service.finServices.length,
|
||||
icon: Icons.euro_symbol,
|
||||
color: Colors.teal,
|
||||
onTap: () async {
|
||||
final result = await showDialog<List<FinServiceModel>>(
|
||||
context: context,
|
||||
builder: (context) => FinanceServiceDialog(
|
||||
productCubit: context.read<ProductCubit>(),
|
||||
currentStoreId: service.storeId,
|
||||
initialServices:
|
||||
service.finServices, // Passiamo la lista attuale
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
context.read<ServicesCubit>().updateFinServices(result);
|
||||
}
|
||||
},
|
||||
),
|
||||
ActionCard(
|
||||
label: "Intratten.",
|
||||
count: service.entertainmentServices.length,
|
||||
icon: Icons.movie_filter_outlined,
|
||||
color: Colors.purple,
|
||||
onTap: () async {
|
||||
final result =
|
||||
await showDialog<List<EntertainmentServiceModel>>(
|
||||
context: context,
|
||||
builder: (context) => EntertainmentServiceDialog(
|
||||
initialServices: service.entertainmentServices,
|
||||
currentStoreId: service.storeId,
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
context
|
||||
.read<ServicesCubit>()
|
||||
.updateEntertainmentServices(result);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user