This commit is contained in:
2026-04-11 12:40:03 +02:00
parent a485d79460
commit 9f154afe9e
15 changed files with 522 additions and 12 deletions

View File

@@ -1,3 +1,7 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/customer_model.dart';
@@ -38,7 +42,7 @@ class CustomerRepository {
try {
final response = await _client
.from('customer')
.select()
.select('*, customer_file(count)')
.eq('company_id', companyId)
.eq('is_active', true)
.order('nome');
@@ -67,4 +71,89 @@ class CustomerRepository {
return [];
}
}
/// Recupera i file di un cliente specifico
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async {
try {
final response = await _client
.from('customer_file')
.select()
.eq('customer_id', customerId);
return (response as List)
.map((f) => CustomerFileModel.fromJson(f))
.toList();
} catch (e) {
throw 'Errore recupero file: $e';
}
}
/// Salva il riferimento del file nel DB
Future<void> saveFileReference(CustomerFileModel file) async {
await _client.from('customer_file').insert(file.toJson());
}
/// Carica un file e salva il riferimento nel database
Future<CustomerFileModel> uploadAndRegisterFile({
required String customerId,
required PlatformFile pickedFile,
}) async {
try {
final user = _client.auth.currentUser;
if (user == null) throw 'Utente non autenticato';
final fileName = pickedFile.name;
final extension = pickedFile.extension ?? '';
final path =
'${user.id}/$customerId/${DateTime.now().millisecondsSinceEpoch}_$fileName';
// Usiamo bytes invece del path per massima compatibilità
if (pickedFile.bytes == null && pickedFile.path == null) {
throw 'Impossibile leggere il contenuto del file';
}
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
if (pickedFile.bytes != null) {
await _client.storage
.from('documents')
.uploadBinary(path, pickedFile.bytes!);
} else {
final file = File(pickedFile.path!);
await _client.storage.from('documents').upload(path, file);
}
final String publicUrl = _client.storage
.from('documents')
.getPublicUrl(path);
final fileRecord = CustomerFileModel(
customerId: customerId,
name: fileName,
url: publicUrl,
extension: extension,
);
final response = await _client
.from('customer_file')
.insert(fileRecord.toJson())
.select()
.single();
return CustomerFileModel.fromJson(response);
} catch (e) {
throw 'Errore durante l\'upload: $e';
}
}
/// Aggiorna la lista degli URL nel database
Future<void> updateCustomerDocuments(int id, List<String> urls) async {
await _client.from('customer').update({'document_urls': urls}).eq('id', id);
}
/// Elimina un file dallo storage
Future<void> deleteDocument(String fullPath) async {
// Il path dovrebbe essere ricavato dall'URL
final path = fullPath.split('documents/').last;
await _client.storage.from('documents').remove([path]);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
class CustomerFileModel extends Equatable {
final int? id;
final String customerId; // Riferimento UUID
final String name;
final String url;
final String extension;
final DateTime? createdAt;
const CustomerFileModel({
this.id,
required this.customerId,
required this.name,
required this.url,
required this.extension,
this.createdAt,
});
factory CustomerFileModel.fromJson(Map<String, dynamic> json) {
return CustomerFileModel(
id: json['id'],
customerId: json['customer_id'],
name: json['name'],
url: json['url'],
extension: json['extension'] ?? '',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
'customer_id': customerId,
'name': name,
'url': url,
'extension': extension,
};
}
@override
List<Object?> get props => [id, customerId, name, url, extension, createdAt];
}

View File

@@ -1,7 +1,7 @@
import 'package:equatable/equatable.dart';
class CustomerModel extends Equatable {
final int? id; // Bigint in SQL
final String? id; // Bigint in SQL
final DateTime? createdAt;
final String nome;
final String telefono;
@@ -11,6 +11,7 @@ class CustomerModel extends Equatable {
final bool nonDisturbare;
final String companyId; // UUID
final bool isActive;
final int fileCount;
const CustomerModel({
this.id,
@@ -23,6 +24,7 @@ class CustomerModel extends Equatable {
this.nonDisturbare = false,
required this.companyId,
this.isActive = true,
this.fileCount = 0,
});
@override
@@ -37,10 +39,11 @@ class CustomerModel extends Equatable {
nonDisturbare,
companyId,
isActive,
fileCount,
];
CustomerModel copyWith({
int? id,
String? id,
DateTime? createdAt,
String? nome,
String? telefono,
@@ -50,6 +53,7 @@ class CustomerModel extends Equatable {
bool? nonDisturbare,
String? companyId,
bool? isActive,
int? fileCount,
}) {
return CustomerModel(
id: id ?? this.id,
@@ -62,12 +66,18 @@ class CustomerModel extends Equatable {
nonDisturbare: nonDisturbare ?? this.nonDisturbare,
companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive,
fileCount: fileCount ?? this.fileCount,
);
}
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;
}
return CustomerModel(
id: json['id'],
id: json['id'] as String,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: null,
@@ -79,8 +89,9 @@ class CustomerModel extends Equatable {
? DateTime.parse(json['data_ultimo_contatto'])
: null,
nonDisturbare: json['non_disturbare'] ?? false,
companyId: json['company_id'],
companyId: json['company_id'] as String,
isActive: json['is_active'] ?? true,
fileCount: count,
);
}

View File

@@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:get_it/get_it.dart';
class CustomerDetailScreen extends StatefulWidget {
final CustomerModel customer;
const CustomerDetailScreen({super.key, required this.customer});
@override
State<CustomerDetailScreen> createState() => _CustomerDetailScreenState();
}
class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
final _repository = GetIt.I<CustomerRepository>();
List<CustomerFileModel> _files = [];
bool _isLoadingFiles = true;
@override
void initState() {
super.initState();
_loadFiles();
}
Future<void> _loadFiles() async {
try {
final files = await _repository.getCustomerFiles(
widget.customer.id.toString(),
);
setState(() {
_files = files;
_isLoadingFiles = false;
});
} catch (e) {
setState(() => _isLoadingFiles = false);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
Future<void> _pickAndUpload() async {
// Chiamata statica pulita
FilePickerResult? result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.any,
withData: true, // Fondamentale per avere i bytes pronti se servono
);
if (result != null) {
for (var pickedFile in result.files) {
try {
final newFile = await _repository.uploadAndRegisterFile(
customerId: widget.customer.id.toString(),
pickedFile: pickedFile,
);
setState(() => _files.add(newFile));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")),
);
}
}
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: context.background,
appBar: AppBar(
title: Text(
widget.customer.nome,
style: const TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: context.background,
elevation: 0,
),
body: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// COLONNA SINISTRA: ANAGRAFICA
Expanded(
flex: 1,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: _buildInfoSection(),
),
),
// DIVISORE VERTICALE
VerticalDivider(
width: 1,
color: context.accent.withValues(alpha: 0.1),
),
// COLONNA DESTRA: DOCUMENTI
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(24),
child: _buildDocumentSection(),
),
),
],
),
);
}
Widget _buildInfoSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoTile(Icons.phone_android, "Telefono", widget.customer.telefono),
_infoTile(
Icons.email_outlined,
"Email",
widget.customer.email.isEmpty ? "Non fornita" : widget.customer.email,
),
_infoTile(
Icons.notes_outlined,
"Note",
widget.customer.note.isEmpty
? "Nessun appunto"
: widget.customer.note,
),
const SizedBox(height: 20),
if (widget.customer.nonDisturbare)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(Icons.privacy_tip, color: Colors.red, size: 20),
SizedBox(width: 10),
Text(
"PRIVACY: Non disturbare",
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
);
}
Widget _buildDocumentSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"DOCUMENTI",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: context.accent,
),
),
ElevatedButton.icon(
onPressed: _pickAndUpload,
icon: const Icon(Icons.add_circle_outline),
label: const Text("CARICA FILE"),
),
],
),
const SizedBox(height: 20),
if (_isLoadingFiles)
const Center(child: CircularProgressIndicator())
else if (_files.isEmpty)
const Center(child: Text("Nessun documento presente"))
else
Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: _files.length,
itemBuilder: (context, index) => _FileCard(file: _files[index]),
),
),
],
);
}
Widget _infoTile(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: context.accent),
const SizedBox(width: 8),
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(color: context.secondaryText, fontSize: 16),
),
],
),
);
}
}
class _FileCard extends StatelessWidget {
final CustomerFileModel file;
const _FileCard({required this.file});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: context.accent.withValues(alpha: 0.1)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(_getFileIcon(file.extension), size: 48, color: context.accent),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
),
],
),
);
}
IconData _getFileIcon(String ext) {
switch (ext.toLowerCase()) {
case 'pdf':
return Icons.picture_as_pdf;
case 'jpg':
case 'jpeg':
case 'png':
return Icons.image;
default:
return Icons.insert_drive_file;
}
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/customers/blocs/customer_bloc.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_form.dart';
import 'package:go_router/go_router.dart';
class CustomersContent extends StatefulWidget {
const CustomersContent({super.key});
@@ -147,7 +148,10 @@ class _CustomersContentState extends State<CustomersContent> {
final customer = state.customers[index];
return _CustomerTile(
customer: customer,
onTap: () => _openCustomerForm(customer: customer),
onTap: () => context.push(
'/customer/${customer.id}',
extra: customer,
),
);
},
);
@@ -222,6 +226,27 @@ class _CustomerTile extends StatelessWidget {
customer.telefono,
style: TextStyle(color: context.secondaryText),
),
if (customer.email.isNotEmpty) ...[
Text(' - ', style: TextStyle(color: context.secondaryText)),
Icon(Icons.email, size: 14, color: context.secondaryText),
const SizedBox(width: 4),
Text(
customer.email,
style: TextStyle(color: context.secondaryText),
),
],
if (customer.fileCount > 0) ...[
Text(' - ', style: TextStyle(color: context.secondaryText)),
Icon(Icons.attach_file, size: 14, color: context.accent),
Text(
'${customer.fileCount} doc',
style: TextStyle(
color: context.accent,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
],
),
),