From 68b075f0b140aa4eec3a313c26c076115f89a695 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Mon, 4 May 2026 12:50:00 +0200 Subject: [PATCH] pr? Co-authored-by: Copilot --- .../data/attachments_repository.dart | 23 + .../attachments/models/attachment_model.dart | 6 +- .../ui/attachment_viewer_screen.dart | 220 +++++ .../attachments/ui/quick_rename_dialog.dart | 85 ++ .../customers/data/customer_repository.dart | 2 +- .../blocs/operation_files_bloc.dart | 63 ++ .../blocs/operation_files_events.dart | 23 + .../operations/blocs/operations_cubit.dart | 5 + .../data/operations_repository.dart | 26 +- .../operations/ui/operation_form_screen.dart | 71 +- .../ui/widgets/details_section.dart | 12 +- .../ui/widgets/operation_files_section.dart | 762 ++++++++++++++++++ lib/main.dart | 4 + pubspec.lock | 56 ++ pubspec.yaml | 2 + 15 files changed, 1345 insertions(+), 15 deletions(-) create mode 100644 lib/features/attachments/data/attachments_repository.dart create mode 100644 lib/features/attachments/ui/attachment_viewer_screen.dart create mode 100644 lib/features/attachments/ui/quick_rename_dialog.dart create mode 100644 lib/features/operations/ui/widgets/operation_files_section.dart diff --git a/lib/features/attachments/data/attachments_repository.dart b/lib/features/attachments/data/attachments_repository.dart new file mode 100644 index 0000000..a7760b3 --- /dev/null +++ b/lib/features/attachments/data/attachments_repository.dart @@ -0,0 +1,23 @@ +import 'dart:typed_data'; + +import 'package:supabase_flutter/supabase_flutter.dart'; + +class AttachmentsRepository { + final _supabase = Supabase.instance.client; + + /// Scarica i byte di un file direttamente da Supabase Storage + Future downloadAttachmentBytes(String storagePath) async { + try { + // ATTENZIONE: Sostituisci 'attachments' con il nome VERO del tuo bucket su Supabase! + // Se il tuo storagePath contiene già il nome del bucket all'inizio, + // assicurati di passargli solo il percorso interno. + final Uint8List bytes = await _supabase.storage + .from('attachments') // <--- NOME DEL TUO BUCKET + .download(storagePath); + + return bytes; + } catch (e) { + throw Exception("Impossibile scaricare il documento dal cloud: $e"); + } + } +} diff --git a/lib/features/attachments/models/attachment_model.dart b/lib/features/attachments/models/attachment_model.dart index 35ad3b2..8e61b3e 100644 --- a/lib/features/attachments/models/attachment_model.dart +++ b/lib/features/attachments/models/attachment_model.dart @@ -9,7 +9,7 @@ class AttachmentModel extends Equatable { final String? operationId; final String name; final String extension; - final String storagePath; + final String? storagePath; final int fileSize; final Uint8List? localBytes; final String companyId; @@ -21,7 +21,7 @@ class AttachmentModel extends Equatable { this.operationId, required this.name, required this.extension, - required this.storagePath, + this.storagePath, required this.fileSize, this.localBytes, required this.companyId, @@ -88,7 +88,7 @@ class AttachmentModel extends Equatable { operationId: map['operation_id'] as String?, name: map['name'] as String, extension: map['extension'] as String, - storagePath: map['storage_path'] as String, + storagePath: map['storage_path'] as String?, fileSize: map['file_size'] is int ? map['file_size'] : int.tryParse(map['file_size']?.toString() ?? '0') ?? 0, diff --git a/lib/features/attachments/ui/attachment_viewer_screen.dart b/lib/features/attachments/ui/attachment_viewer_screen.dart new file mode 100644 index 0000000..b1a9347 --- /dev/null +++ b/lib/features/attachments/ui/attachment_viewer_screen.dart @@ -0,0 +1,220 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flux/core/utils/functions.dart'; +import 'package:flux/features/attachments/models/attachment_model.dart'; +import 'package:pdfx/pdfx.dart'; +import 'package:internet_file/internet_file.dart'; + +class AttachmentViewerScreen extends StatefulWidget { + final AttachmentModel attachment; + final Function(String newName)? onRename; + final VoidCallback? onDelete; + + const AttachmentViewerScreen({ + super.key, + required this.attachment, + this.onRename, + this.onDelete, + }); + + @override + State createState() => _AttachmentViewerScreenState(); +} + +class _AttachmentViewerScreenState extends State { + PdfControllerPinch? _pdfController; + bool _isLoading = true; + String? _errorMessage; + Uint8List? _fileBytes; + late String _fileName; + + bool get isPdf => widget.attachment.extension.toLowerCase() == 'pdf'; + + @override + void initState() { + super.initState(); + _fileName = widget.attachment.name; + _loadFile(); + } + + Future _loadFile() async { + try { + // 1. Capiamo da dove prendere i dati + if (widget.attachment.localBytes != null) { + _fileBytes = widget.attachment.localBytes; + } else if (widget.attachment.storagePath != null && + widget.attachment.storagePath!.isNotEmpty) { + final signedUrl = await getSignedUrl(widget.attachment.storagePath!); + _fileBytes = await InternetFile.get(signedUrl); + } else { + throw Exception("Nessun documento trovato o byte mancanti."); + } + + // 2. Se è PDF, inizializziamo il controller + if (isPdf && _fileBytes != null) { + _pdfController = PdfControllerPinch( + document: PdfDocument.openData(_fileBytes!), + ); + } + + if (mounted) setState(() => _isLoading = false); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } + } + + @override + void dispose() { + _pdfController?.dispose(); + super.dispose(); + } + + void _showRenameDialog() { + final ctrl = TextEditingController(text: _fileName); + ctrl.selection = TextSelection( + baseOffset: 0, + extentOffset: ctrl.text.length, + ); + final focusNode = FocusNode(); + + showDialog( + context: context, + builder: (context) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => focusNode.requestFocus(), + ); + return AlertDialog( + title: const Text('Rinomina File'), + content: TextField( + controller: ctrl, + focusNode: focusNode, + decoration: InputDecoration( + labelText: 'Nuovo nome', + suffixText: '.${widget.attachment.extension}', + ), + onSubmitted: (val) { + Navigator.pop(context); + if (val.trim().isNotEmpty && widget.onRename != null) { + setState(() { + _fileName = val.trim(); + }); + widget.onRename!(val.trim()); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annulla'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + if (ctrl.text.trim().isNotEmpty && widget.onRename != null) { + setState(() { + _fileName = ctrl.text.trim(); + }); + widget.onRename!(ctrl.text.trim()); + } + }, + child: const Text('Salva'), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black87, // Sfondo scuro per i viewer è il top + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: Text(_fileName, style: const TextStyle(fontSize: 16)), + actions: [ + if (widget.onRename != null) + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Rinomina', + onPressed: _showRenameDialog, + ), + if (widget.onDelete != null) + IconButton( + icon: const Icon(Icons.delete, color: Colors.redAccent), + tooltip: 'Elimina', + onPressed: () { + // Chiediamo conferma + showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('Eliminare file?'), + content: const Text( + 'Sei sicuro di voler eliminare questo allegato?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(c), + child: const Text('Annulla'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + onPressed: () { + Navigator.pop(c); // Chiude dialog + widget.onDelete!(); // Lancia eliminazione + Navigator.pop(context); // Chiude il viewer + }, + child: const Text('Elimina'), + ), + ], + ), + ); + }, + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + if (_errorMessage != null) { + return Center( + child: Text( + 'Errore: $_errorMessage', + style: const TextStyle(color: Colors.redAccent), + ), + ); + } + if (_fileBytes == null) { + return const Center( + child: Text( + 'File non disponibile', + style: TextStyle(color: Colors.white), + ), + ); + } + + if (isPdf && _pdfController != null) { + return PdfViewPinch(controller: _pdfController!); + } else { + return InteractiveViewer( + maxScale: 5.0, + child: Center(child: Image.memory(_fileBytes!)), + ); + } + } +} diff --git a/lib/features/attachments/ui/quick_rename_dialog.dart b/lib/features/attachments/ui/quick_rename_dialog.dart new file mode 100644 index 0000000..22e1da3 --- /dev/null +++ b/lib/features/attachments/ui/quick_rename_dialog.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class QuickRenameDialog extends StatefulWidget { + final String suggestedName; + final Widget previewWidget; // Può essere Image.memory o un'icona PDF + + const QuickRenameDialog({ + super.key, + required this.suggestedName, + required this.previewWidget, + }); + + @override + State createState() => _QuickRenameDialogState(); +} + +class _QuickRenameDialogState extends State { + late TextEditingController _nameCtrl; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(text: widget.suggestedName); + + // MAGIA UX: Selezioniamo tutto il testo di default appena si apre! + _nameCtrl.selection = TextSelection( + baseOffset: 0, + extentOffset: widget.suggestedName.length, + ); + + // Richiediamo il focus appena il widget è costruito + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Rinomina per Export'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Anteprima del documento (limitiamo l'altezza) + Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration(border: Border.all(color: Colors.grey)), + child: widget.previewWidget, + ), + const SizedBox(height: 16), + TextField( + controller: _nameCtrl, + focusNode: _focusNode, + decoration: const InputDecoration( + labelText: 'Nome del file', + suffixText: '.pdf', // Facciamo capire che sarà un PDF + border: OutlineInputBorder(), + ), + // MAGIA UX 2: Se preme invio sulla tastiera, salva e chiude! + onSubmitted: (value) => Navigator.of(context).pop(value), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), // Ritorna null + child: const Text('Salta'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(_nameCtrl.text), + child: const Text('Esporta (Invio)'), + ), + ], + ); + } +} diff --git a/lib/features/customers/data/customer_repository.dart b/lib/features/customers/data/customer_repository.dart index f7ffc8f..43c31ff 100644 --- a/lib/features/customers/data/customer_repository.dart +++ b/lib/features/customers/data/customer_repository.dart @@ -168,7 +168,7 @@ class CustomerRepository { for (var file in files) { if (file.operationId == null) { idsToDelete.add(file.id!); - storagePathsToDelete.add(file.storagePath); + storagePathsToDelete.add(file.storagePath!); } else { idsToEdit.add(file.id!); } diff --git a/lib/features/operations/blocs/operation_files_bloc.dart b/lib/features/operations/blocs/operation_files_bloc.dart index 34aca84..7a48387 100644 --- a/lib/features/operations/blocs/operation_files_bloc.dart +++ b/lib/features/operations/blocs/operation_files_bloc.dart @@ -31,6 +31,10 @@ class OperationFilesBloc on(_onDeleteOperationFiles); on(_onToggleOperationFileSelection); on(_onLinkFilesToCustomer); + on(_onRenameOperationFile); + on(_onDeleteSpecificOperationFiles); + on(_onSelectAllOperationFiles); + on(_onClearOperationFileSelection); // Se il BLoC nasce con un ID, accendiamo subito lo stream! if (operationId != null) { @@ -266,6 +270,22 @@ class OperationFilesBloc emit(state.copyWith(selectedFiles: selectedFiles)); } + void _onSelectAllOperationFiles( + SelectAllOperationFilesEvent event, + Emitter emit, + ) { + // Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati + emit(state.copyWith(selectedFiles: state.allFiles)); + } + + void _onClearOperationFileSelection( + ClearOperationFileSelectionEvent event, + Emitter emit, + ) { + // Svuotiamo brutalmente la lista + emit(state.copyWith(selectedFiles: [])); + } + FutureOr _onLinkFilesToCustomer( LinkFilesToCustomerEvent event, Emitter emit, @@ -323,4 +343,47 @@ class OperationFilesBloc ); } } + + FutureOr _onRenameOperationFile( + RenameOperationFileEvent event, + Emitter emit, + ) async { + // BIVIO 1: File Locale (Bozza) + if (event.file.localBytes != null) { + final updatedLocalFiles = state.localFiles.map((f) { + if (f == event.file) { + return f.copyWith(name: event.newName); + } + return f; + }).toList(); + emit(state.copyWith(localFiles: updatedLocalFiles)); + return; + } + + // BIVIO 2: File Remoto (Salvato su DB) + emit(state.copyWith(status: OperationFilesStatus.loading)); + try { + await _repository.renameAttachment(event.file.id!, event.newName); + emit(state.copyWith(status: OperationFilesStatus.success)); + } catch (e) { + emit( + state.copyWith( + status: OperationFilesStatus.failure, + error: "Errore rinomina: $e", + ), + ); + } + } + + FutureOr _onDeleteSpecificOperationFiles( + DeleteSpecificOperationFileEvent event, + Emitter emit, + ) { + if (event.file.localBytes != null) { + final updatedLocalFiles = state.localFiles + .where((f) => f != event.file) + .toList(); + emit(state.copyWith(localFiles: updatedLocalFiles)); + } + } } diff --git a/lib/features/operations/blocs/operation_files_events.dart b/lib/features/operations/blocs/operation_files_events.dart index 44a3b5e..f80dfce 100644 --- a/lib/features/operations/blocs/operation_files_events.dart +++ b/lib/features/operations/blocs/operation_files_events.dart @@ -56,3 +56,26 @@ class ToggleOperationFileSelectionEvent extends OperationFilesEvent { final AttachmentModel file; const ToggleOperationFileSelectionEvent(this.file); } + +class RenameOperationFileEvent extends OperationFilesEvent { + final AttachmentModel file; + final String newName; + + const RenameOperationFileEvent(this.file, this.newName); + + @override + List get props => [file, newName]; +} + +class DeleteSpecificOperationFileEvent extends OperationFilesEvent { + final AttachmentModel file; + + const DeleteSpecificOperationFileEvent(this.file); + + @override + List get props => [file]; +} + +class SelectAllOperationFilesEvent extends OperationFilesEvent {} + +class ClearOperationFileSelectionEvent extends OperationFilesEvent {} diff --git a/lib/features/operations/blocs/operations_cubit.dart b/lib/features/operations/blocs/operations_cubit.dart index b148b8a..da3df30 100644 --- a/lib/features/operations/blocs/operations_cubit.dart +++ b/lib/features/operations/blocs/operations_cubit.dart @@ -217,6 +217,7 @@ class OperationsCubit extends Cubit { String? providerId, String? providerDisplayName, String? subtype, + String? description, DateTime? expirationDate, int? quantity, String? modelId, @@ -227,6 +228,7 @@ class OperationsCubit extends Cubit { bool clearProvider = false, bool clearType = false, bool clearSubtype = false, + bool clearDescription = false, bool clearExpiration = false, bool clearQuantity = false, bool clearModel = false, @@ -258,6 +260,9 @@ class OperationsCubit extends Cubit { : (providerDisplayName ?? current.providerDisplayName), quantity: newQuantity, type: clearType ? null : (type ?? current.type), + description: clearDescription + ? null + : (description ?? current.description), subtype: clearSubtype ? null : (subtype ?? current.subtype), expirationDate: clearExpiration ? null diff --git a/lib/features/operations/data/operations_repository.dart b/lib/features/operations/data/operations_repository.dart index 2d38fb8..f0cd46a 100644 --- a/lib/features/operations/data/operations_repository.dart +++ b/lib/features/operations/data/operations_repository.dart @@ -247,6 +247,30 @@ class OperationsRepository { .eq('id', file.id!); } + Future renameAttachment(String id, String newName) async { + try { + await _supabase.from('attachment').update({'name': newName}).eq('id', id); + } catch (e) { + throw '$e'; + } + } + + Future deleteSpecificOperationFile(AttachmentModel file) async { + try { + if (file.customerId == null) { + await _supabase.from('attachment').delete().eq('id', file.id!); + await _supabase.storage.from('documents').remove([file.storagePath!]); + } else { + await _supabase + .from('attachment') + .update({'operation_id': null}) + .eq('id', file.id!); + } + } catch (e) { + throw '$e'; + } + } + Future deleteOperationFiles(List files) async { if (files.isEmpty) return; // 1. Prepariamo le liste di ID e di Percorsi @@ -256,7 +280,7 @@ class OperationsRepository { for (var file in files) { if (file.customerId == null) { idsToDelete.add(file.id!); - storagePathsToDelete.add(file.storagePath); + storagePathsToDelete.add(file.storagePath!); } else { idsToEdit.add(file.id!); } diff --git a/lib/features/operations/ui/operation_form_screen.dart b/lib/features/operations/ui/operation_form_screen.dart index ed6f233..40f7193 100644 --- a/lib/features/operations/ui/operation_form_screen.dart +++ b/lib/features/operations/ui/operation_form_screen.dart @@ -5,6 +5,7 @@ import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/ui/widgets/customer_section.dart'; import 'package:flux/features/operations/ui/widgets/details_section.dart'; +import 'package:flux/features/operations/ui/widgets/operation_files_section.dart'; import 'package:flux/features/operations/ui/widgets/staff_section.dart'; import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH // import 'package:flux/features/attachments/ui/operation_files_section.dart'; @@ -29,6 +30,7 @@ class _OperationFormScreenState extends State { final _referenceController = TextEditingController(); final _noteController = TextEditingController(); final _freeTextSubtypeController = TextEditingController(); + final _freeTextDescriptionController = TextEditingController(); final List _availableTypes = [ 'AL', @@ -89,6 +91,11 @@ class _OperationFormScreenState extends State { model.subtype!.isNotEmpty) { _freeTextSubtypeController.text = model.subtype!; } + if (_freeTextDescriptionController.text.isEmpty && + model.description != null && + model.description!.isNotEmpty) { + _freeTextDescriptionController.text = model.description!; + } _isInitialized = true; } @@ -103,6 +110,9 @@ class _OperationFormScreenState extends State { subtype: ['Entertainment', 'Custom'].contains(currentOperation.type) ? _freeTextSubtypeController.text : currentOperation.subtype, + description: ['Energy', 'Custom'].contains(currentOperation.type) + ? _freeTextDescriptionController.text + : currentOperation.description, ); cubit.initOperationForm(existingOperation: operationToSave); @@ -138,6 +148,7 @@ class _OperationFormScreenState extends State { ), ); _freeTextSubtypeController.clear(); + _freeTextDescriptionController.clear(); } else if (state.status == OperationsStatus.failure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -168,8 +179,51 @@ class _OperationFormScreenState extends State { key: _formKey, child: LayoutBuilder( builder: (context, constraints) { + final isUltraWide = constraints.maxWidth > 1400; final isDesktop = constraints.maxWidth > 900; - if (isDesktop) { + if (isUltraWide) { + // --- LAYOUT 3 COLONNE (Schermi giganti) --- + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. FORM PRINCIPALE (40%) + Expanded( + flex: 4, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + // Attenzione: devi togliere la sezione file dal _buildMainFormContent! + child: _buildMainFormContent( + theme, + state, + showFiles: false, + ), + ), + ), + VerticalDivider(width: 1, color: theme.dividerColor), + + // 2. NOTE (30%) + Expanded( + flex: 3, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: _buildNotesSection(isDesktop: true), + ), + ), + VerticalDivider(width: 1, color: theme.dividerColor), + + // 3. FILE (30%) + Expanded( + flex: 3, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: OperationFilesSection( + currentOp: state.currentOperation!, + ), + ), + ), + ], + ); + } else if (isDesktop) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -252,7 +306,11 @@ class _OperationFormScreenState extends State { ); } - Widget _buildMainFormContent(ThemeData theme, OperationsState state) { + Widget _buildMainFormContent( + ThemeData theme, + OperationsState state, { + bool showFiles = true, + }) { final currentOp = state.currentOperation; final currentType = currentOp?.type ?? 'AL'; @@ -296,6 +354,7 @@ class _OperationFormScreenState extends State { currentOp: currentOp, currentType: currentType, freeTextSubtypeController: _freeTextSubtypeController, + freeTextDescriptionController: _freeTextDescriptionController, durationQuickPicks: _buildDurationQuickPicks(currentOp), ), @@ -331,13 +390,7 @@ class _OperationFormScreenState extends State { ), const Divider(height: 32), - _buildSectionTitle('Documenti & Foto'), - const Center( - child: Text( - "Widget File in arrivo...", - style: TextStyle(color: Colors.grey), - ), - ), + if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)], ], ); } diff --git a/lib/features/operations/ui/widgets/details_section.dart b/lib/features/operations/ui/widgets/details_section.dart index 343db76..9361d37 100644 --- a/lib/features/operations/ui/widgets/details_section.dart +++ b/lib/features/operations/ui/widgets/details_section.dart @@ -10,6 +10,7 @@ class DetailsSection extends StatelessWidget { final OperationModel? currentOp; final String currentType; final TextEditingController freeTextSubtypeController; + final TextEditingController freeTextDescriptionController; final Widget durationQuickPicks; const DetailsSection({ @@ -17,6 +18,7 @@ class DetailsSection extends StatelessWidget { required this.currentOp, required this.currentType, required this.freeTextSubtypeController, + required this.freeTextDescriptionController, required this.durationQuickPicks, }); @@ -309,7 +311,6 @@ class DetailsSection extends StatelessWidget { items: [ 'Luce', 'Gas', - 'Dual', ].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(), onChanged: (val) { if (val != null) { @@ -320,6 +321,15 @@ class DetailsSection extends StatelessWidget { }, ), const SizedBox(height: 16), + TextFormField( + controller: freeTextDescriptionController, + decoration: InputDecoration( + labelText: currentType == 'Energy' + ? 'Offerta scelta' + : 'Nome del servizio/offerta', + ), + ), + const SizedBox(height: 16), ], // 2. SCENARIO FIN (Ricerca Modello/Prodotto) diff --git a/lib/features/operations/ui/widgets/operation_files_section.dart b/lib/features/operations/ui/widgets/operation_files_section.dart new file mode 100644 index 0000000..670725d --- /dev/null +++ b/lib/features/operations/ui/widgets/operation_files_section.dart @@ -0,0 +1,762 @@ +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/operations/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:pdf/pdf.dart' as p; // Se ti serve formattazione core +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 createState() => _OperationFilesSectionState(); +} + +class _OperationFilesSectionState extends State { + String? _exportDirectory; + double _maxMbLimit = 1.0; + + @override + void initState() { + super.initState(); + _loadExportDirectory(); + } + + // --- GESTIONE CARTELLA CITRIX (SOLO DESKTOP) --- + Future _loadExportDirectory() async { + if (kIsWeb) return; + final prefs = await SharedPreferences.getInstance(); + setState(() { + _exportDirectory = prefs.getString('citrix_export_path'); + }); + } + + Future _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 _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().add( + AddOperationFilesEvent(result.files), + ); + } + } + + // --- APERTURA VIEWER --- + void _openFile(AttachmentModel file) { + // 1. Catturiamo il BLoC dalla pagina corrente prima di navigare + final operationFilesBloc = context.read(); + 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 _exportMergedPdf(List 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 allPagesAsImages = []; + final repository = GetIt.I.get(); + + 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( + 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 _exportSplitPdfs(List 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(); + + 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 ?? 'Documento'; + + 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( + 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( + 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().add( + ClearOperationFileSelectionEvent(), + ); + } else { + context.read().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().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().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( + 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) => + >[ + const PopupMenuItem( + value: 'merge', + child: ListTile( + leading: Icon( + Icons.merge_type, + color: Colors.blue, + ), + title: Text('Unisci in un singolo PDF'), + ), + ), + const PopupMenuItem( + 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().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().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, + ), + ), + ), + ), + ], + ); + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 93198f7..8f62822 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flux/features/attachments/data/attachments_repository.dart'; import 'package:flux/features/auth/bloc/auth_cubit.dart'; import 'package:flux/features/operations/data/operations_repository.dart'; import 'package:flux/l10n/app_localizations.dart'; @@ -88,6 +89,9 @@ Future setupLocator() async { () => OperationsRepository(), ); getIt.registerLazySingleton(() => ProviderRepository()); + getIt.registerLazySingleton( + () => AttachmentsRepository(), + ); // NOTA: CompanyRepository l'ho tolto perché la logica della Company // ora è gestita dal CoreRepository durante l'Onboarding. diff --git a/pubspec.lock b/pubspec.lock index 3573306..399aabd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -49,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" bloc: dependency: transitive description: @@ -365,6 +389,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" image_picker: dependency: "direct main" description: @@ -637,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" pdfx: dependency: "direct main" description: @@ -733,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" postgrest: dependency: transitive description: @@ -962,6 +1010,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + universal_io: + dependency: "direct main" + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" universal_platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9a63d01..017d845 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,8 @@ dependencies: shared_preferences: ^2.5.5 supabase_flutter: ^2.12.2 uuid: ^4.5.3 + pdf: ^3.12.0 + universal_io: ^2.3.1 dev_dependencies: flutter_test: