feat-insert-service (#5)

Reviewed-on: http://catelliub.zapto.org:3000/brontomark/flux/pulls/5
Co-authored-by: mark-cachy <marco@catelli.it>
Co-committed-by: mark-cachy <marco@catelli.it>
This commit is contained in:
2026-04-20 16:52:20 +02:00
committed by brontomark
parent 667bbf6404
commit c3d4f3fac7
63 changed files with 4715 additions and 1371 deletions

View File

@@ -1,117 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart';
part 'customer_events.dart';
part 'customer_state.dart';
class CustomerBloc extends Bloc<CustomerEvent, CustomerState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
CustomerBloc() : super(const CustomerState()) {
on<LoadCustomersRequested>(_onLoadCustomers);
on<CreateCustomerRequested>(_onCreateCustomer);
on<SearchCustomersRequested>(_onSearchCustomers);
on<UpdateCustomerRequested>(_onUpdateCustomer);
}
Future<void> _onLoadCustomers(
LoadCustomersRequested event,
Emitter<CustomerState> emit,
) async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
final customers = await _repository.getCustomers(event.companyId);
emit(
state.copyWith(status: CustomerStatus.success, customers: customers),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onCreateCustomer(
CreateCustomerRequested event,
Emitter<CustomerState> emit,
) async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
final newCustomer = await _repository.createCustomer(event.customer);
// Aggiorniamo la lista locale aggiungendo il nuovo cliente in cima
final updatedList = List<CustomerModel>.from(state.customers)
..insert(0, newCustomer);
emit(
state.copyWith(
status: CustomerStatus.success,
customers: updatedList,
lastCreatedCustomer:
newCustomer, // Lo passiamo per le Dialog "al volo"
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onUpdateCustomer(
UpdateCustomerRequested event,
Emitter<CustomerState> emit,
) async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
// Qui dovresti aggiungere un metodo updateCustomer nel Repository
// Simile al create ma usando .update().eq('id', customer.id)
final updatedCustomer = await _repository.updateCustomer(event.customer);
final updatedList = List<CustomerModel>.from(state.customers);
final index = updatedList.indexWhere((c) => c.id == updatedCustomer.id);
if (index != -1) {
updatedList[index] = updatedCustomer;
}
emit(
state.copyWith(
status: CustomerStatus.success,
customers: updatedList,
lastCreatedCustomer: updatedCustomer,
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onSearchCustomers(
SearchCustomersRequested event,
Emitter<CustomerState> emit,
) async {
// Non mettiamo loading per evitare flickering durante la digitazione
try {
final results = await _repository.searchCustomers(
event.companyId,
event.query,
);
emit(state.copyWith(status: CustomerStatus.success, customers: results));
} catch (_) {}
}
}

View File

@@ -0,0 +1,160 @@
import 'dart:async'; // Serve per il Timer del debounce
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart';
part 'customer_state.dart';
class CustomerCubit extends Cubit<CustomerState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
// Variabile per gestire il debounce della ricerca
Timer? _searchDebounce;
CustomerCubit() : super(const CustomerState());
// --- LETTURA ---
Future<void> loadCustomers() async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
final customers = await _repository.getCustomers(
_sessionBloc.state.company!.id,
);
emit(
state.copyWith(status: CustomerStatus.success, customers: customers),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- CREAZIONE ---
Future<void> createCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
final newCustomer = await _repository.saveCustomer(customer);
// Aggiorniamo la lista locale aggiungendo il nuovo cliente in cima
final updatedList = List<CustomerModel>.from(state.customers)
..insert(0, newCustomer);
emit(
state.copyWith(
status: CustomerStatus.success,
customers: updatedList,
lastCreatedCustomer: newCustomer,
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- AGGIORNAMENTO ---
Future<void> updateCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
final updatedCustomer = await _repository.updateCustomer(customer);
final updatedList = List<CustomerModel>.from(state.customers);
final index = updatedList.indexWhere((c) => c.id == updatedCustomer.id);
if (index != -1) {
updatedList[index] = updatedCustomer;
}
emit(
state.copyWith(
status: CustomerStatus.success,
customers: updatedList,
lastCreatedCustomer:
updatedCustomer, // Utile se modifichi un cliente appena creato
),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
}
// --- RICERCA CON DEBOUNCE ---
void searchCustomers(String query) {
// 1. Se c'è già una ricerca in attesa (l'utente sta digitando veloce), la annulliamo
if (_searchDebounce?.isActive ?? false) _searchDebounce!.cancel();
// 2. Facciamo partire un timer di 400 millisecondi
_searchDebounce = Timer(const Duration(milliseconds: 300), () async {
// Se cancella tutto e la query è vuota, ricarichiamo la lista base
if (query.trim().isEmpty) {
await loadCustomers();
return;
}
// Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive
try {
final results = await _repository.searchCustomers(
_sessionBloc.state.company!.id,
query,
);
emit(
state.copyWith(status: CustomerStatus.success, customers: results),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerStatus.failure,
errorMessage: e.toString(),
),
);
}
});
}
Future<CustomerModel?> quickCreateCustomer({
required String name,
String? phone,
String? email,
}) async {
final newCustomer = CustomerModel(
nome: name,
telefono: phone ?? '',
email: email ?? '',
companyId: _sessionBloc.state.company!.id,
note: '',
);
try {
final saved = await _repository.saveCustomer(newCustomer);
// Lo aggiungiamo in cima ai suggerimenti
emit(state.copyWith(customers: [saved, ...state.customers]));
return saved;
} catch (e) {
return null;
}
}
// Pulizia della memoria quando il Cubit viene distrutto
@override
Future<void> close() {
_searchDebounce?.cancel();
return super.close();
}
}

View File

@@ -1,34 +0,0 @@
part of 'customer_bloc.dart';
abstract class CustomerEvent extends Equatable {
const CustomerEvent();
@override
List<Object?> get props => [];
}
// Carica tutti i clienti dell'azienda
class LoadCustomersRequested extends CustomerEvent {
final String companyId;
const LoadCustomersRequested(this.companyId);
}
// Crea un cliente (usato sia dalla lista che dalla Dialog operazioni)
class CreateCustomerRequested extends CustomerEvent {
final CustomerModel customer;
const CreateCustomerRequested(this.customer);
}
// Ricerca in tempo reale
class SearchCustomersRequested extends CustomerEvent {
final String companyId;
final String query;
const SearchCustomersRequested(this.companyId, this.query);
}
class UpdateCustomerRequested extends CustomerEvent {
final CustomerModel customer;
const UpdateCustomerRequested(this.customer);
@override
List<Object?> get props => [customer];
}

View File

@@ -1,12 +1,11 @@
part of 'customer_bloc.dart';
part of 'customer_cubit.dart';
enum CustomerStatus { initial, loading, success, failure }
class CustomerState extends Equatable {
final CustomerStatus status;
final List<CustomerModel> customers; // Per la lista generale
final CustomerModel?
lastCreatedCustomer; // <--- Fondamentale per la Dialog "al volo"
final List<CustomerModel> customers;
final CustomerModel? lastCreatedCustomer;
final String? errorMessage;
const CustomerState({

View File

@@ -1,37 +1,38 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/core/utils/string_extensions.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';
class CustomerRepository {
final SupabaseClient _client = GetIt.I<SupabaseClient>();
final SupabaseClient _supabase = GetIt.I<SupabaseClient>();
final String companyId = GetIt.I.get<SessionBloc>().state.company!.id;
// Crea un nuovo cliente
Future<CustomerModel> createCustomer(CustomerModel customer) async {
Future<CustomerModel> saveCustomer(CustomerModel customer) async {
try {
final response = await _client
final response = await _supabase
.from('customer')
.insert(customer.toJson())
.upsert(customer.toJson())
.select()
.single();
return CustomerModel.fromJson(response);
return CustomerModel.fromMap(response);
} catch (e) {
throw 'Errore durante la creazione del cliente: $e';
throw 'Errore durante il salvataggio del cliente: $e';
}
}
Future<CustomerModel> updateCustomer(CustomerModel customer) async {
try {
final response = await _client
final response = await _supabase
.from('customer')
.update(customer.toJson())
.eq('id', customer.id!)
.select()
.single();
return CustomerModel.fromJson(response);
return CustomerModel.fromMap(response);
} catch (e) {
throw 'Errore durante la modifica del cliente: $e';
}
@@ -40,14 +41,17 @@ class CustomerRepository {
// Recupera tutti i clienti dell'azienda
Future<List<CustomerModel>> getCustomers(String companyId) async {
try {
final response = await _client
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';
}
@@ -59,14 +63,14 @@ class CustomerRepository {
String query,
) async {
try {
final response = await _client
final response = await _supabase
.from('customer')
.select()
.eq('company_id', companyId)
.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 [];
}
@@ -75,13 +79,13 @@ class CustomerRepository {
/// Recupera i file di un cliente specifico
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async {
try {
final response = await _client
final response = await _supabase
.from('customer_file')
.select()
.eq('customer_id', customerId);
return (response as List)
.map((f) => CustomerFileModel.fromJson(f))
.map((f) => CustomerFileModel.fromMap(f))
.toList();
} catch (e) {
throw 'Errore recupero file: $e';
@@ -89,8 +93,8 @@ class CustomerRepository {
}
/// Salva il riferimento del file nel DB
Future<void> saveFileReference(CustomerFileModel file) async {
await _client.from('customer_file').insert(file.toJson());
Future<void> saveCustomerFile(CustomerFileModel file) async {
await _supabase.from('customer_file').insert(file.toMap());
}
/// Carica un file e salva il riferimento nel database
@@ -98,15 +102,24 @@ class CustomerRepository {
required String customerId,
required PlatformFile pickedFile,
}) async {
final cleanFileName = pickedFile.name.replaceAll(
RegExp(r'[^a-zA-Z0-9\.\-]'),
'_',
);
final storagePath =
'$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = pickedFile.size;
final fileToSave = CustomerFileModel(
customerId: customerId,
name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(),
url: storagePath,
fileSize: fileSize,
);
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
? 'application/pdf'
: 'image/${fileToSave.extension}';
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';
@@ -114,46 +127,43 @@ class CustomerRepository {
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
if (pickedFile.bytes != null) {
await _client.storage
await _supabase.storage
.from('documents')
.uploadBinary(path, pickedFile.bytes!);
} else {
final file = File(pickedFile.path!);
await _client.storage.from('documents').upload(path, file);
.uploadBinary(
storagePath,
pickedFile.bytes!,
fileOptions: FileOptions(contentType: mimeType, upsert: true),
);
}
final String publicUrl = _client.storage
.from('documents')
.getPublicUrl(path);
final fileRecord = CustomerFileModel(
customerId: customerId,
name: fileName,
url: publicUrl,
extension: extension,
);
final response = await _client
final response = await _supabase
.from('customer_file')
.insert(fileRecord.toJson())
.insert(fileToSave.toMap())
.select()
.single();
return CustomerFileModel.fromJson(response);
return CustomerFileModel.fromMap(response);
} catch (e) {
throw 'Errore durante l\'upload: $e';
}
}
Future<void> saveFileReference(CustomerFileModel file) async {
await _supabase.from('customer_file').upsert(file.toMap());
}
/// 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);
await _supabase
.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]);
await _supabase.storage.from('documents').remove([path]);
}
}

View File

@@ -7,6 +7,7 @@ class CustomerFileModel extends Equatable {
final String url;
final String extension;
final DateTime? createdAt;
final int fileSize;
const CustomerFileModel({
this.id,
@@ -15,31 +16,76 @@ class CustomerFileModel extends Equatable {
required this.url,
required this.extension,
this.createdAt,
required this.fileSize,
});
factory CustomerFileModel.fromJson(Map<String, dynamic> json) {
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (fileSize.toString().length - 1) ~/ 3;
if (i >= suffixes.length) i = suffixes.length - 1;
double num = fileSize / (1 << (i * 10));
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
CustomerFileModel copyWith({
String? id,
String? customerId,
String? name,
String? url,
String? extension,
DateTime? createdAt,
int? fileSize,
}) {
return CustomerFileModel(
id: json['id'] as String,
customerId: json['customer_id'],
name: json['name'],
url: json['url'],
extension: json['extension'] ?? '',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: null,
id: id ?? this.id,
customerId: customerId ?? this.customerId,
name: name ?? this.name,
url: url ?? this.url,
extension: extension ?? this.extension,
createdAt: createdAt ?? this.createdAt,
fileSize: fileSize ?? this.fileSize,
);
}
Map<String, dynamic> toJson() {
factory CustomerFileModel.fromMap(Map<String, dynamic> map) {
return CustomerFileModel(
id: map['id'] as String,
customerId: map['customer_id'],
name: map['name'],
url: map['url'],
extension: map['extension'] ?? '',
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'customer_id': customerId,
'name': name,
'url': url,
'extension': extension,
'file_size': fileSize,
};
}
@override
List<Object?> get props => [id, customerId, name, url, extension, createdAt];
List<Object?> get props => [
id,
customerId,
name,
url,
extension,
createdAt,
fileSize,
];
}

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

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customer_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
class CustomerSearchSheet extends StatefulWidget {
const CustomerSearchSheet({super.key});
@override
State<CustomerSearchSheet> createState() => _CustomerSearchSheetState();
}
class _CustomerSearchSheetState extends State<CustomerSearchSheet> {
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
context.read<CustomerCubit>().loadCustomers();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
context.read<CustomerCubit>().searchCustomers(query);
}
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Trova Cliente",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
tooltip: "Chiudi",
),
],
),
const SizedBox(height: 16),
// --- BARRA DI RICERCA ---
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Cerca per nome, cognome o CF...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged("");
},
),
),
onChanged: _onSearchChanged,
),
const SizedBox(height: 16),
// --- TASTO NUOVO CLIENTE ---
SizedBox(
width: double.infinity,
child: IconButton(
icon: const Icon(Icons.person_add),
onPressed: () async {
final servicesCubit = context.read<ServicesCubit>();
// Apriamo la dialog passando la query attuale
final CustomerModel? nuovoCliente = await showDialog(
context: context,
builder: (context) => QuickCustomerDialog(
initialQuery: _searchController.text,
),
);
if (nuovoCliente != null) {
servicesCubit.updateField(
customerId: nuovoCliente.id,
customerDisplayName: nuovoCliente.nome,
);
setState(() {
_searchController.clear();
});
}
},
),
),
const SizedBox(height: 24),
// --- LISTA RISULTATI CON BLOC BUILDER ---
const Text(
"Risultati",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
),
const SizedBox(height: 8),
Expanded(
// AGGANCIO AL CUBIT REALE
child: BlocBuilder<CustomerCubit, CustomerState>(
builder: (context, state) {
// 1. Stato di caricamento
if (state.status == CustomerStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
// 2. Nessun risultato trovato
if (state.customers.isEmpty) {
return const Center(
child: Text(
"Nessun cliente trovato.\nProva a cambiare i termini di ricerca.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
);
}
// 3. Mostriamo la lista vera
return ListView.separated(
itemCount: state.customers.length,
separatorBuilder: (context, index) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final customer = state.customers[index];
// Assumo che il tuo CustomerModel abbia le proprietà name e surname.
// Adatta queste variabili al tuo modello reale!
final displayName = customer.nome.trim();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
// Mostra l'iniziale
child: Text(
displayName.isNotEmpty
? displayName[0].toUpperCase()
: "?",
),
),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(customer.email),
trailing: const Icon(
Icons.check_circle_outline,
color: Colors.grey,
),
onTap: () {
// Salviamo l'ID e il nome formattato nel form dei servizi
context.read<ServicesCubit>().updateField(
customerId: customer.id,
customerDisplayName: displayName,
);
// Chiudiamo la modale
Navigator.pop(context);
},
);
},
);
},
),
),
],
),
),
);
}
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/customers/blocs/customer_bloc.dart';
import 'package:flux/features/customers/blocs/customer_cubit.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';
@@ -26,16 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
void _loadInitialCustomers() {
final companyId = context.read<SessionBloc>().state.company?.id;
if (companyId != null) {
context.read<CustomerBloc>().add(LoadCustomersRequested(companyId));
context.read<CustomerCubit>().loadCustomers();
}
}
void _onSearch(String query) {
final companyId = context.read<SessionBloc>().state.company?.id;
if (companyId != null) {
context.read<CustomerBloc>().add(
SearchCustomersRequested(companyId, query),
);
context.read<CustomerCubit>().searchCustomers(query);
}
}
@@ -57,16 +55,12 @@ class _CustomersContentState extends State<CustomersContent> {
if (customer == null) {
// CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create
context.read<CustomerBloc>().add(
CreateCustomerRequested(
customerFromForm.copyWith(companyId: companyId),
),
context.read<CustomerCubit>().createCustomer(
customerFromForm.copyWith(companyId: companyId),
);
} else {
// CASO MODIFICA: L'ID e il companyId sono già nel modello
context.read<CustomerBloc>().add(
UpdateCustomerRequested(customerFromForm),
);
context.read<CustomerCubit>().updateCustomer(customerFromForm);
}
Navigator.pop(dialogContext);
},
@@ -125,7 +119,7 @@ class _CustomersContentState extends State<CustomersContent> {
// LISTA CLIENTI
Expanded(
child: BlocBuilder<CustomerBloc, CustomerState>(
child: BlocBuilder<CustomerCubit, CustomerState>(
builder: (context, state) {
if (state.status == CustomerStatus.loading &&
state.customers.isEmpty) {
@@ -235,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

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customer_cubit.dart';
class QuickCustomerDialog extends StatefulWidget {
final String initialQuery;
const QuickCustomerDialog({super.key, required this.initialQuery});
@override
State<QuickCustomerDialog> createState() => _QuickCustomerDialogState();
}
class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
late final TextEditingController _nameCtrl;
final _phoneCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _noteCtrl = TextEditingController();
bool _isLoading = false;
@override
void initState() {
super.initState();
// Prendiamo tutta la stringa nuda e cruda!
_nameCtrl = TextEditingController(text: widget.initialQuery.trim());
}
@override
void dispose() {
_nameCtrl.dispose();
_phoneCtrl.dispose();
_emailCtrl.dispose();
_noteCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
final NavigatorState navigator = Navigator.of(context);
if (_nameCtrl.text.isEmpty) return;
setState(() => _isLoading = true);
// Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti)
final newCustomer = await context.read<CustomerCubit>().quickCreateCustomer(
name: _nameCtrl.text.trim(),
phone: _phoneCtrl.text.trim(),
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit:
// email: _emailCtrl.text.trim(),
// note: _noteCtrl.text.trim(),
);
setState(() => _isLoading = false);
if (context.mounted) {
navigator.pop(newCustomer); // Restituiamo il cliente creato
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("Nuovo Cliente Rapido"),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _nameCtrl,
autofocus: true, // Focus immediato!
decoration: const InputDecoration(
labelText: "Nome / Ragione Sociale *",
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 8),
TextField(
controller: _phoneCtrl,
decoration: const InputDecoration(labelText: "Telefono"),
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 8),
TextField(
controller: _emailCtrl,
decoration: const InputDecoration(labelText: "Email"),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 8),
TextField(
controller: _noteCtrl,
decoration: const InputDecoration(labelText: "Note rapide"),
maxLines: 2,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: _isLoading ? null : _save,
child: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text("Salva e Usa"),
),
],
);
}
}