u
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/core/widgets/flux_logo.dart';
|
||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||
|
||||
@@ -6,8 +6,6 @@ import 'package:flux/core/blocs/session/session_bloc.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||
import 'package:flux/features/company/models/company_model.dart';
|
||||
import 'package:flux/features/settings/settings.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class CreateCompanyScreen extends StatefulWidget {
|
||||
const CreateCompanyScreen({super.key});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
45
lib/features/customers/models/customer_file_model.dart
Normal file
45
lib/features/customers/models/customer_file_model.dart
Normal 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];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
271
lib/features/customers/ui/customer_detail_screen.dart
Normal file
271
lib/features/customers/ui/customer_detail_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/settings/settings.dart';
|
||||
import 'package:flux/features/store/bloc/store_bloc.dart';
|
||||
import 'package:flux/features/store/models/store_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:flux/core/blocs/session/session_bloc.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||
@@ -23,7 +21,6 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
|
||||
final _capController = TextEditingController();
|
||||
final _comuneController = TextEditingController();
|
||||
final _provinciaController = TextEditingController();
|
||||
final AppSettings _settings = GetIt.I<AppSettings>();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
||||
Reference in New Issue
Block a user