diff --git a/lib/core/widgets/image_viewer_widget.dart b/lib/core/widgets/image_viewer_widget.dart index f0cf481..887c2a5 100644 --- a/lib/core/widgets/image_viewer_widget.dart +++ b/lib/core/widgets/image_viewer_widget.dart @@ -1,22 +1,60 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; // <--- AGGIUNGI QUESTO class ImageViewerWidget extends StatelessWidget { - final String url; + final String? storagePath; // ATTENZIONE: Ora contiene lo storagePath! + final Uint8List? bytes; - const ImageViewerWidget({super.key, required this.url}); + const ImageViewerWidget({super.key, this.storagePath, this.bytes}) + : assert( + (storagePath != null && storagePath != '') || bytes != null, + 'Errore: Devi fornire un Path valido o i bytes del file!', + ); + + // Funzione che chiede le chiavi a Supabase + Future _getSignedUrl() async { + return await Supabase.instance.client.storage + .from('documents') + .createSignedUrl(storagePath!, 60); // Link che si autodistrugge in 60s + } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, leading: IconButton( - icon: const Icon(Icons.close), + icon: const Icon(Icons.close, color: Colors.black), onPressed: () => Navigator.pop(context), ), ), body: InteractiveViewer( - // InteractiveViewer dà lo zoom gratis alle immagini! - child: Center(child: Image.network(url)), + maxScale: 5.0, + child: Center( + // Se abbiamo i byte, mostriamo subito. Altrimenti usiamo il FutureBuilder! + child: bytes != null + ? Image.memory(bytes!) + : FutureBuilder( + future: _getSignedUrl(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + if (snapshot.hasError) { + return const Text( + "Errore caricamento immagine (Permessi negati?)", + style: TextStyle(color: Colors.red), + ); + } + if (snapshot.hasData) { + return Image.network(snapshot.data!); + } + return const SizedBox.shrink(); + }, + ), + ), ), ); } diff --git a/lib/core/widgets/pdf_viewer_widget.dart b/lib/core/widgets/pdf_viewer_widget.dart index f7a269b..216f4ba 100644 --- a/lib/core/widgets/pdf_viewer_widget.dart +++ b/lib/core/widgets/pdf_viewer_widget.dart @@ -1,11 +1,19 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:pdfx/pdfx.dart'; -import 'package:internet_file/internet_file.dart'; // flutter pub add internet_file +import 'package:internet_file/internet_file.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; class PdfViewerWidget extends StatefulWidget { - final String url; + final String? storagePath; + final Uint8List? bytes; - const PdfViewerWidget({super.key, required this.url}); + const PdfViewerWidget({super.key, this.storagePath, this.bytes}) + : assert( + (storagePath != null && storagePath != '') || bytes != null, + 'Errore: Devi fornire un URL valido o i bytes del file!', + ); @override State createState() => _PdfViewerWidgetState(); @@ -14,6 +22,7 @@ class PdfViewerWidget extends StatefulWidget { class _PdfViewerWidgetState extends State { late PdfControllerPinch _pdfController; bool _isLoading = true; + String? _errorMessage; @override void initState() { @@ -22,12 +31,37 @@ class _PdfViewerWidgetState extends State { } Future _initPdf() async { - // Scarica il file in memoria in modo fluido - final pdfData = await InternetFile.get(widget.url); - _pdfController = PdfControllerPinch( - document: PdfDocument.openData(pdfData), - ); - if (mounted) setState(() => _isLoading = false); + try { + Uint8List pdfData; + + if (widget.bytes != null) { + // SCENARIO 1: Pratica in bozza, file appena scelto (Locale) + pdfData = widget.bytes!; + } else if (widget.storagePath != null && widget.storagePath!.isNotEmpty) { + // SCENARIO 2: Pratica salvata, scarichiamo da Supabase (Remoto) + final signedUrl = await GetIt.I + .get() + .storage + .from('documents') + .createSignedUrl(widget.storagePath!, 60); + pdfData = await InternetFile.get(signedUrl); + } else { + throw Exception("Nessun documento trovato"); + } + + _pdfController = PdfControllerPinch( + document: PdfDocument.openData(pdfData), + ); + + if (mounted) setState(() => _isLoading = false); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } } @override @@ -39,21 +73,25 @@ class _PdfViewerWidgetState extends State { @override Widget build(BuildContext context) { if (_isLoading) { - return const Center(child: CircularProgressIndicator()); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } + + if (_errorMessage != null) { + return Scaffold( + appBar: AppBar(leading: const CloseButton()), + body: Center(child: Text("Errore: $_errorMessage")), + ); + } + return Scaffold( - // Usiamo Scaffold dentro il Dialog per avere l'AppBar e poter chiudere appBar: AppBar( - title: const Text("Visualizzatore PDF"), + title: const Text("Anteprima PDF"), leading: IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), ), ), - body: PdfViewPinch( - controller: _pdfController, - // pdfx gestisce nativamente il pinch to zoom! - ), + body: PdfViewPinch(controller: _pdfController), ); } } diff --git a/lib/features/customers/data/customer_repository.dart b/lib/features/customers/data/customer_repository.dart index bc84a17..ddbef62 100644 --- a/lib/features/customers/data/customer_repository.dart +++ b/lib/features/customers/data/customer_repository.dart @@ -18,7 +18,7 @@ class CustomerRepository { .upsert(customer.toJson()) .select() .single(); - return CustomerModel.fromJson(response); + return CustomerModel.fromMap(response); } catch (e) { throw 'Errore durante il salvataggio del cliente: $e'; } @@ -32,7 +32,7 @@ class CustomerRepository { .eq('id', customer.id!) .select() .single(); - return CustomerModel.fromJson(response); + return CustomerModel.fromMap(response); } catch (e) { throw 'Errore durante la modifica del cliente: $e'; } @@ -43,12 +43,15 @@ class CustomerRepository { try { final response = await _supabase .from('customer') - .select('*, customer_file(count)') + .select(''' + *, + customer_file(*) + ''') .eq('company_id', companyId) .eq('is_active', true) .order('nome'); - return (response as List).map((c) => CustomerModel.fromJson(c)).toList(); + return (response as List).map((c) => CustomerModel.fromMap(c)).toList(); } catch (e) { throw 'Errore nel recupero clienti'; } @@ -67,7 +70,7 @@ class CustomerRepository { .or('nome.ilike.%$query%,telefono.ilike.%$query%') .limit(10); - return (response as List).map((c) => CustomerModel.fromJson(c)).toList(); + return (response as List).map((c) => CustomerModel.fromMap(c)).toList(); } catch (e) { return []; } @@ -110,7 +113,7 @@ class CustomerRepository { customerId: customerId, name: cleanFileName.fileNameWithoutExtension(), extension: cleanFileName.fileExtension(), - url: '', + url: storagePath, fileSize: fileSize, ); final String mimeType = fileToSave.extension.toLowerCase() == 'pdf' @@ -133,13 +136,9 @@ class CustomerRepository { ); } - final String publicUrl = _supabase.storage - .from('documents') - .getPublicUrl(storagePath); - final response = await _supabase .from('customer_file') - .insert(fileToSave.copyWith(url: publicUrl).toMap()) + .insert(fileToSave.toMap()) .select() .single(); diff --git a/lib/features/customers/models/customer_model.dart b/lib/features/customers/models/customer_model.dart index fc2519d..fa55090 100644 --- a/lib/features/customers/models/customer_model.dart +++ b/lib/features/customers/models/customer_model.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flux/core/utils/string_extensions.dart'; +import 'package:flux/features/customers/models/customer_file_model.dart'; class CustomerModel extends Equatable { final String? id; // Bigint in SQL @@ -12,7 +13,7 @@ class CustomerModel extends Equatable { final bool nonDisturbare; final String companyId; // UUID final bool isActive; - final int fileCount; + final List files; const CustomerModel({ this.id, @@ -25,7 +26,7 @@ class CustomerModel extends Equatable { this.nonDisturbare = false, required this.companyId, this.isActive = true, - this.fileCount = 0, + this.files = const [], }); @override @@ -40,7 +41,7 @@ class CustomerModel extends Equatable { nonDisturbare, companyId, isActive, - fileCount, + files, ]; CustomerModel copyWith({ @@ -54,7 +55,7 @@ class CustomerModel extends Equatable { bool? nonDisturbare, String? companyId, bool? isActive, - int? fileCount, + List? files, }) { return CustomerModel( id: id ?? this.id, @@ -67,32 +68,31 @@ class CustomerModel extends Equatable { nonDisturbare: nonDisturbare ?? this.nonDisturbare, companyId: companyId ?? this.companyId, isActive: isActive ?? this.isActive, - fileCount: fileCount ?? this.fileCount, + files: files ?? this.files, ); } - factory CustomerModel.fromJson(Map json) { - int count = 0; - if (json['customer_file'] != null && - (json['customer_file'] as List).isNotEmpty) { - count = json['customer_file'][0]['count'] ?? 0; - } + factory CustomerModel.fromMap(Map map) { return CustomerModel( - id: json['id'] as String, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) + id: map['id'] as String, + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at']) : null, - nome: (json['nome'] as String).myFormat(), - telefono: json['telefono'], - email: json['email'], - note: json['note'] ?? '', - dataUltimoContatto: json['data_ultimo_contatto'] != null - ? DateTime.parse(json['data_ultimo_contatto']) + nome: (map['nome'] as String).myFormat(), + telefono: map['telefono'], + email: map['email'], + note: map['note'] ?? '', + dataUltimoContatto: map['data_ultimo_contatto'] != null + ? DateTime.parse(map['data_ultimo_contatto']) : null, - nonDisturbare: json['non_disturbare'] ?? false, - companyId: json['company_id'] as String, - isActive: json['is_active'] ?? true, - fileCount: count, + nonDisturbare: map['non_disturbare'] ?? false, + companyId: map['company_id'] as String, + isActive: map['is_active'] ?? true, + files: + (map['customer_file'] as List?) + ?.map((x) => CustomerFileModel.fromMap(x)) + .toList() ?? + const [], ); } diff --git a/lib/features/customers/ui/customers_content.dart b/lib/features/customers/ui/customers_content.dart index fcacbc9..e7c22dd 100644 --- a/lib/features/customers/ui/customers_content.dart +++ b/lib/features/customers/ui/customers_content.dart @@ -33,7 +33,7 @@ class _CustomersContentState extends State { void _onSearch(String query) { final companyId = context.read().state.company?.id; if (companyId != null) { - context.read().searchCustomers( query); + context.read().searchCustomers(query); } } @@ -229,11 +229,11 @@ class _CustomerTile extends StatelessWidget { style: TextStyle(color: context.secondaryText), ), ], - if (customer.fileCount > 0) ...[ + if (customer.files.isNotEmpty) ...[ Text(' - ', style: TextStyle(color: context.secondaryText)), Icon(Icons.attach_file, size: 14, color: context.accent), Text( - '${customer.fileCount} doc', + '${customer.files.length} doc', style: TextStyle( color: context.accent, fontWeight: FontWeight.bold, diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart index e4b7640..db154bd 100644 --- a/lib/features/services/blocs/services_cubit.dart +++ b/lib/features/services/blocs/services_cubit.dart @@ -132,7 +132,6 @@ class ServicesCubit extends Cubit { companyId: _sessionBloc.state.company!.id, ), status: ServicesStatus.ready, - files: [], ), ); } @@ -212,7 +211,7 @@ class ServicesCubit extends Cubit { final serviceToSave = state.currentService!.copyWith(isBozza: isBozza); // 2. Salvataggio corazzato - await _repository.saveFullService(serviceToSave, state.files); + await _repository.saveFullService(serviceToSave); // 3. Reset e ricaricamento emit(state.copyWith(status: ServicesStatus.saved, currentService: null)); @@ -230,31 +229,33 @@ class ServicesCubit extends Cubit { // --- GESTIONE ALLEGATI LOCALI --- void addAttachments(List files) { - // Trasformiamo i PlatformFile in ServiceFileModel "temporanei" final newAttachments = files.map((file) { return ServiceFileModel( - id: '', // ID vuoto perché non ancora su DB + id: null, // Meglio null se non è su DB serviceId: state.currentService?.id ?? '', name: file.name.fileNameWithoutExtension(), extension: file.name.fileExtension(), - url: '', // URL vuoto perché non ancora caricato + url: '', fileSize: file.size, - localBytes: file.bytes, // Fondamentale per l'upload! + localBytes: file.bytes, createdAt: DateTime.now(), ); }).toList(); - // Uniamo i file esistenti (remoti + locali già aggiunti) con i nuovi - final updatedList = [ + // Creiamo una nuova lista pulita + final List updatedList = [ ...(state.currentService?.files ?? []), ...newAttachments, ]; - emit( - state.copyWith( - currentService: state.currentService?.copyWith(files: updatedList), - ), - ); + // Emettiamo lo stato assicurandoci che il ServiceModel venga clonato + if (state.currentService != null) { + emit( + state.copyWith( + currentService: state.currentService!.copyWith(files: updatedList), + ), + ); + } } void removeAttachment(int index) { @@ -295,7 +296,9 @@ class ServicesCubit extends Cubit { ); if (savedFile.url.isEmpty) { - throw Exception("Errore: URL del file non trovato dopo il salvataggio."); + throw Exception( + "Errore: URL del file non trovato dopo il salvataggio.", + ); } // 3. Chiamiamo il repository per la copia fisica nel database del cliente @@ -308,12 +311,13 @@ class ServicesCubit extends Cubit { // 4. Feedback all'utente // Potresti emettere un successo o mostrare un toast emit(state.copyWith(status: ServicesStatus.success)); - } catch (e) { - emit(state.copyWith( - status: ServicesStatus.failure, - errorMessage: "Errore durante la copia del file: $e", - )); + emit( + state.copyWith( + status: ServicesStatus.failure, + errorMessage: "Errore durante la copia del file: $e", + ), + ); } } } diff --git a/lib/features/services/blocs/services_state.dart b/lib/features/services/blocs/services_state.dart index f04c002..00439fd 100644 --- a/lib/features/services/blocs/services_state.dart +++ b/lib/features/services/blocs/services_state.dart @@ -10,7 +10,6 @@ class ServicesState extends Equatable { final String query; final DateTimeRange? dateRange; final bool hasReachedMax; - final List files; const ServicesState({ required this.status, @@ -20,7 +19,6 @@ class ServicesState extends Equatable { this.query = '', this.dateRange, this.hasReachedMax = false, - this.files = const [], }); ServicesState copyWith({ @@ -31,7 +29,6 @@ class ServicesState extends Equatable { String? query, DateTimeRange? dateRange, bool? hasReachedMax, - List? files, }) { return ServicesState( status: status ?? this.status, @@ -41,7 +38,6 @@ class ServicesState extends Equatable { query: query ?? this.query, dateRange: dateRange ?? this.dateRange, hasReachedMax: hasReachedMax ?? this.hasReachedMax, - files: files ?? this.files, ); } @@ -54,6 +50,5 @@ class ServicesState extends Equatable { query, dateRange, hasReachedMax, - files, ]; } diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart index 52ca7a7..f81f417 100644 --- a/lib/features/services/data/services_repository.dart +++ b/lib/features/services/data/services_repository.dart @@ -83,10 +83,7 @@ class ServicesRepository { } // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) --- - Future saveFullService( - ServiceModel service, - List files, - ) async { + Future saveFullService(ServiceModel service) async { try { // 1. Upsert del record principale final serviceData = await _supabase @@ -153,16 +150,16 @@ class ServicesRepository { if (insertTasks.isNotEmpty) { await Future.wait(insertTasks); } - if (files.isNotEmpty) { + if (service.files.isNotEmpty) { final List uploadTasks = []; - for (var file in files) { + for (var file in service.files) { final storagePath = '$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}'; final String mimeType = file.extension.toLowerCase() == 'pdf' ? 'application/pdf' : 'image/${file.extension}'; - final fileToSave = file.copyWith(serviceId: newId); + final fileToSave = file.copyWith(serviceId: newId, url: storagePath); // Creiamo una funzione asincrona per caricare file e scrivere nel DB Future uploadAndLink() async { @@ -182,13 +179,7 @@ class ServicesRepository { ), ); - // B. Otteniamo l'URL pubblico e scriviamo il record del file nel DB - final String publicUrl = _supabase.storage - .from('documents') - .getPublicUrl(storagePath); - await _supabase - .from('service_file') - .insert(fileToSave.copyWith(url: publicUrl).toMap()); + await _supabase.from('service_file').insert(fileToSave.toMap()); } uploadTasks.add(uploadAndLink()); diff --git a/lib/features/services/models/service_file_model.dart b/lib/features/services/models/service_file_model.dart index e6fd60b..689577b 100644 --- a/lib/features/services/models/service_file_model.dart +++ b/lib/features/services/models/service_file_model.dart @@ -93,5 +93,6 @@ class ServiceFileModel extends Equatable { url, serviceId, fileSize, + localBytes, ]; } diff --git a/lib/features/services/ui/service_form_screen/attachment_section.dart b/lib/features/services/ui/service_form_screen/attachment_section.dart index c5be3a7..f1e161f 100644 --- a/lib/features/services/ui/service_form_screen/attachment_section.dart +++ b/lib/features/services/ui/service_form_screen/attachment_section.dart @@ -27,7 +27,7 @@ class AttachmentsSection extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final files = state.files; + final files = state.currentService?.files ?? []; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -168,8 +168,14 @@ class AttachmentsSection extends StatelessWidget { width: double.infinity, height: MediaQuery.of(context).size.height * 0.8, child: file.isPdf - ? PdfViewerWidget(url: file.url) - : ImageViewerWidget(url: file.url), + ? PdfViewerWidget( + storagePath: file.url.isNotEmpty ? file.url : null, + bytes: file.localBytes, + ) + : ImageViewerWidget( + storagePath: file.url.isNotEmpty ? file.url : null, + bytes: file.localBytes, + ), ), ), ),