feat-insert-service #5
@@ -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<String> _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<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();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<PdfViewerWidget> createState() => _PdfViewerWidgetState();
|
||||
@@ -14,6 +22,7 @@ class PdfViewerWidget extends StatefulWidget {
|
||||
class _PdfViewerWidgetState extends State<PdfViewerWidget> {
|
||||
late PdfControllerPinch _pdfController;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -22,12 +31,37 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
|
||||
}
|
||||
|
||||
Future<void> _initPdf() async {
|
||||
// Scarica il file in memoria in modo fluido
|
||||
final pdfData = await InternetFile.get(widget.url);
|
||||
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<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
|
||||
@@ -39,21 +73,25 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
|
||||
@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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<CustomerFileModel> 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<CustomerFileModel>? 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<String, dynamic> 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<String, dynamic> 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 [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -132,7 +132,6 @@ class ServicesCubit extends Cubit<ServicesState> {
|
||||
companyId: _sessionBloc.state.company!.id,
|
||||
),
|
||||
status: ServicesStatus.ready,
|
||||
files: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -212,7 +211,7 @@ class ServicesCubit extends Cubit<ServicesState> {
|
||||
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,32 +229,34 @@ class ServicesCubit extends Cubit<ServicesState> {
|
||||
// --- GESTIONE ALLEGATI LOCALI ---
|
||||
|
||||
void addAttachments(List<PlatformFile> 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<ServiceFileModel> updatedList = [
|
||||
...(state.currentService?.files ?? []),
|
||||
...newAttachments,
|
||||
];
|
||||
|
||||
// Emettiamo lo stato assicurandoci che il ServiceModel venga clonato
|
||||
if (state.currentService != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: state.currentService?.copyWith(files: updatedList),
|
||||
currentService: state.currentService!.copyWith(files: updatedList),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void removeAttachment(int index) {
|
||||
if (state.currentService == null) return;
|
||||
@@ -295,7 +296,9 @@ class ServicesCubit extends Cubit<ServicesState> {
|
||||
);
|
||||
|
||||
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<ServicesState> {
|
||||
// 4. Feedback all'utente
|
||||
// Potresti emettere un successo o mostrare un toast
|
||||
emit(state.copyWith(status: ServicesStatus.success));
|
||||
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage: "Errore durante la copia del file: $e",
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ class ServicesState extends Equatable {
|
||||
final String query;
|
||||
final DateTimeRange? dateRange;
|
||||
final bool hasReachedMax;
|
||||
final List<ServiceFileModel> 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<ServiceFileModel>? 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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -83,10 +83,7 @@ class ServicesRepository {
|
||||
}
|
||||
|
||||
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
||||
Future<void> saveFullService(
|
||||
ServiceModel service,
|
||||
List<ServiceFileModel> files,
|
||||
) async {
|
||||
Future<void> 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<Future> 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<void> 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());
|
||||
|
||||
@@ -93,5 +93,6 @@ class ServiceFileModel extends Equatable {
|
||||
url,
|
||||
serviceId,
|
||||
fileSize,
|
||||
localBytes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class AttachmentsSection extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ServicesCubit, ServicesState>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user