fix supabase storage

This commit is contained in:
2026-04-20 16:50:55 +02:00
parent 8dc1c661ed
commit de940cea1f
10 changed files with 172 additions and 100 deletions

View File

@@ -1,22 +1,60 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; // <--- AGGIUNGI QUESTO
class ImageViewerWidget extends StatelessWidget { 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<String> _getSignedUrl() async {
return await Supabase.instance.client.storage
.from('documents')
.createSignedUrl(storagePath!, 60); // Link che si autodistrugge in 60s
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close, color: Colors.black),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
), ),
body: InteractiveViewer( body: InteractiveViewer(
// InteractiveViewer dà lo zoom gratis alle immagini! maxScale: 5.0,
child: Center(child: Image.network(url)), child: Center(
// Se abbiamo i byte, mostriamo subito. Altrimenti usiamo il FutureBuilder!
child: bytes != null
? Image.memory(bytes!)
: FutureBuilder<String>(
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();
},
),
),
), ),
); );
} }

View File

@@ -1,11 +1,19 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:pdfx/pdfx.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 { 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 @override
State<PdfViewerWidget> createState() => _PdfViewerWidgetState(); State<PdfViewerWidget> createState() => _PdfViewerWidgetState();
@@ -14,6 +22,7 @@ class PdfViewerWidget extends StatefulWidget {
class _PdfViewerWidgetState extends State<PdfViewerWidget> { class _PdfViewerWidgetState extends State<PdfViewerWidget> {
late PdfControllerPinch _pdfController; late PdfControllerPinch _pdfController;
bool _isLoading = true; bool _isLoading = true;
String? _errorMessage;
@override @override
void initState() { void initState() {
@@ -22,12 +31,37 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
} }
Future<void> _initPdf() async { Future<void> _initPdf() async {
// Scarica il file in memoria in modo fluido try {
final pdfData = await InternetFile.get(widget.url); Uint8List pdfData;
_pdfController = PdfControllerPinch(
document: PdfDocument.openData(pdfData), if (widget.bytes != null) {
); // SCENARIO 1: Pratica in bozza, file appena scelto (Locale)
if (mounted) setState(() => _isLoading = false); 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<SupabaseClient>()
.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 @override
@@ -39,21 +73,25 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isLoading) { 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( return Scaffold(
// Usiamo Scaffold dentro il Dialog per avere l'AppBar e poter chiudere
appBar: AppBar( appBar: AppBar(
title: const Text("Visualizzatore PDF"), title: const Text("Anteprima PDF"),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
), ),
body: PdfViewPinch( body: PdfViewPinch(controller: _pdfController),
controller: _pdfController,
// pdfx gestisce nativamente il pinch to zoom!
),
); );
} }
} }

View File

@@ -18,7 +18,7 @@ class CustomerRepository {
.upsert(customer.toJson()) .upsert(customer.toJson())
.select() .select()
.single(); .single();
return CustomerModel.fromJson(response); return CustomerModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante il salvataggio del cliente: $e'; throw 'Errore durante il salvataggio del cliente: $e';
} }
@@ -32,7 +32,7 @@ class CustomerRepository {
.eq('id', customer.id!) .eq('id', customer.id!)
.select() .select()
.single(); .single();
return CustomerModel.fromJson(response); return CustomerModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante la modifica del cliente: $e'; throw 'Errore durante la modifica del cliente: $e';
} }
@@ -43,12 +43,15 @@ class CustomerRepository {
try { try {
final response = await _supabase final response = await _supabase
.from('customer') .from('customer')
.select('*, customer_file(count)') .select('''
*,
customer_file(*)
''')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) .eq('is_active', true)
.order('nome'); .order('nome');
return (response as List).map((c) => CustomerModel.fromJson(c)).toList(); return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
} catch (e) { } catch (e) {
throw 'Errore nel recupero clienti'; throw 'Errore nel recupero clienti';
} }
@@ -67,7 +70,7 @@ class CustomerRepository {
.or('nome.ilike.%$query%,telefono.ilike.%$query%') .or('nome.ilike.%$query%,telefono.ilike.%$query%')
.limit(10); .limit(10);
return (response as List).map((c) => CustomerModel.fromJson(c)).toList(); return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
} catch (e) { } catch (e) {
return []; return [];
} }
@@ -110,7 +113,7 @@ class CustomerRepository {
customerId: customerId, customerId: customerId,
name: cleanFileName.fileNameWithoutExtension(), name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(), extension: cleanFileName.fileExtension(),
url: '', url: storagePath,
fileSize: fileSize, fileSize: fileSize,
); );
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf' 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 final response = await _supabase
.from('customer_file') .from('customer_file')
.insert(fileToSave.copyWith(url: publicUrl).toMap()) .insert(fileToSave.toMap())
.select() .select()
.single(); .single();

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
class CustomerModel extends Equatable { class CustomerModel extends Equatable {
final String? id; // Bigint in SQL final String? id; // Bigint in SQL
@@ -12,7 +13,7 @@ class CustomerModel extends Equatable {
final bool nonDisturbare; final bool nonDisturbare;
final String companyId; // UUID final String companyId; // UUID
final bool isActive; final bool isActive;
final int fileCount; final List<CustomerFileModel> files;
const CustomerModel({ const CustomerModel({
this.id, this.id,
@@ -25,7 +26,7 @@ class CustomerModel extends Equatable {
this.nonDisturbare = false, this.nonDisturbare = false,
required this.companyId, required this.companyId,
this.isActive = true, this.isActive = true,
this.fileCount = 0, this.files = const [],
}); });
@override @override
@@ -40,7 +41,7 @@ class CustomerModel extends Equatable {
nonDisturbare, nonDisturbare,
companyId, companyId,
isActive, isActive,
fileCount, files,
]; ];
CustomerModel copyWith({ CustomerModel copyWith({
@@ -54,7 +55,7 @@ class CustomerModel extends Equatable {
bool? nonDisturbare, bool? nonDisturbare,
String? companyId, String? companyId,
bool? isActive, bool? isActive,
int? fileCount, List<CustomerFileModel>? files,
}) { }) {
return CustomerModel( return CustomerModel(
id: id ?? this.id, id: id ?? this.id,
@@ -67,32 +68,31 @@ class CustomerModel extends Equatable {
nonDisturbare: nonDisturbare ?? this.nonDisturbare, nonDisturbare: nonDisturbare ?? this.nonDisturbare,
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
fileCount: fileCount ?? this.fileCount, files: files ?? this.files,
); );
} }
factory CustomerModel.fromJson(Map<String, dynamic> json) { factory CustomerModel.fromMap(Map<String, dynamic> map) {
int count = 0;
if (json['customer_file'] != null &&
(json['customer_file'] as List).isNotEmpty) {
count = json['customer_file'][0]['count'] ?? 0;
}
return CustomerModel( return CustomerModel(
id: json['id'] as String, id: map['id'] as String,
createdAt: json['created_at'] != null createdAt: map['created_at'] != null
? DateTime.parse(json['created_at']) ? DateTime.parse(map['created_at'])
: null, : null,
nome: (json['nome'] as String).myFormat(), nome: (map['nome'] as String).myFormat(),
telefono: json['telefono'], telefono: map['telefono'],
email: json['email'], email: map['email'],
note: json['note'] ?? '', note: map['note'] ?? '',
dataUltimoContatto: json['data_ultimo_contatto'] != null dataUltimoContatto: map['data_ultimo_contatto'] != null
? DateTime.parse(json['data_ultimo_contatto']) ? DateTime.parse(map['data_ultimo_contatto'])
: null, : null,
nonDisturbare: json['non_disturbare'] ?? false, nonDisturbare: map['non_disturbare'] ?? false,
companyId: json['company_id'] as String, companyId: map['company_id'] as String,
isActive: json['is_active'] ?? true, isActive: map['is_active'] ?? true,
fileCount: count, files:
(map['customer_file'] as List?)
?.map((x) => CustomerFileModel.fromMap(x))
.toList() ??
const [],
); );
} }

View File

@@ -33,7 +33,7 @@ class _CustomersContentState extends State<CustomersContent> {
void _onSearch(String query) { void _onSearch(String query) {
final companyId = context.read<SessionBloc>().state.company?.id; final companyId = context.read<SessionBloc>().state.company?.id;
if (companyId != null) { if (companyId != null) {
context.read<CustomerCubit>().searchCustomers( query); context.read<CustomerCubit>().searchCustomers(query);
} }
} }
@@ -229,11 +229,11 @@ class _CustomerTile extends StatelessWidget {
style: TextStyle(color: context.secondaryText), style: TextStyle(color: context.secondaryText),
), ),
], ],
if (customer.fileCount > 0) ...[ if (customer.files.isNotEmpty) ...[
Text(' - ', style: TextStyle(color: context.secondaryText)), Text(' - ', style: TextStyle(color: context.secondaryText)),
Icon(Icons.attach_file, size: 14, color: context.accent), Icon(Icons.attach_file, size: 14, color: context.accent),
Text( Text(
'${customer.fileCount} doc', '${customer.files.length} doc',
style: TextStyle( style: TextStyle(
color: context.accent, color: context.accent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@@ -132,7 +132,6 @@ class ServicesCubit extends Cubit<ServicesState> {
companyId: _sessionBloc.state.company!.id, companyId: _sessionBloc.state.company!.id,
), ),
status: ServicesStatus.ready, status: ServicesStatus.ready,
files: [],
), ),
); );
} }
@@ -212,7 +211,7 @@ class ServicesCubit extends Cubit<ServicesState> {
final serviceToSave = state.currentService!.copyWith(isBozza: isBozza); final serviceToSave = state.currentService!.copyWith(isBozza: isBozza);
// 2. Salvataggio corazzato // 2. Salvataggio corazzato
await _repository.saveFullService(serviceToSave, state.files); await _repository.saveFullService(serviceToSave);
// 3. Reset e ricaricamento // 3. Reset e ricaricamento
emit(state.copyWith(status: ServicesStatus.saved, currentService: null)); emit(state.copyWith(status: ServicesStatus.saved, currentService: null));
@@ -230,31 +229,33 @@ class ServicesCubit extends Cubit<ServicesState> {
// --- GESTIONE ALLEGATI LOCALI --- // --- GESTIONE ALLEGATI LOCALI ---
void addAttachments(List<PlatformFile> files) { void addAttachments(List<PlatformFile> files) {
// Trasformiamo i PlatformFile in ServiceFileModel "temporanei"
final newAttachments = files.map((file) { final newAttachments = files.map((file) {
return ServiceFileModel( return ServiceFileModel(
id: '', // ID vuoto perché non ancora su DB id: null, // Meglio null se non è su DB
serviceId: state.currentService?.id ?? '', serviceId: state.currentService?.id ?? '',
name: file.name.fileNameWithoutExtension(), name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(), extension: file.name.fileExtension(),
url: '', // URL vuoto perché non ancora caricato url: '',
fileSize: file.size, fileSize: file.size,
localBytes: file.bytes, // Fondamentale per l'upload! localBytes: file.bytes,
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
}).toList(); }).toList();
// Uniamo i file esistenti (remoti + locali già aggiunti) con i nuovi // Creiamo una nuova lista pulita
final updatedList = [ final List<ServiceFileModel> updatedList = [
...(state.currentService?.files ?? []), ...(state.currentService?.files ?? []),
...newAttachments, ...newAttachments,
]; ];
emit( // Emettiamo lo stato assicurandoci che il ServiceModel venga clonato
state.copyWith( if (state.currentService != null) {
currentService: state.currentService?.copyWith(files: updatedList), emit(
), state.copyWith(
); currentService: state.currentService!.copyWith(files: updatedList),
),
);
}
} }
void removeAttachment(int index) { void removeAttachment(int index) {
@@ -295,7 +296,9 @@ class ServicesCubit extends Cubit<ServicesState> {
); );
if (savedFile.url.isEmpty) { 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 // 3. Chiamiamo il repository per la copia fisica nel database del cliente
@@ -308,12 +311,13 @@ class ServicesCubit extends Cubit<ServicesState> {
// 4. Feedback all'utente // 4. Feedback all'utente
// Potresti emettere un successo o mostrare un toast // Potresti emettere un successo o mostrare un toast
emit(state.copyWith(status: ServicesStatus.success)); emit(state.copyWith(status: ServicesStatus.success));
} catch (e) { } catch (e) {
emit(state.copyWith( emit(
status: ServicesStatus.failure, state.copyWith(
errorMessage: "Errore durante la copia del file: $e", status: ServicesStatus.failure,
)); errorMessage: "Errore durante la copia del file: $e",
),
);
} }
} }
} }

View File

@@ -10,7 +10,6 @@ class ServicesState extends Equatable {
final String query; final String query;
final DateTimeRange? dateRange; final DateTimeRange? dateRange;
final bool hasReachedMax; final bool hasReachedMax;
final List<ServiceFileModel> files;
const ServicesState({ const ServicesState({
required this.status, required this.status,
@@ -20,7 +19,6 @@ class ServicesState extends Equatable {
this.query = '', this.query = '',
this.dateRange, this.dateRange,
this.hasReachedMax = false, this.hasReachedMax = false,
this.files = const [],
}); });
ServicesState copyWith({ ServicesState copyWith({
@@ -31,7 +29,6 @@ class ServicesState extends Equatable {
String? query, String? query,
DateTimeRange? dateRange, DateTimeRange? dateRange,
bool? hasReachedMax, bool? hasReachedMax,
List<ServiceFileModel>? files,
}) { }) {
return ServicesState( return ServicesState(
status: status ?? this.status, status: status ?? this.status,
@@ -41,7 +38,6 @@ class ServicesState extends Equatable {
query: query ?? this.query, query: query ?? this.query,
dateRange: dateRange ?? this.dateRange, dateRange: dateRange ?? this.dateRange,
hasReachedMax: hasReachedMax ?? this.hasReachedMax, hasReachedMax: hasReachedMax ?? this.hasReachedMax,
files: files ?? this.files,
); );
} }
@@ -54,6 +50,5 @@ class ServicesState extends Equatable {
query, query,
dateRange, dateRange,
hasReachedMax, hasReachedMax,
files,
]; ];
} }

View File

@@ -83,10 +83,7 @@ class ServicesRepository {
} }
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) --- // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<void> saveFullService( Future<void> saveFullService(ServiceModel service) async {
ServiceModel service,
List<ServiceFileModel> files,
) async {
try { try {
// 1. Upsert del record principale // 1. Upsert del record principale
final serviceData = await _supabase final serviceData = await _supabase
@@ -153,16 +150,16 @@ class ServicesRepository {
if (insertTasks.isNotEmpty) { if (insertTasks.isNotEmpty) {
await Future.wait(insertTasks); await Future.wait(insertTasks);
} }
if (files.isNotEmpty) { if (service.files.isNotEmpty) {
final List<Future> uploadTasks = []; final List<Future> uploadTasks = [];
for (var file in files) { for (var file in service.files) {
final storagePath = final storagePath =
'$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}'; '$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}';
final String mimeType = file.extension.toLowerCase() == 'pdf' final String mimeType = file.extension.toLowerCase() == 'pdf'
? 'application/pdf' ? 'application/pdf'
: 'image/${file.extension}'; : '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 // Creiamo una funzione asincrona per caricare file e scrivere nel DB
Future<void> uploadAndLink() async { Future<void> uploadAndLink() async {
@@ -182,13 +179,7 @@ class ServicesRepository {
), ),
); );
// B. Otteniamo l'URL pubblico e scriviamo il record del file nel DB await _supabase.from('service_file').insert(fileToSave.toMap());
final String publicUrl = _supabase.storage
.from('documents')
.getPublicUrl(storagePath);
await _supabase
.from('service_file')
.insert(fileToSave.copyWith(url: publicUrl).toMap());
} }
uploadTasks.add(uploadAndLink()); uploadTasks.add(uploadAndLink());

View File

@@ -93,5 +93,6 @@ class ServiceFileModel extends Equatable {
url, url,
serviceId, serviceId,
fileSize, fileSize,
localBytes,
]; ];
} }

View File

@@ -27,7 +27,7 @@ class AttachmentsSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<ServicesCubit, ServicesState>( return BlocBuilder<ServicesCubit, ServicesState>(
builder: (context, state) { builder: (context, state) {
final files = state.files; final files = state.currentService?.files ?? [];
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -168,8 +168,14 @@ class AttachmentsSection extends StatelessWidget {
width: double.infinity, width: double.infinity,
height: MediaQuery.of(context).size.height * 0.8, height: MediaQuery.of(context).size.height * 0.8,
child: file.isPdf child: file.isPdf
? PdfViewerWidget(url: file.url) ? PdfViewerWidget(
: ImageViewerWidget(url: file.url), storagePath: file.url.isNotEmpty ? file.url : null,
bytes: file.localBytes,
)
: ImageViewerWidget(
storagePath: file.url.isNotEmpty ? file.url : null,
bytes: file.localBytes,
),
), ),
), ),
), ),