feat-insert-service #5

Merged
brontomark merged 11 commits from feat-insert-service into main 2026-04-20 16:52:20 +02:00
10 changed files with 172 additions and 100 deletions
Showing only changes of commit de940cea1f - Show all commits

View File

@@ -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();
},
),
),
),
);
}

View File

@@ -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);
_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<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),
);
}
}

View File

@@ -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();

View File

@@ -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 [],
);
}

View File

@@ -33,7 +33,7 @@ class _CustomersContentState extends State<CustomersContent> {
void _onSearch(String query) {
final companyId = context.read<SessionBloc>().state.company?.id;
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),
),
],
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,

View File

@@ -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,31 +229,33 @@ 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,
];
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<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(
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",
),
);
}
}
}

View File

@@ -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,
];
}

View File

@@ -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());

View File

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

View File

@@ -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,
),
),
),
),