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

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