visualizzazione file dei servizi e copia nei file del cliente
This commit is contained in:
@@ -31,7 +31,7 @@ extension MyStringExtensions on String? {
|
|||||||
|
|
||||||
String fileNameWithoutExtension() {
|
String fileNameWithoutExtension() {
|
||||||
if (this == null || this!.trim().isEmpty) return '';
|
if (this == null || this!.trim().isEmpty) return '';
|
||||||
|
this!.replaceAll(RegExp(r'[^a-zA-Z0-9\.\-]'), '_');
|
||||||
final parts = this!.split('.');
|
final parts = this!.split('.');
|
||||||
if (parts.length < 2) return this!; // Nessuna estensione trovata
|
if (parts.length < 2) return this!; // Nessuna estensione trovata
|
||||||
|
|
||||||
|
|||||||
23
lib/core/widgets/image_viewer_widget.dart
Normal file
23
lib/core/widgets/image_viewer_widget.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ImageViewerWidget extends StatelessWidget {
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
const ImageViewerWidget({super.key, required this.url});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: InteractiveViewer(
|
||||||
|
// InteractiveViewer dà lo zoom gratis alle immagini!
|
||||||
|
child: Center(child: Image.network(url)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
lib/core/widgets/pdf_viewer_widget.dart
Normal file
59
lib/core/widgets/pdf_viewer_widget.dart
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pdfx/pdfx.dart';
|
||||||
|
import 'package:internet_file/internet_file.dart'; // flutter pub add internet_file
|
||||||
|
|
||||||
|
class PdfViewerWidget extends StatefulWidget {
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
const PdfViewerWidget({super.key, required this.url});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PdfViewerWidget> createState() => _PdfViewerWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PdfViewerWidgetState extends State<PdfViewerWidget> {
|
||||||
|
late PdfControllerPinch _pdfController;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initPdf();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pdfController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
return Scaffold(
|
||||||
|
// Usiamo Scaffold dentro il Dialog per avere l'AppBar e poter chiudere
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text("Visualizzatore PDF"),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: PdfViewPinch(
|
||||||
|
controller: _pdfController,
|
||||||
|
// pdfx gestisce nativamente il pinch to zoom!
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ class CustomerRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Salva il riferimento del file nel DB
|
/// Salva il riferimento del file nel DB
|
||||||
Future<void> saveFileReference(CustomerFileModel file) async {
|
Future<void> saveCustomerFile(CustomerFileModel file) async {
|
||||||
await _supabase.from('customer_file').insert(file.toMap());
|
await _supabase.from('customer_file').insert(file.toMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +149,10 @@ class CustomerRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveFileReference(CustomerFileModel file) async {
|
||||||
|
await _supabase.from('customer_file').upsert(file.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
/// Aggiorna la lista degli URL nel database
|
/// Aggiorna la lista degli URL nel database
|
||||||
Future<void> updateCustomerDocuments(int id, List<String> urls) async {
|
Future<void> updateCustomerDocuments(int id, List<String> urls) async {
|
||||||
await _supabase
|
await _supabase
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import 'package:file_picker/file_picker.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flux/core/blocs/session/session_bloc.dart';
|
import 'package:flux/core/blocs/session/session_bloc.dart';
|
||||||
|
import 'package:flux/core/utils/string_extensions.dart';
|
||||||
import 'package:flux/features/services/data/services_repository.dart';
|
import 'package:flux/features/services/data/services_repository.dart';
|
||||||
import 'package:flux/features/services/models/energy_service_model.dart';
|
import 'package:flux/features/services/models/energy_service_model.dart';
|
||||||
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
||||||
import 'package:flux/features/services/models/fin_service_model.dart';
|
import 'package:flux/features/services/models/fin_service_model.dart';
|
||||||
|
import 'package:flux/features/services/models/service_file_model.dart';
|
||||||
import 'package:flux/features/services/models/service_model.dart';
|
import 'package:flux/features/services/models/service_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
@@ -130,7 +132,7 @@ class ServicesCubit extends Cubit<ServicesState> {
|
|||||||
companyId: _sessionBloc.state.company!.id,
|
companyId: _sessionBloc.state.company!.id,
|
||||||
),
|
),
|
||||||
status: ServicesStatus.ready,
|
status: ServicesStatus.ready,
|
||||||
localAttachments: [],
|
files: [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,7 +212,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.localAttachments);
|
await _repository.saveFullService(serviceToSave, state.files);
|
||||||
|
|
||||||
// 3. Reset e ricaricamento
|
// 3. Reset e ricaricamento
|
||||||
emit(state.copyWith(status: ServicesStatus.saved, currentService: null));
|
emit(state.copyWith(status: ServicesStatus.saved, currentService: null));
|
||||||
@@ -228,14 +230,90 @@ class ServicesCubit extends Cubit<ServicesState> {
|
|||||||
// --- GESTIONE ALLEGATI LOCALI ---
|
// --- GESTIONE ALLEGATI LOCALI ---
|
||||||
|
|
||||||
void addAttachments(List<PlatformFile> files) {
|
void addAttachments(List<PlatformFile> files) {
|
||||||
// Aggiungiamo i nuovi file a quelli già presenti in memoria
|
// Trasformiamo i PlatformFile in ServiceFileModel "temporanei"
|
||||||
final updatedList = [...state.localAttachments, ...files];
|
final newAttachments = files.map((file) {
|
||||||
emit(state.copyWith(localAttachments: updatedList));
|
return ServiceFileModel(
|
||||||
|
id: '', // ID vuoto perché non ancora su DB
|
||||||
|
serviceId: state.currentService?.id ?? '',
|
||||||
|
name: file.name.fileNameWithoutExtension(),
|
||||||
|
extension: file.name.fileExtension(),
|
||||||
|
url: '', // URL vuoto perché non ancora caricato
|
||||||
|
fileSize: file.size,
|
||||||
|
localBytes: file.bytes, // Fondamentale per l'upload!
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Uniamo i file esistenti (remoti + locali già aggiunti) con i nuovi
|
||||||
|
final updatedList = [
|
||||||
|
...(state.currentService?.files ?? []),
|
||||||
|
...newAttachments,
|
||||||
|
];
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
currentService: state.currentService?.copyWith(files: updatedList),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeLocalAttachment(int index) {
|
void removeAttachment(int index) {
|
||||||
final updatedList = List<PlatformFile>.from(state.localAttachments);
|
if (state.currentService == null) return;
|
||||||
|
|
||||||
|
final updatedList = List<ServiceFileModel>.from(
|
||||||
|
state.currentService!.files,
|
||||||
|
);
|
||||||
updatedList.removeAt(index);
|
updatedList.removeAt(index);
|
||||||
emit(state.copyWith(localAttachments: updatedList));
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
currentService: state.currentService?.copyWith(files: updatedList),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveAndCopyFileToCustomer(ServiceFileModel file) async {
|
||||||
|
final currentService = state.currentService;
|
||||||
|
if (currentService == null || currentService.customerId == null) {
|
||||||
|
// Magari mostra un errore: non posso copiare al cliente se non c'è un cliente!
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(state.copyWith(status: ServicesStatus.loading));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Salviamo la pratica (Bozza o definitiva che sia)
|
||||||
|
// Questo assicura che il file sia stato caricato su Storage e censito su DB
|
||||||
|
await saveCurrentService(isBozza: currentService.isBozza);
|
||||||
|
|
||||||
|
// 2. Recuperiamo il file "aggiornato"
|
||||||
|
// Dopo il saveCurrentService, il file che prima era "locale" ora ha un URL.
|
||||||
|
// Lo cerchiamo nella lista aggiornata per nome o estensione.
|
||||||
|
final savedFile = state.currentService!.files.firstWhere(
|
||||||
|
(f) => f.name == file.name && f.extension == file.extension,
|
||||||
|
orElse: () => file,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (savedFile.url.isEmpty) {
|
||||||
|
throw Exception("Errore: URL del file non trovato dopo il salvataggio.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Chiamiamo il repository per la copia fisica nel database del cliente
|
||||||
|
// Passiamo l'URL del file e l'ID del cliente
|
||||||
|
await _repository.copyFileToCustomer(
|
||||||
|
file: savedFile,
|
||||||
|
customerId: currentService.customerId!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ServicesState extends Equatable {
|
|||||||
final String query;
|
final String query;
|
||||||
final DateTimeRange? dateRange;
|
final DateTimeRange? dateRange;
|
||||||
final bool hasReachedMax;
|
final bool hasReachedMax;
|
||||||
final List<PlatformFile> localAttachments;
|
final List<ServiceFileModel> files;
|
||||||
|
|
||||||
const ServicesState({
|
const ServicesState({
|
||||||
required this.status,
|
required this.status,
|
||||||
@@ -20,7 +20,7 @@ class ServicesState extends Equatable {
|
|||||||
this.query = '',
|
this.query = '',
|
||||||
this.dateRange,
|
this.dateRange,
|
||||||
this.hasReachedMax = false,
|
this.hasReachedMax = false,
|
||||||
this.localAttachments = const [],
|
this.files = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
ServicesState copyWith({
|
ServicesState copyWith({
|
||||||
@@ -31,7 +31,7 @@ class ServicesState extends Equatable {
|
|||||||
String? query,
|
String? query,
|
||||||
DateTimeRange? dateRange,
|
DateTimeRange? dateRange,
|
||||||
bool? hasReachedMax,
|
bool? hasReachedMax,
|
||||||
List<PlatformFile>? localAttachments,
|
List<ServiceFileModel>? files,
|
||||||
}) {
|
}) {
|
||||||
return ServicesState(
|
return ServicesState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -41,7 +41,7 @@ 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,
|
||||||
localAttachments: localAttachments ?? this.localAttachments,
|
files: files ?? this.files,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,6 @@ class ServicesState extends Equatable {
|
|||||||
query,
|
query,
|
||||||
dateRange,
|
dateRange,
|
||||||
hasReachedMax,
|
hasReachedMax,
|
||||||
localAttachments,
|
files,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flux/core/blocs/session/session_bloc.dart';
|
import 'package:flux/core/blocs/session/session_bloc.dart';
|
||||||
import 'package:flux/core/utils/string_extensions.dart';
|
import 'package:flux/features/customers/data/customer_repository.dart';
|
||||||
|
import 'package:flux/features/customers/models/customer_file_model.dart';
|
||||||
import 'package:flux/features/services/models/service_file_model.dart';
|
import 'package:flux/features/services/models/service_file_model.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
@@ -10,6 +10,7 @@ import '../models/service_model.dart';
|
|||||||
class ServicesRepository {
|
class ServicesRepository {
|
||||||
final _supabase = Supabase.instance.client;
|
final _supabase = Supabase.instance.client;
|
||||||
final companyId = GetIt.I.get<SessionBloc>().state.company!.id;
|
final companyId = GetIt.I.get<SessionBloc>().state.company!.id;
|
||||||
|
final CustomerRepository _customerRepository = GetIt.I<CustomerRepository>();
|
||||||
|
|
||||||
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
|
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
|
||||||
Future<ServiceModel> fetchServiceById(String id) async {
|
Future<ServiceModel> fetchServiceById(String id) async {
|
||||||
@@ -84,7 +85,7 @@ class ServicesRepository {
|
|||||||
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
||||||
Future<void> saveFullService(
|
Future<void> saveFullService(
|
||||||
ServiceModel service,
|
ServiceModel service,
|
||||||
List<PlatformFile> localFiles,
|
List<ServiceFileModel> files,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
// 1. Upsert del record principale
|
// 1. Upsert del record principale
|
||||||
@@ -152,40 +153,27 @@ class ServicesRepository {
|
|||||||
if (insertTasks.isNotEmpty) {
|
if (insertTasks.isNotEmpty) {
|
||||||
await Future.wait(insertTasks);
|
await Future.wait(insertTasks);
|
||||||
}
|
}
|
||||||
if (localFiles.isNotEmpty) {
|
if (files.isNotEmpty) {
|
||||||
final List<Future> uploadTasks = [];
|
final List<Future> uploadTasks = [];
|
||||||
|
|
||||||
for (var file in localFiles) {
|
for (var file in files) {
|
||||||
// Puliamo il nome del file per evitare problemi con spazi o caratteri strani
|
|
||||||
final cleanFileName = file.name.replaceAll(
|
|
||||||
RegExp(r'[^a-zA-Z0-9\.\-]'),
|
|
||||||
'_',
|
|
||||||
);
|
|
||||||
final storagePath =
|
final storagePath =
|
||||||
'$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
|
'$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}';
|
||||||
|
final String mimeType = file.extension.toLowerCase() == 'pdf'
|
||||||
final int fileSize = file.size;
|
? 'application/pdf'
|
||||||
|
: 'image/${file.extension}';
|
||||||
final fileToSave = ServiceFileModel(
|
final fileToSave = file.copyWith(serviceId: newId);
|
||||||
name: cleanFileName.fileNameWithoutExtension(),
|
|
||||||
extension: cleanFileName.fileExtension(),
|
|
||||||
url: '',
|
|
||||||
serviceId: newId,
|
|
||||||
fileSize: fileSize,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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 {
|
||||||
// Determiniamo il MIME type corretto in base all'estensione
|
// Determiniamo il MIME type corretto in base all'estensione
|
||||||
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
|
|
||||||
? 'application/pdf'
|
|
||||||
: 'image/${fileToSave.extension}';
|
|
||||||
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
|
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
|
||||||
await _supabase.storage
|
await _supabase.storage
|
||||||
.from('documents')
|
.from('documents')
|
||||||
.uploadBinary(
|
.uploadBinary(
|
||||||
storagePath,
|
storagePath,
|
||||||
file.bytes!,
|
fileToSave.localBytes!,
|
||||||
fileOptions: FileOptions(
|
fileOptions: FileOptions(
|
||||||
contentType:
|
contentType:
|
||||||
mimeType, // Diciamo a Supabase esattamente cos'è!
|
mimeType, // Diciamo a Supabase esattamente cos'è!
|
||||||
@@ -255,4 +243,18 @@ class ServicesRepository {
|
|||||||
]; // Fallback se non c'è ancora storia
|
]; // Fallback se non c'è ancora storia
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> copyFileToCustomer({
|
||||||
|
required ServiceFileModel file,
|
||||||
|
required String customerId,
|
||||||
|
}) async {
|
||||||
|
CustomerFileModel fileToCopy = CustomerFileModel(
|
||||||
|
customerId: customerId,
|
||||||
|
name: file.name,
|
||||||
|
url: file.url,
|
||||||
|
extension: file.extension,
|
||||||
|
fileSize: file.fileSize,
|
||||||
|
);
|
||||||
|
await _customerRepository.saveCustomerFile(fileToCopy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
class ServiceFileModel extends Equatable {
|
class ServiceFileModel extends Equatable {
|
||||||
@@ -7,7 +9,8 @@ class ServiceFileModel extends Equatable {
|
|||||||
final String extension;
|
final String extension;
|
||||||
final String url;
|
final String url;
|
||||||
final String serviceId;
|
final String serviceId;
|
||||||
final int fileSize; // <--- Aggiunto
|
final int fileSize;
|
||||||
|
final Uint8List? localBytes;
|
||||||
|
|
||||||
const ServiceFileModel({
|
const ServiceFileModel({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -17,6 +20,7 @@ class ServiceFileModel extends Equatable {
|
|||||||
required this.url,
|
required this.url,
|
||||||
required this.serviceId,
|
required this.serviceId,
|
||||||
required this.fileSize,
|
required this.fileSize,
|
||||||
|
this.localBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
|
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
|
||||||
@@ -39,6 +43,7 @@ class ServiceFileModel extends Equatable {
|
|||||||
String? url,
|
String? url,
|
||||||
String? serviceId,
|
String? serviceId,
|
||||||
int? fileSize,
|
int? fileSize,
|
||||||
|
Uint8List? localBytes,
|
||||||
}) {
|
}) {
|
||||||
return ServiceFileModel(
|
return ServiceFileModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -48,6 +53,7 @@ class ServiceFileModel extends Equatable {
|
|||||||
url: url ?? this.url,
|
url: url ?? this.url,
|
||||||
serviceId: serviceId ?? this.serviceId,
|
serviceId: serviceId ?? this.serviceId,
|
||||||
fileSize: fileSize ?? this.fileSize,
|
fileSize: fileSize ?? this.fileSize,
|
||||||
|
localBytes: localBytes ?? this.localBytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flux/core/utils/string_extensions.dart';
|
|||||||
import 'package:flux/features/services/models/energy_service_model.dart';
|
import 'package:flux/features/services/models/energy_service_model.dart';
|
||||||
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
||||||
import 'package:flux/features/services/models/fin_service_model.dart';
|
import 'package:flux/features/services/models/fin_service_model.dart';
|
||||||
|
import 'package:flux/features/services/models/service_file_model.dart'; // <-- Aggiunto Import
|
||||||
|
|
||||||
class ServiceModel extends Equatable {
|
class ServiceModel extends Equatable {
|
||||||
final String? id;
|
final String? id;
|
||||||
@@ -29,6 +30,9 @@ class ServiceModel extends Equatable {
|
|||||||
final List<FinServiceModel> finServices;
|
final List<FinServiceModel> finServices;
|
||||||
final List<EntertainmentServiceModel> entertainmentServices;
|
final List<EntertainmentServiceModel> entertainmentServices;
|
||||||
|
|
||||||
|
// ALLEGATI (Aggiunto)
|
||||||
|
final List<ServiceFileModel> files;
|
||||||
|
|
||||||
const ServiceModel({
|
const ServiceModel({
|
||||||
this.id,
|
this.id,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
@@ -47,6 +51,7 @@ class ServiceModel extends Equatable {
|
|||||||
this.energyServices = const [],
|
this.energyServices = const [],
|
||||||
this.finServices = const [],
|
this.finServices = const [],
|
||||||
this.entertainmentServices = const [],
|
this.entertainmentServices = const [],
|
||||||
|
this.files = const [], // <-- Aggiunto default vuoto
|
||||||
this.customerDisplayName,
|
this.customerDisplayName,
|
||||||
required this.companyId,
|
required this.companyId,
|
||||||
});
|
});
|
||||||
@@ -69,6 +74,7 @@ class ServiceModel extends Equatable {
|
|||||||
List<EnergyServiceModel>? energyServices,
|
List<EnergyServiceModel>? energyServices,
|
||||||
List<FinServiceModel>? finServices,
|
List<FinServiceModel>? finServices,
|
||||||
List<EntertainmentServiceModel>? entertainmentServices,
|
List<EntertainmentServiceModel>? entertainmentServices,
|
||||||
|
List<ServiceFileModel>? files, // <-- Aggiunto
|
||||||
String? customerDisplayName,
|
String? customerDisplayName,
|
||||||
String? companyId,
|
String? companyId,
|
||||||
}) {
|
}) {
|
||||||
@@ -91,6 +97,7 @@ class ServiceModel extends Equatable {
|
|||||||
finServices: finServices ?? this.finServices,
|
finServices: finServices ?? this.finServices,
|
||||||
entertainmentServices:
|
entertainmentServices:
|
||||||
entertainmentServices ?? this.entertainmentServices,
|
entertainmentServices ?? this.entertainmentServices,
|
||||||
|
files: files ?? this.files, // <-- Aggiunto
|
||||||
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
|
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
|
||||||
companyId: companyId ?? this.companyId,
|
companyId: companyId ?? this.companyId,
|
||||||
);
|
);
|
||||||
@@ -115,6 +122,7 @@ class ServiceModel extends Equatable {
|
|||||||
energyServices,
|
energyServices,
|
||||||
finServices,
|
finServices,
|
||||||
entertainmentServices,
|
entertainmentServices,
|
||||||
|
files, // <-- Aggiunto
|
||||||
customerDisplayName,
|
customerDisplayName,
|
||||||
companyId,
|
companyId,
|
||||||
];
|
];
|
||||||
@@ -155,6 +163,13 @@ class ServiceModel extends Equatable {
|
|||||||
.toList() ??
|
.toList() ??
|
||||||
const [],
|
const [],
|
||||||
|
|
||||||
|
// I FILE! (Assicurati che la foreign key su Supabase usi esattamente questo nome)
|
||||||
|
files:
|
||||||
|
(map['service_file'] as List?)
|
||||||
|
?.map((x) => ServiceFileModel.fromMap(x))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
|
||||||
// Display name del cliente con fallback
|
// Display name del cliente con fallback
|
||||||
customerDisplayName: map['customer'] != null
|
customerDisplayName: map['customer'] != null
|
||||||
? "${map['customer']['nome'] ?? ''}".myFormat()
|
? "${map['customer']['nome'] ?? ''}".myFormat()
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flux/core/widgets/image_viewer_widget.dart';
|
||||||
|
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
|
||||||
import 'package:flux/features/services/blocs/services_cubit.dart';
|
import 'package:flux/features/services/blocs/services_cubit.dart';
|
||||||
|
import 'package:flux/features/services/models/service_file_model.dart';
|
||||||
|
|
||||||
class AttachmentsSection extends StatelessWidget {
|
class AttachmentsSection extends StatelessWidget {
|
||||||
const AttachmentsSection({super.key});
|
const AttachmentsSection({super.key});
|
||||||
@@ -24,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 localFiles = state.localAttachments;
|
final files = state.files;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -49,7 +52,7 @@ class AttachmentsSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
if (localFiles.isEmpty)
|
if (files.isEmpty)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -71,42 +74,47 @@ class AttachmentsSection extends StatelessWidget {
|
|||||||
ListView.builder(
|
ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: localFiles.length,
|
itemCount: files.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final file = localFiles[index];
|
final file = files[index];
|
||||||
// Calcoliamo la dimensione in MB
|
// Calcoliamo la dimensione in MB
|
||||||
final sizeMb = (file.size / (1024 * 1024)).toStringAsFixed(2);
|
final sizeMb = (file.fileSize / (1024 * 1024))
|
||||||
|
.toStringAsFixed(2);
|
||||||
|
|
||||||
// Scegliamo un'icona in base al tipo di file
|
// Scegliamo un'icona in base al tipo di file
|
||||||
final isPdf = file.extension?.toLowerCase() == 'pdf';
|
final isPdf = file.extension.toLowerCase() == 'pdf';
|
||||||
|
|
||||||
return Card(
|
return GestureDetector(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
onTap: () => _handleSingleClick(context, file),
|
||||||
elevation: 0,
|
onDoubleTap: () => _handleDoubleClick(context, file),
|
||||||
shape: RoundedRectangleBorder(
|
child: Card(
|
||||||
borderRadius: BorderRadius.circular(8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
side: BorderSide(color: Colors.grey.shade300),
|
elevation: 0,
|
||||||
),
|
shape: RoundedRectangleBorder(
|
||||||
child: ListTile(
|
borderRadius: BorderRadius.circular(8),
|
||||||
leading: Icon(
|
side: BorderSide(color: Colors.grey.shade300),
|
||||||
isPdf ? Icons.picture_as_pdf : Icons.image,
|
|
||||||
color: isPdf ? Colors.red : Colors.blue,
|
|
||||||
size: 32,
|
|
||||||
),
|
),
|
||||||
title: Text(
|
child: ListTile(
|
||||||
file.name,
|
leading: Icon(
|
||||||
maxLines: 1,
|
isPdf ? Icons.picture_as_pdf : Icons.image,
|
||||||
overflow: TextOverflow.ellipsis,
|
color: isPdf ? Colors.red : Colors.blue,
|
||||||
),
|
size: 32,
|
||||||
subtitle: Text("$sizeMb MB"),
|
),
|
||||||
trailing: IconButton(
|
title: Text(
|
||||||
icon: const Icon(
|
file.name,
|
||||||
Icons.delete_outline,
|
maxLines: 1,
|
||||||
color: Colors.red,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text("$sizeMb MB"),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
onPressed: () => context
|
||||||
|
.read<ServicesCubit>()
|
||||||
|
.removeAttachment(index),
|
||||||
),
|
),
|
||||||
onPressed: () => context
|
|
||||||
.read<ServicesCubit>()
|
|
||||||
.removeLocalAttachment(index),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -117,4 +125,54 @@ class AttachmentsSection extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- LOGICA DI COPIA AL CLIENTE ---
|
||||||
|
void _handleSingleClick(BuildContext context, ServiceFileModel file) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text("Copia nei documenti Cliente"),
|
||||||
|
content: const Text(
|
||||||
|
"Vuoi copiare questo file nell'anagrafica del cliente? \n\n"
|
||||||
|
"Attenzione: per procedere, la pratica attuale verrà prima salvata in stato BOZZA.",
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text("Annulla"),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
// 1. Diciamo al Cubit di salvare in Bozza e fare la copia
|
||||||
|
context.read<ServicesCubit>().saveAndCopyFileToCustomer(file);
|
||||||
|
},
|
||||||
|
child: const Text("Salva e Copia"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LOGICA DI VISUALIZZAZIONE OVERLAY ---
|
||||||
|
void _handleDoubleClick(BuildContext context, ServiceFileModel file) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
builder: (ctx) => Dialog(
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: MediaQuery.of(context).size.height * 0.8,
|
||||||
|
child: file.isPdf
|
||||||
|
? PdfViewerWidget(url: file.url)
|
||||||
|
: ImageViewerWidget(url: file.url),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import Foundation
|
|||||||
|
|
||||||
import app_links
|
import app_links
|
||||||
import file_picker
|
import file_picker
|
||||||
|
import pdfx
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
|
PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
72
pubspec.lock
72
pubspec.lock
@@ -145,6 +145,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.8"
|
version: "2.0.8"
|
||||||
|
extension:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: extension
|
||||||
|
sha256: be3a6b7f8adad2f6e2e8c63c895d19811fcf203e23466c6296267941d0ff4f24
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -177,6 +185,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.0.2"
|
version: "11.0.2"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -312,6 +328,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
internet_file:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: internet_file
|
||||||
|
sha256: c303ebf02caa853f072c49150557e76957622adacb18420008531c97a5ef5026
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -488,6 +512,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
pdfx:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: pdfx
|
||||||
|
sha256: "29db9b71d46bf2335e001f91693f2c3fbbf0760e4c2eb596bf4bafab211471c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.2"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -496,6 +528,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.2"
|
version: "7.0.2"
|
||||||
|
photo_view:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: photo_view
|
||||||
|
sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.0"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -685,6 +725,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.12.2"
|
version: "2.12.2"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.0+1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -709,6 +757,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
universal_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_file
|
||||||
|
sha256: d1a957fccaad2a32023b62fe435b273ee47aaf2eb804709795e4bf4afff50960
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
universal_platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_platform
|
||||||
|
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -773,6 +837,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.5"
|
version: "3.1.5"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.3"
|
||||||
vector_graphics:
|
vector_graphics:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ dependencies:
|
|||||||
get_it: ^9.2.1
|
get_it: ^9.2.1
|
||||||
go_router: ^17.2.0
|
go_router: ^17.2.0
|
||||||
google_fonts: ^8.0.2
|
google_fonts: ^8.0.2
|
||||||
|
internet_file: ^1.3.0
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
pdfx: ^2.9.2
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
supabase_flutter: ^2.12.2
|
supabase_flutter: ^2.12.2
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <app_links/app_links_plugin_c_api.h>
|
#include <app_links/app_links_plugin_c_api.h>
|
||||||
|
#include <pdfx/pdfx_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
AppLinksPluginCApiRegisterWithRegistrar(
|
AppLinksPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||||
|
PdfxPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
app_links
|
app_links
|
||||||
|
pdfx
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user