rese agnostiche sezioni customer e staff per le form. Inizio di lavoro per rendere agnostico il bloc degli allegati
This commit is contained in:
215
lib/core/widgets/shared_forms/customer_section.dart
Normal file
215
lib/core/widgets/shared_forms/customer_section.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||
import 'package:flux/features/customers/models/customer_model.dart';
|
||||
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
|
||||
class CustomerSection extends StatelessWidget {
|
||||
final OperationModel? currentOp;
|
||||
final ValueChanged<CustomerModel> onCustomerSelected;
|
||||
|
||||
const CustomerSection({
|
||||
super.key,
|
||||
required this.currentOp,
|
||||
required this.onCustomerSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCustomer =
|
||||
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Text(
|
||||
'Cliente',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => _showCustomerModal(context), // Passiamo il context!
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.2),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasCustomer
|
||||
? currentOp!.customerDisplayName!
|
||||
: 'Seleziona Cliente *',
|
||||
style: TextStyle(
|
||||
fontWeight: hasCustomer
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: hasCustomer ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.search),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// --- MODALE SELEZIONE CLIENTE ---
|
||||
void _showCustomerModal(BuildContext context) {
|
||||
String currentSearchQuery = '';
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.8,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Seleziona Cliente',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(modalContext),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Barra di Ricerca
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cerca per nome, telefono o email...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: (query) {
|
||||
currentSearchQuery = query;
|
||||
context.read<CustomersCubit>().searchCustomers(query);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Pulsante Nuovo Cliente
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('Crea Nuovo Cliente'),
|
||||
onPressed: () async {
|
||||
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
|
||||
final newCustomer = await showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<CustomersCubit>(),
|
||||
child: QuickCustomerDialog(
|
||||
initialQuery:
|
||||
currentSearchQuery, // <-- Passiamo quello che ha digitato!
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Se l'ha creato davvero (e non ha premuto annulla)...
|
||||
if (newCustomer != null) {
|
||||
// 1. Aggiorniamo il form delle operazioni
|
||||
onCustomerSelected(newCustomer);
|
||||
|
||||
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
|
||||
if (context.mounted) {
|
||||
Navigator.pop(modalContext);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Lista Clienti dal Bloc
|
||||
Expanded(
|
||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == CustomersStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.customers.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Nessun cliente trovato.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: state.customers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final customer = state.customers[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: Text(
|
||||
customer.name.substring(0, 1).toUpperCase(),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
customer.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${customer.phoneNumber} • ${customer.email}',
|
||||
),
|
||||
onTap: () {
|
||||
onCustomerSelected(customer);
|
||||
Navigator.pop(modalContext);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
760
lib/core/widgets/shared_forms/operation_files_section.dart
Normal file
760
lib/core/widgets/shared_forms/operation_files_section.dart
Normal file
@@ -0,0 +1,760 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
||||
import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart';
|
||||
import 'package:flux/features/attachments/ui/quick_rename_dialog.dart';
|
||||
import 'package:flux/features/attachments/blocs/operation_files_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
|
||||
|
||||
class _ExportItem {
|
||||
final Uint8List bytes;
|
||||
final String sourceName;
|
||||
final bool isMultiPage;
|
||||
final int pageIndex;
|
||||
|
||||
_ExportItem({
|
||||
required this.bytes,
|
||||
required this.sourceName,
|
||||
required this.isMultiPage,
|
||||
required this.pageIndex,
|
||||
});
|
||||
}
|
||||
|
||||
class OperationFilesSection extends StatefulWidget {
|
||||
final OperationModel currentOp;
|
||||
|
||||
const OperationFilesSection({super.key, required this.currentOp});
|
||||
|
||||
@override
|
||||
State<OperationFilesSection> createState() => _OperationFilesSectionState();
|
||||
}
|
||||
|
||||
class _OperationFilesSectionState extends State<OperationFilesSection> {
|
||||
String? _exportDirectory;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadExportDirectory();
|
||||
}
|
||||
|
||||
// --- GESTIONE CARTELLA CITRIX (SOLO DESKTOP) ---
|
||||
Future<void> _loadExportDirectory() async {
|
||||
if (kIsWeb) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_exportDirectory = prefs.getString('citrix_export_path');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectExportDirectory() async {
|
||||
final String? selectedDirectory = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Seleziona la cartella di esportazione per TIM/Citrix',
|
||||
);
|
||||
|
||||
if (selectedDirectory != null) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('citrix_export_path', selectedDirectory);
|
||||
setState(() {
|
||||
_exportDirectory = selectedDirectory;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Cartella Export impostata: $selectedDirectory'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SELEZIONE FILE DAL PC/TELEFONO ---
|
||||
Future<void> _pickFiles() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
allowMultiple: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'],
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result != null && mounted) {
|
||||
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
|
||||
context.read<OperationFilesBloc>().add(
|
||||
AddOperationFilesEvent(result.files),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- APERTURA VIEWER ---
|
||||
void _openFile(AttachmentModel file) {
|
||||
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
|
||||
final operationFilesBloc = context.read<OperationFilesBloc>();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (viewerContext) => BlocProvider.value(
|
||||
value: operationFilesBloc,
|
||||
child: AttachmentViewerScreen(
|
||||
attachment: file,
|
||||
onRename: (newName) {
|
||||
// Spara l'evento al BLoC e lui farà il resto!
|
||||
operationFilesBloc.add(RenameOperationFileEvent(file, newName));
|
||||
},
|
||||
onDelete: () {
|
||||
operationFilesBloc.add(DeleteSpecificOperationFileEvent(file));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportMergedPdf(List<AttachmentModel> selectedFiles) async {
|
||||
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. "FLATTEN" DI TUTTO (Stessa magia di prima)
|
||||
List<Uint8List> allPagesAsImages = [];
|
||||
final repository = GetIt.I.get<AttachmentsRepository>();
|
||||
|
||||
for (var file in selectedFiles) {
|
||||
Uint8List? fileBytes;
|
||||
|
||||
if (file.localBytes != null) {
|
||||
fileBytes = file.localBytes;
|
||||
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
||||
fileBytes = await repository.downloadAttachmentBytes(
|
||||
file.storagePath!,
|
||||
);
|
||||
}
|
||||
|
||||
if (fileBytes == null) continue;
|
||||
|
||||
if (file.extension == 'pdf') {
|
||||
final document = await px.PdfDocument.openData(fileBytes);
|
||||
for (int i = 1; i <= document.pagesCount; i++) {
|
||||
final page = await document.getPage(i);
|
||||
final pageImage = await page.render(
|
||||
width: page.width * 2,
|
||||
height: page.height * 2,
|
||||
format: px.PdfPageImageFormat.jpeg,
|
||||
);
|
||||
if (pageImage != null) {
|
||||
allPagesAsImages.add(pageImage.bytes);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
await document.close();
|
||||
} else {
|
||||
// È un'immagine
|
||||
allPagesAsImages.add(fileBytes);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) Navigator.pop(context); // Togliamo il loading
|
||||
|
||||
// Se per qualche motivo la lista è vuota, usciamo
|
||||
if (allPagesAsImages.isEmpty) return;
|
||||
|
||||
// 2. LOGICA DEL NOME SUGGERITO
|
||||
String suggestedName;
|
||||
if (selectedFiles.length == 1) {
|
||||
// Se c'è un solo file (es. ho selezionato 3 foto ma poi ho deselezionato le altre)
|
||||
suggestedName = selectedFiles.first.name;
|
||||
} else {
|
||||
// Se sono più file uniti
|
||||
suggestedName = '${widget.currentOp.customerDisplayName}_Unito';
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 3. DIALOG DI CONFERMA (Mostriamo la PRIMA pagina come anteprima per fargli capire cos'è)
|
||||
final finalName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => QuickRenameDialog(
|
||||
suggestedName: suggestedName,
|
||||
previewWidget: Image.memory(
|
||||
allPagesAsImages.first,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (finalName == null || finalName.isEmpty) return; // Ha annullato
|
||||
|
||||
// 4. CREAZIONE DEL PDF UNICO (IL MERGE VERO E PROPRIO)
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Cicliamo su tutte le immagini estratte e creiamo una pagina per ognuna
|
||||
for (var imageBytes in allPagesAsImages) {
|
||||
final pdfImage = pw.MemoryImage(imageBytes);
|
||||
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
margin: pw.EdgeInsets.zero,
|
||||
build: (pw.Context context) {
|
||||
return pw.Center(child: pw.Image(pdfImage));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final mergedPdfBytes = await pdf.save();
|
||||
|
||||
// 5. SALVATAGGIO SUL DISCO
|
||||
if (kIsWeb) {
|
||||
// Trigger download web
|
||||
} else {
|
||||
final fileToSave = File('$_exportDirectory/$finalName.pdf');
|
||||
await fileToSave.writeAsBytes(mergedPdfBytes);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PDF Multi-pagina creato e salvato con successo!'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
// Se il loading è ancora aperto, lo chiudiamo
|
||||
if (Navigator.canPop(context)) Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Errore durante l\'unione: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportSplitPdfs(List<AttachmentModel> selectedFiles) async {
|
||||
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. "FLATTEN" INTELLIGENTE (Ora usiamo la nostra classe _ExportItem)
|
||||
List<_ExportItem> itemsToExport = [];
|
||||
final repository = GetIt.I.get<AttachmentsRepository>();
|
||||
|
||||
for (var file in selectedFiles) {
|
||||
Uint8List? fileBytes;
|
||||
|
||||
if (file.localBytes != null) {
|
||||
fileBytes = file.localBytes;
|
||||
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
||||
fileBytes = await repository.downloadAttachmentBytes(
|
||||
file.storagePath!,
|
||||
);
|
||||
}
|
||||
|
||||
if (fileBytes == null) continue;
|
||||
|
||||
// Recuperiamo il nome che l'utente ha (magari) già impostato
|
||||
final baseName = file.name;
|
||||
|
||||
if (file.extension == 'pdf') {
|
||||
final document = await px.PdfDocument.openData(fileBytes);
|
||||
final isMulti =
|
||||
document.pagesCount > 1; // Controlliamo se è multipagina!
|
||||
|
||||
for (int i = 1; i <= document.pagesCount; i++) {
|
||||
final page = await document.getPage(i);
|
||||
|
||||
final pageImage = await page.render(
|
||||
width: page.width * 2,
|
||||
height: page.height * 2,
|
||||
format: px.PdfPageImageFormat.jpeg,
|
||||
);
|
||||
|
||||
if (pageImage != null) {
|
||||
// Salviamo l'immagine CON il suo contesto storico
|
||||
itemsToExport.add(
|
||||
_ExportItem(
|
||||
bytes: pageImage.bytes,
|
||||
sourceName: baseName,
|
||||
isMultiPage: isMulti,
|
||||
pageIndex: i,
|
||||
),
|
||||
);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
await document.close();
|
||||
} else {
|
||||
// SE È UN'IMMAGINE, la salviamo come singola pagina
|
||||
itemsToExport.add(
|
||||
_ExportItem(
|
||||
bytes: fileBytes,
|
||||
sourceName: baseName,
|
||||
isMultiPage: false,
|
||||
pageIndex: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
// 2. IL CICLO UX
|
||||
for (var item in itemsToExport) {
|
||||
if (!mounted) return;
|
||||
|
||||
// LA TUA MAGIA UX SUI NOMI:
|
||||
// Se è singolo (foto o PDF da 1 pag) -> Usa il nome originale nudo e crudo!
|
||||
// Se è multipagina -> Usa il nome originale + il numero di pagina
|
||||
String suggestedName = item.sourceName;
|
||||
if (item.isMultiPage) {
|
||||
suggestedName = '${item.sourceName}_Pag_${item.pageIndex}';
|
||||
}
|
||||
|
||||
final finalName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => QuickRenameDialog(
|
||||
suggestedName: suggestedName,
|
||||
previewWidget: Image.memory(item.bytes, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
|
||||
if (finalName == null || finalName.isEmpty) continue;
|
||||
|
||||
// CREAZIONE DEL PDF SINGOLO
|
||||
final pdf = pw.Document();
|
||||
final pdfImage = pw.MemoryImage(item.bytes); // Usiamo item.bytes!
|
||||
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
margin: pw.EdgeInsets.zero,
|
||||
build: (pw.Context context) {
|
||||
return pw.Center(child: pw.Image(pdfImage));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final singlePdfBytes = await pdf.save();
|
||||
|
||||
if (kIsWeb) {
|
||||
// Trigger download web
|
||||
} else {
|
||||
final fileToSave = File('$_exportDirectory/$finalName.pdf');
|
||||
await fileToSave.writeAsBytes(singlePdfBytes);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Esportazione completata con successo!'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Errore: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// USIAMO IL TUO BLOC!
|
||||
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
|
||||
builder: (context, state) {
|
||||
final allFiles = state.allFiles;
|
||||
final selectedFiles = state.selectedFiles;
|
||||
final hasSelection = selectedFiles.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 1. SETTINGS CARTELLA (Solo visibile su Desktop)
|
||||
if (!kIsWeb)
|
||||
Card(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.folder_special,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: const Text(
|
||||
'Cartella Export (Es. Citrix TIM)',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
_exportDirectory ??
|
||||
'Nessuna cartella selezionata. Clicca per impostare.',
|
||||
style: TextStyle(
|
||||
color: _exportDirectory == null
|
||||
? theme.colorScheme.error
|
||||
: null,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.settings),
|
||||
onTap: _selectExportDirectory,
|
||||
),
|
||||
),
|
||||
|
||||
// 2. ACTION BAR DINAMICA
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Bottone di Aggiunta
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add_photo_alternate),
|
||||
label: const Text('Aggiungi File'),
|
||||
onPressed: state.status == OperationFilesStatus.uploading
|
||||
? null
|
||||
: _pickFiles,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// NUOVO: SELEZIONA / DESELEZIONA TUTTO
|
||||
if (allFiles.isNotEmpty) ...[
|
||||
TextButton.icon(
|
||||
icon: Icon(
|
||||
selectedFiles.length == allFiles.length
|
||||
? Icons.deselect
|
||||
: Icons.select_all,
|
||||
),
|
||||
label: Text(
|
||||
selectedFiles.length == allFiles.length
|
||||
? 'Deseleziona Tutto'
|
||||
: 'Seleziona Tutto',
|
||||
),
|
||||
onPressed: () {
|
||||
if (selectedFiles.length == allFiles.length) {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
ClearOperationFileSelectionEvent(),
|
||||
);
|
||||
} else {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
SelectAllOperationFilesEvent(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Loader di upload
|
||||
if (state.status == OperationFilesStatus.uploading)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Azioni visibili SOLO se c'è una selezione!
|
||||
if (hasSelection) ...[
|
||||
// Bottone Elimina
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: 'Elimina selezionati',
|
||||
onPressed: () {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
DeleteOperationFilesEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Bottone Associa a Cliente
|
||||
if (widget.currentOp.customerId != null &&
|
||||
widget.currentOp.customerId!.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_add, color: Colors.blue),
|
||||
tooltip: 'Copia nei documenti del Cliente',
|
||||
onPressed: () {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
LinkFilesToCustomerEvent(
|
||||
customerId: widget.currentOp.customerId!,
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('File copiati nella scheda cliente!'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// IL NUOVO BOTTONE ESPORTA CON MENU A TENDINA
|
||||
PopupMenuButton<String>(
|
||||
tooltip: 'Opzioni di esportazione',
|
||||
position: PopupMenuPosition
|
||||
.under, // Opzionale: fa aprire il menu sotto al bottone
|
||||
onSelected: (value) {
|
||||
if (value == 'merge') {
|
||||
_exportMergedPdf(selectedFiles);
|
||||
} else if (value == 'split') {
|
||||
_exportSplitPdfs(selectedFiles);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<String>>[
|
||||
const PopupMenuItem<String>(
|
||||
value: 'merge',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.merge_type,
|
||||
color: Colors.blue,
|
||||
),
|
||||
title: Text('Unisci in un singolo PDF'),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'split',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.splitscreen,
|
||||
color: Colors.orange,
|
||||
),
|
||||
title: Text(
|
||||
'Dividi: un PDF per ogni pagina/foto',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// IL FIX È QUI SOTTO: AbsorbPointer + onPressed vuoto
|
||||
child: AbsorbPointer(
|
||||
child: FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
label: Text('Esporta (${selectedFiles.length})'),
|
||||
onPressed: () {}, // Manteniamo vivo il colore!
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. GRIGLIA DEI FILE
|
||||
if (allFiles.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.dividerColor,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Column(
|
||||
children: [
|
||||
Icon(Icons.upload_file, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Nessun file allegato. Usa il pulsante per aggiungere documenti o foto.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 150,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: allFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = allFiles[index];
|
||||
final isPdf = file.extension == 'pdf';
|
||||
final isSelected = selectedFiles.contains(file);
|
||||
final isLocal =
|
||||
file.localBytes !=
|
||||
null; // Per capire se è un file in bozza
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// CARD DEL FILE
|
||||
InkWell(
|
||||
onTap: () => _openFile(file),
|
||||
onLongPress: () {
|
||||
// Selezione rapida con long press!
|
||||
context.read<OperationFilesBloc>().add(
|
||||
ToggleOperationFileSelectionEvent(file),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.dividerColor,
|
||||
width: isSelected ? 3 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Anteprima
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: isPdf
|
||||
? const Icon(
|
||||
Icons.picture_as_pdf,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
)
|
||||
: isLocal
|
||||
? ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(
|
||||
top: Radius.circular(8),
|
||||
),
|
||||
child: Image.memory(
|
||||
file.localBytes!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.image,
|
||||
size: 48,
|
||||
color: Colors.blue,
|
||||
), // Da remoto metterai il tuo NetworkImage se vuoi
|
||||
),
|
||||
),
|
||||
// Nome File
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
file.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// CHECKBOX DI SELEZIONE
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
ToggleOperationFileSelectionEvent(file),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: Colors.white.withValues(alpha: 0.8),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Icon(
|
||||
isSelected ? Icons.check : Icons.circle,
|
||||
size: 16,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// BADGE "IN ATTESA" (Se è locale ma la pratica è salvata)
|
||||
if (isLocal)
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Bozza',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
137
lib/core/widgets/shared_forms/staff_section.dart
Normal file
137
lib/core/widgets/shared_forms/staff_section.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
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/staff/blocs/staff_cubit.dart';
|
||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class StaffSection extends StatelessWidget {
|
||||
final OperationModel? currentOp;
|
||||
final ValueChanged<StaffMemberModel> onStaffSelected;
|
||||
|
||||
const StaffSection({
|
||||
super.key,
|
||||
required this.currentOp,
|
||||
required this.onStaffSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final selectedStaffId =
|
||||
currentOp?.staffId ??
|
||||
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Text(
|
||||
'Operatore',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocBuilder<StaffCubit, StaffState>(
|
||||
builder: (context, state) {
|
||||
// Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder!
|
||||
final staffMembers = state.storeStaff;
|
||||
final currentLoggedStaffMember = GetIt.I
|
||||
.get<SessionCubit>()
|
||||
.state
|
||||
.currentStaffMember;
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: staffMembers.map((staff) {
|
||||
final isSelected = staff.id == selectedStaffId;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
onStaffSelected(staff);
|
||||
|
||||
/* context.read<OperationsCubit>().updateOperationFields(
|
||||
staffId: staff.id,
|
||||
staffDisplayName: staff.name,
|
||||
); */
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.only(right: 12.0),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 10.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.dividerColor,
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: isSelected
|
||||
? Colors.white
|
||||
: theme.colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
staff.name.substring(0, 1).toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
staff == currentLoggedStaffMember
|
||||
? 'Tu (${staff.name})'
|
||||
: staff.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user