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

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/features/auth/ui/auth_screen.dart';
import 'package:flux/features/company/ui/create_company_screen.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
import 'package:flux/features/home_and_dashboard/ui/home_screen.dart';
import 'package:flux/features/store/ui/create_store_screen.dart';
import 'package:go_router/go_router.dart';
@@ -58,6 +60,14 @@ class AppRouter {
path: '/create-store',
builder: (context, state) => const CreateStoreScreen(),
),
GoRoute(
path: '/customer/:id',
builder: (context, state) {
// Recuperiamo l'oggetto customer passato tramite extra
final customer = state.extra as CustomerModel;
return CustomerDetailScreen(customer: customer);
},
),
],
);
}

View File

@@ -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';

View File

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

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

View File

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

View File

@@ -6,11 +6,13 @@ import FlutterMacOS
import Foundation
import app_links
import file_picker
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -1,6 +1,8 @@
PODS:
- app_links (6.4.1):
- FlutterMacOS
- file_picker (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- shared_preferences_foundation (0.0.1):
- Flutter
@@ -10,6 +12,7 @@ PODS:
DEPENDENCIES:
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
@@ -17,6 +20,8 @@ DEPENDENCIES:
EXTERNAL SOURCES:
app_links:
:path: Flutter/ephemeral/.symlinks/plugins/app_links/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
FlutterMacOS:
:path: Flutter/ephemeral
shared_preferences_foundation:
@@ -26,6 +31,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd

View File

@@ -10,5 +10,13 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@@ -6,5 +6,13 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@@ -105,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@@ -121,6 +129,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.0"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
equatable:
dependency: "direct main"
description:
@@ -153,6 +169,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
url: "https://pub.dev"
source: hosted
version: "11.0.2"
flutter:
dependency: "direct main"
description: flutter
@@ -174,6 +198,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
flutter_svg:
dependency: "direct main"
description:
@@ -797,6 +829,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:

View File

@@ -8,6 +8,7 @@ environment:
dependencies:
equatable: ^2.0.8
file_picker: ^11.0.2
flutter:
sdk: flutter
flutter_bloc: ^9.1.1