feat-ultimi_servizi-contratti_in_scadenza #12
@@ -12,7 +12,6 @@ import 'package:flux/features/customers/models/customer_model.dart';
|
||||
import 'package:flux/features/customers/ui/customer_detail_screen.dart';
|
||||
import 'package:flux/features/customers/ui/customer_mobile_upload_screen.dart';
|
||||
import 'package:flux/features/customers/ui/customers_content.dart';
|
||||
import 'package:flux/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart';
|
||||
import 'package:flux/features/home/ui/home_screen.dart';
|
||||
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
||||
import 'package:flux/features/master_data/products/ui/products_screen.dart';
|
||||
@@ -23,8 +22,8 @@ import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
|
||||
import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:flux/features/operations/ui/operation_form_screen/operation_form_screen.dart';
|
||||
import 'package:flux/features/operations/ui/operation_form_screen/operation_mobile_upload_screen.dart';
|
||||
import 'package:flux/features/operations/ui/operation_form_screen.dart';
|
||||
import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart';
|
||||
import 'package:flux/features/operations/ui/operations_screen.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -6,31 +6,31 @@ 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';
|
||||
part 'customers_state.dart';
|
||||
|
||||
class CustomerCubit extends Cubit<CustomerState> {
|
||||
class CustomersCubit extends Cubit<CustomersState> {
|
||||
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
|
||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||
|
||||
// Variabile per gestire il debounce della ricerca
|
||||
Timer? _searchDebounce;
|
||||
|
||||
CustomerCubit() : super(const CustomerState());
|
||||
CustomersCubit() : super(const CustomersState());
|
||||
|
||||
// --- LETTURA ---
|
||||
Future<void> loadCustomers() async {
|
||||
emit(state.copyWith(status: CustomerStatus.loading));
|
||||
emit(state.copyWith(status: CustomersStatus.loading));
|
||||
try {
|
||||
final customers = await _repository.getCustomers(
|
||||
_sessionCubit.state.company!.id!,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(status: CustomerStatus.success, customers: customers),
|
||||
state.copyWith(status: CustomersStatus.success, customers: customers),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.failure,
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
@@ -39,7 +39,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
|
||||
// --- CREAZIONE ---
|
||||
Future<void> createCustomer(CustomerModel customer) async {
|
||||
emit(state.copyWith(status: CustomerStatus.loading));
|
||||
emit(state.copyWith(status: CustomersStatus.loading));
|
||||
try {
|
||||
final newCustomer = await _repository.saveCustomer(customer);
|
||||
|
||||
@@ -49,7 +49,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.success,
|
||||
status: CustomersStatus.success,
|
||||
customers: updatedList,
|
||||
lastCreatedCustomer: newCustomer,
|
||||
),
|
||||
@@ -57,7 +57,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.failure,
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
@@ -66,7 +66,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
|
||||
// --- AGGIORNAMENTO ---
|
||||
Future<void> updateCustomer(CustomerModel customer) async {
|
||||
emit(state.copyWith(status: CustomerStatus.loading));
|
||||
emit(state.copyWith(status: CustomersStatus.loading));
|
||||
try {
|
||||
final updatedCustomer = await _repository.updateCustomer(customer);
|
||||
|
||||
@@ -79,7 +79,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.success,
|
||||
status: CustomersStatus.success,
|
||||
customers: updatedList,
|
||||
lastCreatedCustomer:
|
||||
updatedCustomer, // Utile se modifichi un cliente appena creato
|
||||
@@ -88,7 +88,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.failure,
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
@@ -115,12 +115,12 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
query,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(status: CustomerStatus.success, customers: results),
|
||||
state.copyWith(status: CustomersStatus.success, customers: results),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.failure,
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
part of 'customer_cubit.dart';
|
||||
part of 'customers_cubit.dart';
|
||||
|
||||
enum CustomerStatus {
|
||||
enum CustomersStatus {
|
||||
initial,
|
||||
loading,
|
||||
filesLoading,
|
||||
@@ -9,26 +9,26 @@ enum CustomerStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
class CustomerState extends Equatable {
|
||||
final CustomerStatus status;
|
||||
class CustomersState extends Equatable {
|
||||
final CustomersStatus status;
|
||||
final List<CustomerModel> customers;
|
||||
final CustomerModel? lastCreatedCustomer;
|
||||
final String? errorMessage;
|
||||
|
||||
const CustomerState({
|
||||
this.status = CustomerStatus.initial,
|
||||
const CustomersState({
|
||||
this.status = CustomersStatus.initial,
|
||||
this.customers = const [],
|
||||
this.lastCreatedCustomer,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
CustomerState copyWith({
|
||||
CustomerStatus? status,
|
||||
CustomersState copyWith({
|
||||
CustomersStatus? status,
|
||||
List<CustomerModel>? customers,
|
||||
CustomerModel? lastCreatedCustomer,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return CustomerState(
|
||||
return CustomersState(
|
||||
status: status ?? this.status,
|
||||
customers: customers ?? this.customers,
|
||||
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
|
||||
@@ -1,202 +0,0 @@
|
||||
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/operations/blocs/operations_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 operationsCubit = context.read<OperationsCubit>();
|
||||
// Apriamo la dialog passando la query attuale
|
||||
final CustomerModel? nuovoCliente = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => QuickCustomerDialog(
|
||||
initialQuery: _searchController.text,
|
||||
),
|
||||
);
|
||||
|
||||
if (nuovoCliente != null) {
|
||||
operationsCubit.updateField(
|
||||
customerId: nuovoCliente.id,
|
||||
customerDisplayName: nuovoCliente.name,
|
||||
);
|
||||
|
||||
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.name.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<OperationsCubit>().updateField(
|
||||
customerId: customer.id,
|
||||
customerDisplayName: displayName,
|
||||
);
|
||||
|
||||
// Chiudiamo la modale
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_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,14 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
void _loadInitialCustomers() {
|
||||
final companyId = context.read<SessionCubit>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomerCubit>().loadCustomers();
|
||||
context.read<CustomersCubit>().loadCustomers();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearch(String query) {
|
||||
final companyId = context.read<SessionCubit>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomerCubit>().searchCustomers(query);
|
||||
context.read<CustomersCubit>().searchCustomers(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,9 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
|
||||
// LISTA CLIENTI
|
||||
Expanded(
|
||||
child: BlocBuilder<CustomerCubit, CustomerState>(
|
||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == CustomerStatus.loading &&
|
||||
if (state.status == CustomersStatus.loading &&
|
||||
state.customers.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -242,12 +242,12 @@ void openCustomerForm({
|
||||
|
||||
if (customer == null) {
|
||||
// CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create
|
||||
context.read<CustomerCubit>().createCustomer(
|
||||
context.read<CustomersCubit>().createCustomer(
|
||||
customerFromForm.copyWith(companyId: companyId),
|
||||
);
|
||||
} else {
|
||||
// CASO MODIFICA: L'ID e il companyId sono già nel modello
|
||||
context.read<CustomerCubit>().updateCustomer(customerFromForm);
|
||||
context.read<CustomersCubit>().updateCustomer(customerFromForm);
|
||||
}
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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/blocs/customers_cubit.dart';
|
||||
|
||||
class QuickCustomerDialog extends StatefulWidget {
|
||||
final String initialQuery;
|
||||
@@ -42,7 +42,9 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
// Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti)
|
||||
final newCustomer = await context.read<CustomerCubit>().quickCreateCustomer(
|
||||
final newCustomer = await context
|
||||
.read<CustomersCubit>()
|
||||
.quickCreateCustomer(
|
||||
name: _nameCtrl.text.trim(),
|
||||
phone: _phoneCtrl.text.trim(),
|
||||
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit:
|
||||
|
||||
@@ -210,12 +210,40 @@ class OperationsCubit extends Cubit<OperationsState> {
|
||||
.toList();
|
||||
}
|
||||
|
||||
void updateField({String? customerId, String? customerDisplayName}) {
|
||||
// --- GESTIONE DELLO STATO DEL FORM IN TEMPO REALE ---
|
||||
void updateOperationFields({
|
||||
String? customerId,
|
||||
String? customerDisplayName,
|
||||
String? type,
|
||||
String? providerId,
|
||||
String? subtype,
|
||||
DateTime? expirationDate,
|
||||
int? quantity,
|
||||
// Aggiungiamo questi flag per forzare la pulizia dei campi quando cambi tipo
|
||||
bool clearProvider = false,
|
||||
bool clearType = false,
|
||||
bool clearSubtype = false,
|
||||
bool clearExpiration = false,
|
||||
}) {
|
||||
if (state.currentOperation == null) return;
|
||||
final updated = state.currentOperation!.copyWith(
|
||||
|
||||
final current = state.currentOperation!;
|
||||
|
||||
// Creiamo il modello aggiornato
|
||||
// ATTENZIONE: adatta questa logica in base a come è scritto il tuo copyWith!
|
||||
final updated = current.copyWith(
|
||||
customerId: customerId,
|
||||
customerDisplayName: customerDisplayName,
|
||||
type: clearType ? null : type,
|
||||
subtype: clearSubtype ? null : subtype,
|
||||
expirationDate: clearExpiration ? null : expirationDate,
|
||||
// Se clearProvider è true, forziamo una stringa vuota (o null se il tuo modello lo supporta)
|
||||
providerId: clearProvider ? null : (providerId ?? current.providerId),
|
||||
// Idem per subtype e date.
|
||||
// Se expirationDate è nullabile nel copyWith, dovresti poterlo gestire
|
||||
quantity: quantity ?? current.quantity,
|
||||
);
|
||||
|
||||
emit(state.copyWith(currentOperation: updated));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum EnergyType { luce, gas } // Mappa il tuo public.energy_type
|
||||
|
||||
class EnergyOperationModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final EnergyType type;
|
||||
final DateTime expiration;
|
||||
final String providerId;
|
||||
final String? operationId;
|
||||
|
||||
const EnergyOperationModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.type,
|
||||
required this.expiration,
|
||||
required this.providerId,
|
||||
this.operationId,
|
||||
});
|
||||
|
||||
EnergyOperationModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
EnergyType? type,
|
||||
DateTime? expiration,
|
||||
String? providerId,
|
||||
String? operationId,
|
||||
}) {
|
||||
return EnergyOperationModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
expiration: expiration ?? this.expiration,
|
||||
providerId: providerId ?? this.providerId,
|
||||
operationId: operationId ?? this.operationId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
type,
|
||||
expiration,
|
||||
providerId,
|
||||
operationId,
|
||||
];
|
||||
|
||||
factory EnergyOperationModel.fromMap(Map<String, dynamic> map) {
|
||||
return EnergyOperationModel(
|
||||
id: map['id'],
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
type: map['type'] == 'gas' ? EnergyType.gas : EnergyType.luce,
|
||||
expiration: DateTime.parse(map['expiration']),
|
||||
providerId: map['provider_id'],
|
||||
operationId: map['operation_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'type': type.name, // .name trasforma l'enum in 'luce' o 'gas'
|
||||
'expiration': expiration.toIso8601String(),
|
||||
'provider_id': providerId,
|
||||
'operation_id': operationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class EntertainmentOperationModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final String type; // es. Sky, DAZN, ecc.
|
||||
final bool constrained; // Vincolato?
|
||||
final DateTime constrainExpiration;
|
||||
final String? operationId;
|
||||
final String? providerId;
|
||||
|
||||
const EntertainmentOperationModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.type,
|
||||
required this.constrained,
|
||||
required this.constrainExpiration,
|
||||
this.operationId,
|
||||
this.providerId,
|
||||
});
|
||||
|
||||
EntertainmentOperationModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? type,
|
||||
bool? constrained,
|
||||
DateTime? constrainExpiration,
|
||||
String? operationId,
|
||||
String? providerId,
|
||||
}) {
|
||||
return EntertainmentOperationModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
constrained: constrained ?? this.constrained,
|
||||
constrainExpiration: constrainExpiration ?? this.constrainExpiration,
|
||||
operationId: operationId ?? this.operationId,
|
||||
providerId: providerId ?? this.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
type,
|
||||
constrained,
|
||||
constrainExpiration,
|
||||
operationId,
|
||||
providerId,
|
||||
];
|
||||
|
||||
factory EntertainmentOperationModel.fromMap(Map<String, dynamic> map) {
|
||||
return EntertainmentOperationModel(
|
||||
id: map['id'],
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
type: map['type'],
|
||||
constrained: map['constrained'] ?? false,
|
||||
constrainExpiration: DateTime.parse(map['constrain_expiration']),
|
||||
operationId: map['operation_id'],
|
||||
providerId: map['provider_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'type': type,
|
||||
'constrained': constrained,
|
||||
'constrain_expiration': constrainExpiration.toIso8601String(),
|
||||
'operation_id': operationId,
|
||||
'provider_id': providerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class FinOperationModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final DateTime expiration;
|
||||
final String? operationId;
|
||||
final String? modelId; // FK verso model (es. iPhone, Samsung, ecc.)
|
||||
final String? providerId;
|
||||
|
||||
const FinOperationModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.expiration,
|
||||
this.operationId,
|
||||
this.modelId,
|
||||
this.providerId,
|
||||
});
|
||||
|
||||
FinOperationModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
DateTime? expiration,
|
||||
String? operationId,
|
||||
String? modelId,
|
||||
String? providerId,
|
||||
}) {
|
||||
return FinOperationModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
expiration: expiration ?? this.expiration,
|
||||
operationId: operationId ?? this.operationId,
|
||||
modelId: modelId ?? this.modelId,
|
||||
providerId: providerId ?? this.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, createdAt, expiration, operationId, modelId];
|
||||
|
||||
factory FinOperationModel.fromMap(Map<String, dynamic> map) {
|
||||
return FinOperationModel(
|
||||
id: map['id'],
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
expiration: DateTime.parse(map['expiration']),
|
||||
operationId: map['operation_id'],
|
||||
modelId: map['model_id'],
|
||||
providerId: map['provider_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'expiration': expiration.toIso8601String(),
|
||||
'operation_id': operationId,
|
||||
'model_id': modelId,
|
||||
'provider_id': providerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class OperationModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final String type;
|
||||
final String? subType;
|
||||
final String? providerId;
|
||||
final String? providerDisplayName;
|
||||
final String? modelId;
|
||||
@@ -55,6 +56,7 @@ class OperationModel extends Equatable {
|
||||
this.id,
|
||||
this.createdAt,
|
||||
this.type = '',
|
||||
this.subType,
|
||||
this.providerId,
|
||||
this.providerDisplayName,
|
||||
this.modelId,
|
||||
@@ -82,6 +84,7 @@ class OperationModel extends Equatable {
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? type,
|
||||
String? subtype,
|
||||
String? providerId,
|
||||
String? providerDisplayName,
|
||||
String? modelId,
|
||||
@@ -107,6 +110,7 @@ class OperationModel extends Equatable {
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
subType: subtype ?? this.subType,
|
||||
providerId: providerId ?? this.providerId,
|
||||
providerDisplayName: providerDisplayName ?? this.providerDisplayName,
|
||||
modelId: modelId ?? this.modelId,
|
||||
@@ -135,6 +139,7 @@ class OperationModel extends Equatable {
|
||||
id,
|
||||
createdAt,
|
||||
type,
|
||||
subType,
|
||||
providerId,
|
||||
providerDisplayName,
|
||||
modelId,
|
||||
@@ -169,6 +174,7 @@ class OperationModel extends Equatable {
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
type: map['type'] as String? ?? '',
|
||||
subType: map['sub_type'] as String?,
|
||||
providerId: map['provider_id'] as String? ?? '',
|
||||
providerDisplayName: "${map['provider']['name']}".myFormat(),
|
||||
modelId: map['model_id'] as String? ?? '',
|
||||
@@ -206,6 +212,7 @@ class OperationModel extends Equatable {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'type': type,
|
||||
'sub_type': subType,
|
||||
'provider_id': providerId,
|
||||
'model_id': modelId,
|
||||
'description': description,
|
||||
|
||||
624
lib/features/operations/ui/operation_form_screen.dart
Normal file
624
lib/features/operations/ui/operation_form_screen.dart
Normal file
@@ -0,0 +1,624 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
// import 'package:flux/features/attachments/ui/operation_files_section.dart';
|
||||
|
||||
class OperationFormScreen extends StatefulWidget {
|
||||
final String? operationId;
|
||||
final OperationModel? existingOperation;
|
||||
|
||||
const OperationFormScreen({
|
||||
super.key,
|
||||
this.operationId,
|
||||
this.existingOperation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OperationFormScreen> createState() => _OperationFormScreenState();
|
||||
}
|
||||
|
||||
class _OperationFormScreenState extends State<OperationFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// TEXT CONTROLLERS (Unici detentori di stato locale per evitare lag)
|
||||
final _referenceController = TextEditingController();
|
||||
final _noteController = TextEditingController();
|
||||
final _customSubtypeController = TextEditingController();
|
||||
|
||||
final List<String> _availableTypes = [
|
||||
'AL',
|
||||
'MNP',
|
||||
'NIP',
|
||||
'UNICA',
|
||||
'TELEPASS',
|
||||
'Energy',
|
||||
'Fin',
|
||||
'Entertainment',
|
||||
'Custom',
|
||||
];
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Inizializziamo il form nel Cubit
|
||||
context.read<OperationsCubit>().initOperationForm(
|
||||
existingOperation: widget.existingOperation,
|
||||
operationId: widget.operationId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_referenceController.dispose();
|
||||
_noteController.dispose();
|
||||
_customSubtypeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Sincronizza SOLO i testi liberi quando il Cubit ha caricato da DB
|
||||
void _syncTextControllers(OperationModel model) {
|
||||
if (_referenceController.text.isEmpty && model.reference.isNotEmpty) {
|
||||
_referenceController.text = model.reference;
|
||||
}
|
||||
if (_noteController.text.isEmpty && model.note.isNotEmpty) {
|
||||
_noteController.text = model.note;
|
||||
}
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
// --- LOGICA DI SALVATAGGIO ---
|
||||
void _saveOperation({required bool keepAdding}) {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final cubit = context.read<OperationsCubit>();
|
||||
final currentOperation = cubit.state.currentOperation!;
|
||||
|
||||
// 1. "Travasiamo" i testi liberi dai controller al Modello prima di salvare
|
||||
final operationToSave = currentOperation.copyWith(
|
||||
reference: _referenceController.text,
|
||||
note: _noteController.text,
|
||||
// subtype: currentOperation.type == 'Custom' ? _customSubtypeController.text : currentOperation.subtype, // <-- Scommenta quando aggiungi subtype
|
||||
);
|
||||
|
||||
// 2. Aggiorniamo il Cubit con i testi
|
||||
cubit.initOperationForm(existingOperation: operationToSave);
|
||||
|
||||
// 3. Salviamo!
|
||||
cubit.saveCurrentOperation(
|
||||
targetStatus: OperationStatus.ok,
|
||||
shouldPop: !keepAdding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- MODALE SELEZIONE CLIENTE ---
|
||||
void _showCustomerModal() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.8,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Seleziona Cliente',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(modalContext),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Barra di Ricerca
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cerca per nome, telefono o email...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: (query) {
|
||||
// Evento di ricerca (usa debouncer nel cubit!)
|
||||
// context.read<CustomersCubit>().searchCustomers(query);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Pulsante Nuovo Cliente
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('Crea Nuovo Cliente'),
|
||||
onPressed: () {
|
||||
// Apri form nuovo cliente...
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Lista Clienti dal Bloc
|
||||
Expanded(
|
||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
||||
builder: (context, state) {
|
||||
/* Decommenta e adatta al tuo CustomersState
|
||||
if (state.status == CustomersStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.customers.isEmpty) {
|
||||
return const Center(child: Text('Nessun cliente trovato.', style: TextStyle(color: Colors.grey)));
|
||||
}
|
||||
*/
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: 10, // Sostituisci con state.customers.length
|
||||
itemBuilder: (context, index) {
|
||||
// final customer = state.customers[index];
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Icons.person),
|
||||
),
|
||||
title: Text(
|
||||
'Cliente $index',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
), // Sostituisci con customer.name
|
||||
subtitle: const Text(
|
||||
'333 1234567',
|
||||
), // Sostituisci con customer.phoneNumber
|
||||
onTap: () {
|
||||
// Aggiorniamo il form tramite il Cubit delle operazioni
|
||||
context
|
||||
.read<OperationsCubit>()
|
||||
.updateOperationFields(
|
||||
customerId:
|
||||
'id_del_cliente_$index', // customer.id
|
||||
customerDisplayName:
|
||||
'Cliente $index', // customer.name
|
||||
);
|
||||
Navigator.pop(modalContext);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return BlocConsumer<OperationsCubit, OperationsState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.status != current.status ||
|
||||
previous.currentOperation?.id != current.currentOperation?.id,
|
||||
listener: (context, state) {
|
||||
// Sincronizzazione iniziale
|
||||
if (state.status == OperationsStatus.ready &&
|
||||
state.currentOperation != null &&
|
||||
!_isInitialized) {
|
||||
_syncTextControllers(state.currentOperation!);
|
||||
}
|
||||
|
||||
if (state.status == OperationsStatus.saved) {
|
||||
Navigator.of(context).pop();
|
||||
} else if (state.status == OperationsStatus.savedNoPop) {
|
||||
context.read<OperationsCubit>().prepareNextOperationInBatch();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Servizio aggiunto! Inserisci il prossimo.'),
|
||||
),
|
||||
);
|
||||
// Ripuliamo SOLO i testi liberi (il Cubit gestisce già i suoi reset)
|
||||
_referenceController.clear();
|
||||
_noteController.clear();
|
||||
_customSubtypeController.clear();
|
||||
} else if (state.status == OperationsStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage ?? 'Errore'),
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
// Loader iniziale
|
||||
if (!_isInitialized &&
|
||||
(widget.operationId != null || widget.existingOperation != null) &&
|
||||
state.status == OperationsStatus.loading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
state.currentOperation?.id == null
|
||||
? 'Nuova Pratica'
|
||||
: 'Modifica Pratica',
|
||||
),
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isDesktop = constraints.maxWidth > 900;
|
||||
|
||||
if (isDesktop) {
|
||||
// --- LAYOUT DESKTOP ---
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildMainFormContent(theme, state),
|
||||
),
|
||||
),
|
||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildNotesSection(isDesktop: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// --- LAYOUT MOBILE ---
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMainFormContent(theme, state),
|
||||
const Divider(height: 32),
|
||||
_buildNotesSection(isDesktop: false),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
// --- LA CASSA ---
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: OutlinedButton(
|
||||
onPressed: state.status == OperationsStatus.saving
|
||||
? null
|
||||
: () => _saveOperation(keepAdding: true),
|
||||
child: const Text(
|
||||
'Salva e Aggiungi Altro',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: ElevatedButton(
|
||||
onPressed: state.status == OperationsStatus.saving
|
||||
? null
|
||||
: () => _saveOperation(keepAdding: false),
|
||||
child: state.status == OperationsStatus.saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('Salva ed Esci'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- COSTRUTTORI UI COMPONENTI ---
|
||||
|
||||
Widget _buildMainFormContent(ThemeData theme, OperationsState state) {
|
||||
final currentOp = state.currentOperation;
|
||||
final currentType = currentOp?.type ?? 'AL';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- BLOCCO 1: CONTESTO ---
|
||||
_buildSectionTitle('Cliente & Riferimento'),
|
||||
_buildCustomerSelector(currentOp),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _referenceController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Riferimento (es. numero di telefono, targa...)',
|
||||
prefixIcon: Icon(Icons.tag),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
// --- BLOCCO 2: TIPO DI OPERAZIONE ---
|
||||
_buildSectionTitle('Cosa stiamo facendo?'),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: _availableTypes.map((type) {
|
||||
return ChoiceChip(
|
||||
label: Text(type),
|
||||
selected: currentType == type,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
// Diciamo al Cubit di cambiare tipo e spianare i campi dipendenti
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
type: type,
|
||||
clearProvider: true,
|
||||
clearSubtype: true,
|
||||
clearExpiration: true,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
// --- BLOCCO 3: DETTAGLI REATTIVI ---
|
||||
_buildSectionTitle('Dettagli Servizio'),
|
||||
|
||||
// PROVIDER (Mostrato quasi sempre)
|
||||
ListTile(
|
||||
title: const Text('Seleziona Gestore'),
|
||||
subtitle: Text(
|
||||
currentOp?.providerId ?? 'Nessun gestore selezionato',
|
||||
), // Adatta se hai displayName
|
||||
trailing: const Icon(Icons.arrow_drop_down),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: theme.dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: () {
|
||||
// TODO: Modale o Dropdown Provider
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// SOTTO-TIPO (Reattivo)
|
||||
if (['Energy', 'Fin', 'Entertainment'].contains(currentType)) ...[
|
||||
DropdownButtonFormField<String>(
|
||||
value:
|
||||
null, // Sostituisci con currentOp?.subtype quando lo aggiungi
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Dettaglio (es. Luce, Gas...)',
|
||||
),
|
||||
items: [
|
||||
'Luce',
|
||||
'Gas',
|
||||
'Dual',
|
||||
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (val) {
|
||||
// context.read<OperationsCubit>().updateOperationFields(subtype: val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// SOTTO-TIPO CUSTOM (Reattivo)
|
||||
if (currentType == 'Custom') ...[
|
||||
TextFormField(
|
||||
controller: _customSubtypeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Specifica il servizio (es. Monopattino)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// SCADENZA (Reattivo)
|
||||
if ([
|
||||
'Energy',
|
||||
'Fin',
|
||||
'Entertainment',
|
||||
'Custom',
|
||||
].contains(currentType)) ...[
|
||||
ListTile(
|
||||
title: const Text('Data di Scadenza'),
|
||||
subtitle: Text(
|
||||
currentOp?.expirationDate?.toLocal().toString().split(' ')[0] ??
|
||||
'Nessuna scadenza',
|
||||
),
|
||||
trailing: const Icon(Icons.calendar_today),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: theme.dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now().add(const Duration(days: 365)),
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 3650)),
|
||||
);
|
||||
if (date != null) {
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
expirationDate: date,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// QUANTITÀ
|
||||
Row(
|
||||
children: [
|
||||
const Text('Quantità: '),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: () {
|
||||
final q = currentOp?.quantity ?? 1;
|
||||
if (q > 1)
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
quantity: q - 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
'${currentOp?.quantity ?? 1}',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
final q = currentOp?.quantity ?? 1;
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
quantity: q + 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
// --- BLOCCO 5: ALLEGATI ---
|
||||
_buildSectionTitle('Documenti & Foto'),
|
||||
const Center(
|
||||
child: Text(
|
||||
"Widget File in arrivo...",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotesSection({required bool isDesktop}) {
|
||||
final title = _buildSectionTitle('Note Interne');
|
||||
final noteField = TextFormField(
|
||||
controller: _noteController,
|
||||
keyboardType: TextInputType.multiline,
|
||||
minLines: isDesktop ? null : 5,
|
||||
maxLines: null,
|
||||
expands: isDesktop,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Incolla qui seriali, ICCID, IBAN, indirizzi...',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
title,
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: noteField),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [title, const SizedBox(height: 8), noteField],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomerSelector(OperationModel? currentOp) {
|
||||
final hasCustomer =
|
||||
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
|
||||
|
||||
return InkWell(
|
||||
onTap: _showCustomerModal,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.2),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasCustomer
|
||||
? currentOp.customerDisplayName ?? ''
|
||||
: 'Seleziona Cliente *',
|
||||
style: TextStyle(
|
||||
fontWeight: hasCustomer ? FontWeight.bold : FontWeight.normal,
|
||||
color: hasCustomer ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.search),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ActionCard extends StatelessWidget {
|
||||
final String label;
|
||||
final int count;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ActionCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isActive = count > 0;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 110, // Larghezza fissa per avere una griglia ordinata
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? color.withValues(alpha: 0.15)
|
||||
: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isActive ? color : Colors.grey.withValues(alpha: 0.3),
|
||||
width: isActive ? 2 : 1,
|
||||
),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: isActive ? color : Colors.grey, size: 28),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? color : Colors.grey.shade700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (isActive) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/core/widgets/image_viewer_widget.dart';
|
||||
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
|
||||
import 'package:flux/core/widgets/qr_upload_dialog.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
|
||||
class AttachmentsSection extends StatelessWidget {
|
||||
const AttachmentsSection({super.key});
|
||||
|
||||
Future<void> _pickFiles(BuildContext context) async {
|
||||
// Usiamo withData: true fondamentale per avere i bytes e caricare su Supabase Storage
|
||||
FilePickerResult? result = await FilePicker.pickFiles(
|
||||
allowMultiple: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
AddOperationFilesEvent(result.files),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
OperationFilesBloc operationFilesBloc = BlocProvider.of<OperationFilesBloc>(
|
||||
context,
|
||||
);
|
||||
|
||||
return BlocListener<OperationsCubit, OperationsState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.currentOperation?.id == null &&
|
||||
current.currentOperation?.id != null,
|
||||
listener: (context, state) {
|
||||
// FIGASSA! La pratica è stata salvata e ora ha un ID.
|
||||
// Diciamo al Bloc dei file di agganciarsi al database.
|
||||
final newId = state.currentOperation!.id!;
|
||||
context.read<OperationFilesBloc>().add(OperationsavedEvent(newId));
|
||||
},
|
||||
child: BlocBuilder<OperationFilesBloc, OperationFilesState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER SEZIONE ---
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"DOCUMENTI ALLEGATI",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.attach_file),
|
||||
label: const Text("Aggiungi File"),
|
||||
onPressed: () => _pickFiles(context),
|
||||
),
|
||||
if (!context
|
||||
.read<SessionCubit>()
|
||||
.state
|
||||
.isMobileDevice) ...[
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _handleGenerateQr(context),
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text("GENERA QR"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// --- LISTA VUOTA ---
|
||||
if (state.allFiles.isEmpty) // Furbata: usiamo allFiles.isEmpty!
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade300,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade50,
|
||||
),
|
||||
child: const Text(
|
||||
"Nessun documento allegato alla bozza.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
// --- LISTA PIENA ---
|
||||
else ...[
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: state.allFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = state.allFiles[index];
|
||||
final sizeMb = (file.fileSize / (1024 * 1024))
|
||||
.toStringAsFixed(2);
|
||||
final isPdf = file.extension.toLowerCase() == 'pdf';
|
||||
final isSelected = state.selectedFiles.contains(file);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => operationFilesBloc.add(
|
||||
ToggleOperationFileSelectionEvent(file),
|
||||
),
|
||||
onDoubleTap: () => _handleDoubleClick(context, file),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 0,
|
||||
// UX Fina: cambiamo colore del bordo se selezionato
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
// UX Fina: Sfondo leggermente colorato se selezionato
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.05)
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isSelected
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(
|
||||
file.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
file.isLocal ? "$sizeMb MB • (Nuovo)" : " MB",
|
||||
),
|
||||
trailing: Icon(
|
||||
isPdf ? Icons.picture_as_pdf : Icons.image,
|
||||
color: isPdf ? Colors.red : Colors.blue,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// --- PANNELLO AZIONI CONTESTUALI (LA MAGIA) ---
|
||||
// Appare SOLO se c'è almeno un file selezionato
|
||||
if (state.selectedFiles.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Contatore
|
||||
Text(
|
||||
"${state.selectedFiles.length} file selezionati",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// Bottone Elimina
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text("Elimina"),
|
||||
onPressed: () {
|
||||
// Qui lancerai l'evento per eliminare i file selezionati!
|
||||
// Es: operationFilesBloc.add(DeleteSelectedFilesEvent());
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Bottone Copia
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text("Copia in Cliente"),
|
||||
onPressed: () {
|
||||
final cubit = context.read<OperationsCubit>();
|
||||
if (cubit.state.currentOperation?.customerId ==
|
||||
null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context
|
||||
.l10n
|
||||
.operationFormAttachmentSectionNoCustomer,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
LinkFilesToCustomerEvent(
|
||||
customerId: cubit
|
||||
.state
|
||||
.currentOperation!
|
||||
.customerId!,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleGenerateQr(BuildContext context) async {
|
||||
final cubit = context.read<OperationsCubit>();
|
||||
var currentOperation = cubit.state.currentOperation;
|
||||
|
||||
// 1. CATTURIAMO IL BLOC MENTRE SIAMO ANCORA NELLA PAGINA
|
||||
final operationFilesBloc = context.read<OperationFilesBloc>();
|
||||
|
||||
// 2. SE LA PRATICA E' NUOVA (Manca l'ID)
|
||||
if (currentOperation == null || currentOperation.id == null) {
|
||||
// NIENTE BlocListener qui! Solo un semplice Dialog di conferma
|
||||
final bool? confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Salvataggio Necessario"),
|
||||
content: const Text(
|
||||
"Per generare il QR Code e caricare file dal telefono, la pratica deve essere prima salvata in BOZZA.\n\nVuoi salvare ora?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text("Salva in Bozza"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return; // Utente ha annullato
|
||||
|
||||
// Salviamo forzatamente in bozza
|
||||
await cubit.saveCurrentOperation(
|
||||
targetStatus: OperationStatus.draft,
|
||||
shouldPop: false,
|
||||
);
|
||||
|
||||
// Recuperiamo il servizio aggiornato con l'ID!
|
||||
currentOperation = cubit.state.currentOperation;
|
||||
|
||||
if (currentOperation?.id == null) return;
|
||||
}
|
||||
|
||||
// 3. MOSTRIAMO IL QR CODE (Con il Ponte e l'Auto-Chiusura!)
|
||||
if (context.mounted) {
|
||||
final nomePratica =
|
||||
"Pratica ${currentOperation?.customerDisplayName ?? ''}".trim();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => BlocProvider.value(
|
||||
// INIETTIAMO IL BLOC NEL CONTESTO DEL DIALOG ALIENO
|
||||
value: operationFilesBloc,
|
||||
|
||||
// ORA METTIAMO L'AUTO-CHIUSURA SUL QR CODE!
|
||||
child: BlocListener<OperationFilesBloc, OperationFilesState>(
|
||||
listener: (context, state) {
|
||||
// Se arrivano file remoti e lo stato è success, chiudiamo il QR!
|
||||
// (Nota: usiamo dialogContext per assicurarci di chiudere il popup giusto)
|
||||
if (state.status == OperationFilesStatus.success &&
|
||||
state.remoteFiles.isNotEmpty) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
},
|
||||
child: QrUploadDialog(
|
||||
deepLinkUrl:
|
||||
'fluxapp:///operation/${currentOperation!.id}/upload?name=${Uri.encodeComponent(nomePratica)}',
|
||||
title: 'Scatta per\n$nomePratica',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- LOGICA DI VISUALIZZAZIONE OVERLAY ---
|
||||
void _handleDoubleClick(BuildContext context, AttachmentModel file) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) => Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: MediaQuery.of(context).size.height * 0.8,
|
||||
child: file.isPdf
|
||||
? PdfViewerWidget(
|
||||
storagePath: file.storagePath.isNotEmpty
|
||||
? file.storagePath
|
||||
: null,
|
||||
bytes: file.localBytes,
|
||||
)
|
||||
: ImageViewerWidget(
|
||||
storagePath: file.storagePath.isNotEmpty
|
||||
? file.storagePath
|
||||
: null,
|
||||
bytes: file.localBytes,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/features/customers/ui/customer_search_sheet.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
|
||||
class CustomerSection extends StatelessWidget {
|
||||
final OperationModel operation;
|
||||
|
||||
const CustomerSection({super.key, required this.operation});
|
||||
|
||||
void _openCustomerSearch(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(modalContext).viewInsets.bottom,
|
||||
),
|
||||
// La modale di ricerca
|
||||
child: const CustomerSearchSheet(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Niente BlocBuilder qui! Leggiamo solo la variabile 'operation'
|
||||
final hasCustomer = operation.customerId != null;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Dati Cliente",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (!hasCustomer)
|
||||
Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _openCustomerSearch(context),
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text("Seleziona o Crea Cliente"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
operation.customerDisplayName ?? "Cliente Selezionato",
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _openCustomerSearch(context),
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text("Cambia"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/operations/models/energy_operation_model.dart'; // Assicurati degli import
|
||||
|
||||
class EnergyOperationDialog extends StatefulWidget {
|
||||
final List<EnergyOperationModel> initialOperations;
|
||||
final String
|
||||
currentStoreId; // Ci serve per sapere per quale negozio caricare i gestori
|
||||
|
||||
const EnergyOperationDialog({
|
||||
super.key,
|
||||
required this.initialOperations,
|
||||
required this.currentStoreId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EnergyOperationDialog> createState() => _EnergyOperationDialogState();
|
||||
}
|
||||
|
||||
class _EnergyOperationDialogState extends State<EnergyOperationDialog> {
|
||||
// Lista temporanea per non "sporcare" il cubit finché non si preme Conferma
|
||||
late List<EnergyOperationModel> _tempList;
|
||||
bool _isAddingNew = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempList = List.from(widget.initialOperations);
|
||||
// Al caricamento della modale, chiediamo al Cubit di recuperare i gestori veri!
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
widget.currentStoreId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.bolt, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isAddingNew ? "Nuovo Contratto" : "Servizi Energia"),
|
||||
],
|
||||
),
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: SizedBox(
|
||||
width: double.maxFinite,
|
||||
// Cambia vista in base al flag
|
||||
child: _isAddingNew
|
||||
? _EnergyForm(
|
||||
onSave: (newOperation) {
|
||||
setState(() {
|
||||
_tempList.add(newOperation);
|
||||
_isAddingNew = false; // Torna alla lista
|
||||
});
|
||||
},
|
||||
onCancel: () {
|
||||
setState(() => _isAddingNew = false);
|
||||
},
|
||||
)
|
||||
: _EnergyList(
|
||||
operations: _tempList,
|
||||
onDelete: (index) {
|
||||
setState(() => _tempList.removeAt(index));
|
||||
},
|
||||
onAddTap: () {
|
||||
setState(() => _isAddingNew = true); // Passa al form
|
||||
},
|
||||
activeProviders: [
|
||||
// Passiamo i provider attivi filtrati per tipo Energia
|
||||
...context
|
||||
.read<ProvidersCubit>()
|
||||
.state
|
||||
.activeProviders
|
||||
.where((p) => p.energia == true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (!_isAddingNew) ...[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _tempList),
|
||||
child: const Text("Conferma Tutti"),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VISTA 1: LA LISTA DEI CONTRATTI
|
||||
// ==========================================
|
||||
class _EnergyList extends StatelessWidget {
|
||||
final List<EnergyOperationModel> operations;
|
||||
final List<ProviderModel>
|
||||
activeProviders; // <--- NUOVO: La lista vera dal Cubit
|
||||
final Function(int) onDelete;
|
||||
final VoidCallback onAddTap;
|
||||
|
||||
const _EnergyList({
|
||||
required this.operations,
|
||||
required this.activeProviders, // <--- Richiesto
|
||||
required this.onDelete,
|
||||
required this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (operations.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text(
|
||||
"Nessun contratto energia inserito.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: operations.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final s = operations[index];
|
||||
final isLuce = s.type == EnergyType.luce;
|
||||
|
||||
// LA MAGIA: Troviamo il nome partendo dall'ID salvato nel servizio
|
||||
final providerIndex = activeProviders.indexWhere(
|
||||
(p) => p.id == s.providerId,
|
||||
);
|
||||
final providerName = providerIndex >= 0
|
||||
? (activeProviders[providerIndex].nome)
|
||||
: 'Gestore Rimosso/Sconosciuto';
|
||||
|
||||
// Formattazione data pulita (es. 04/09/2025)
|
||||
final day = s.expiration.day.toString().padLeft(2, '0');
|
||||
final month = s.expiration.month.toString().padLeft(2, '0');
|
||||
final formattedDate = "$day/$month/${s.expiration.year}";
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isLuce
|
||||
? Colors.orange.shade100
|
||||
: Colors.blue.shade100,
|
||||
child: Icon(
|
||||
isLuce
|
||||
? Icons.lightbulb_outline
|
||||
: Icons.local_fire_department,
|
||||
color: isLuce ? Colors.orange : Colors.blue,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
providerName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text("Scadenza: $formattedDate"),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onDelete(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi Contratto"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VISTA 2: IL FORM DI INSERIMENTO
|
||||
// ==========================================
|
||||
class _EnergyForm extends StatefulWidget {
|
||||
final Function(EnergyOperationModel) onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _EnergyForm({required this.onSave, required this.onCancel});
|
||||
|
||||
@override
|
||||
State<_EnergyForm> createState() => _EnergyFormState();
|
||||
}
|
||||
|
||||
class _EnergyFormState extends State<_EnergyForm> {
|
||||
EnergyType _selectedType = EnergyType.luce;
|
||||
String? _selectedProviderId;
|
||||
DateTime? _selectedExpiration;
|
||||
int? _selectedMonthsPreset;
|
||||
|
||||
void _applyPreset(int? months) {
|
||||
if (months == null) return;
|
||||
setState(() {
|
||||
_selectedMonthsPreset = months;
|
||||
// Calcoliamo la data: oggi + X mesi
|
||||
final now = DateTime.now();
|
||||
_selectedExpiration = DateTime(now.year, now.month + months, now.day);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now().add(
|
||||
const Duration(days: 365),
|
||||
), // Default 1 anno
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _selectedExpiration = picked);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. Tipo (Luce o Gas) - Segmented Button stile M3
|
||||
SegmentedButton<EnergyType>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: EnergyType.luce,
|
||||
label: Text("Luce"),
|
||||
icon: Icon(Icons.lightbulb_outline),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: EnergyType.gas,
|
||||
label: Text("Gas"),
|
||||
icon: Icon(Icons.local_fire_department),
|
||||
),
|
||||
],
|
||||
selected: {_selectedType},
|
||||
onSelectionChanged: (Set<EnergyType> newSelection) {
|
||||
setState(() => _selectedType = newSelection.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 2. SCADENZA INTELLIGENTE (La parte PRO)
|
||||
const Text(
|
||||
"Scadenza Contratto",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
SegmentedButton<int?>(
|
||||
showSelectedIcon: false, // Per un look più pulito
|
||||
segments: const [
|
||||
ButtonSegment(value: 12, label: Text("12m")),
|
||||
ButtonSegment(value: 24, label: Text("24m")),
|
||||
ButtonSegment(value: 36, label: Text("36m")),
|
||||
ButtonSegment(
|
||||
value: null,
|
||||
label: Icon(Icons.calendar_month, size: 20),
|
||||
),
|
||||
],
|
||||
selected: {_selectedMonthsPreset},
|
||||
onSelectionChanged: (Set<int?> newSelection) {
|
||||
final val = newSelection.first;
|
||||
if (val == null) {
|
||||
_pickDate(); // Se clicca l'icona calendario, apre il picker
|
||||
} else {
|
||||
_applyPreset(val); // Altrimenti applica 12, 24 o 36
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Visualizzazione della data calcolata (o scelta)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _selectedExpiration != null
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event,
|
||||
size: 18,
|
||||
color: _selectedExpiration != null
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_selectedExpiration != null
|
||||
? "Scade il: ${_selectedExpiration!.day.toString().padLeft(2, '0')}/${_selectedExpiration!.month.toString().padLeft(2, '0')}/${_selectedExpiration!.year}"
|
||||
: "Seleziona una scadenza",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _selectedExpiration != null
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 2. Provider Dropdown
|
||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(
|
||||
child: LinearProgressIndicator(),
|
||||
); // Mostra una barretta di caricamento
|
||||
}
|
||||
|
||||
if (state.activeProviders.isEmpty) {
|
||||
return const Text(
|
||||
"Nessun gestore associato a questo negozio.",
|
||||
style: TextStyle(color: Colors.red),
|
||||
);
|
||||
}
|
||||
// Filtra solo i provider di tipo Energia (Se hai una categoria nel modello)
|
||||
// Se non hai una categoria nel ProviderModel, puoi rimuovere il .where
|
||||
final energyProviders = state.activeProviders;
|
||||
return DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Gestore / Provider",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
initialValue: _selectedProviderId,
|
||||
items: energyProviders.map((p) {
|
||||
return DropdownMenuItem(value: p.id, child: Text(p.nome));
|
||||
}).toList(),
|
||||
onChanged: (val) => setState(() => _selectedProviderId = val),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. Scadenza (DatePicker integrato in un TextField)
|
||||
TextFormField(
|
||||
readOnly: true,
|
||||
onTap: _pickDate,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Data Scadenza",
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: const Icon(Icons.calendar_month),
|
||||
),
|
||||
// Mostra la data se selezionata, altrimenti vuoto
|
||||
controller: TextEditingController(
|
||||
text: _selectedExpiration != null
|
||||
? "${_selectedExpiration!.day}/${_selectedExpiration!.month}/${_selectedExpiration!.year}"
|
||||
: "",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 4. Pulsanti Interni al Form
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text("Indietro"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
(_selectedProviderId == null || _selectedExpiration == null)
|
||||
? null // Disabilitato se mancano dati obbligatori
|
||||
: () {
|
||||
final newOperation = EnergyOperationModel(
|
||||
type: _selectedType,
|
||||
expiration: _selectedExpiration!,
|
||||
providerId: _selectedProviderId!,
|
||||
);
|
||||
widget.onSave(newOperation);
|
||||
},
|
||||
child: const Text("Salva Contratto"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||
import 'package:flux/features/operations/models/entertainment_operation_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class EntertainmentOperationDialog extends StatefulWidget {
|
||||
final List<EntertainmentOperationModel> initialOperations;
|
||||
final String currentStoreId;
|
||||
|
||||
const EntertainmentOperationDialog({
|
||||
super.key,
|
||||
required this.initialOperations,
|
||||
required this.currentStoreId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EntertainmentOperationDialog> createState() =>
|
||||
_EntertainmentOperationDialogState();
|
||||
}
|
||||
|
||||
class _EntertainmentOperationDialogState
|
||||
extends State<EntertainmentOperationDialog> {
|
||||
late List<EntertainmentOperationModel> _tempList;
|
||||
bool _isAddingNew = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempList = List.from(widget.initialOperations);
|
||||
// Carichiamo i provider attivi per lo store corrente
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
widget.currentStoreId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.movie_filter_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isAddingNew ? "Nuovo Servizio" : "Servizi Intrattenimento"),
|
||||
],
|
||||
),
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
child: _isAddingNew
|
||||
? _EntertainmentForm(
|
||||
// Il form che abbiamo creato prima
|
||||
onSave: (newOperation) => setState(() {
|
||||
_tempList.add(newOperation);
|
||||
_isAddingNew = false;
|
||||
}),
|
||||
onCancel: () => setState(() => _isAddingNew = false),
|
||||
)
|
||||
: BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
// Passiamo allProviders per garantire la visione dello storico
|
||||
return _EntertainmentList(
|
||||
operations: _tempList,
|
||||
allProviders: state.allProviders,
|
||||
onDelete: (index) =>
|
||||
setState(() => _tempList.removeAt(index)),
|
||||
onAddTap: () => setState(() => _isAddingNew = true),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: !_isAddingNew
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _tempList),
|
||||
child: const Text("Conferma Tutti"),
|
||||
),
|
||||
]
|
||||
: null, // I pulsanti del form sono interni al form stesso
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EntertainmentList extends StatelessWidget {
|
||||
final List<EntertainmentOperationModel> operations;
|
||||
final List<ProviderModel> allProviders;
|
||||
final Function(int) onDelete;
|
||||
final VoidCallback onAddTap;
|
||||
|
||||
const _EntertainmentList({
|
||||
required this.operations,
|
||||
required this.allProviders,
|
||||
required this.onDelete,
|
||||
required this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (operations.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text(
|
||||
"Nessun servizio intrattenimento.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: operations.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final s = operations[index];
|
||||
|
||||
final providerName = allProviders
|
||||
.firstWhere(
|
||||
(p) => p.id == s.providerId,
|
||||
orElse: () => ProviderModel(
|
||||
id: '',
|
||||
nome: 'Fornitore Storico',
|
||||
companyId: '',
|
||||
isActive: false,
|
||||
energia: false,
|
||||
telefoniaFissa: false,
|
||||
telefoniaMobile: false,
|
||||
assicurazioni: false,
|
||||
finanziamenti: false,
|
||||
altro: false,
|
||||
intrattenimento: false,
|
||||
),
|
||||
)
|
||||
.nome;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.purple.shade100,
|
||||
child: const Icon(
|
||||
Icons.movie_creation_outlined,
|
||||
color: Colors.purple,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
"${s.type} • $providerName",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
s.constrained
|
||||
? "Vincolo fino al: ${s.constrainExpiration.day}/${s.constrainExpiration.month}/${s.constrainExpiration.year}"
|
||||
: "Senza vincoli",
|
||||
style: TextStyle(
|
||||
color: s.constrained
|
||||
? Colors.red.shade700
|
||||
: Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onDelete(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi Servizio"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---ENTERTAINMENT FORM (MODALE)---
|
||||
|
||||
class _EntertainmentForm extends StatefulWidget {
|
||||
final Function(EntertainmentOperationModel) onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _EntertainmentForm({required this.onSave, required this.onCancel});
|
||||
|
||||
@override
|
||||
State<_EntertainmentForm> createState() => _EntertainmentFormState();
|
||||
}
|
||||
|
||||
class _EntertainmentFormState extends State<_EntertainmentForm> {
|
||||
String? _selectedProviderId;
|
||||
final TextEditingController _typeController = TextEditingController();
|
||||
bool _isConstrained = false;
|
||||
DateTime _expirationDate = DateTime.now().add(
|
||||
const Duration(days: 365),
|
||||
); // Default 12 mesi
|
||||
|
||||
// Preset rapidi per il vincolo (es: 12, 24 mesi)
|
||||
int? _selectedPresetMonths;
|
||||
|
||||
void _applyPreset(int months) {
|
||||
setState(() {
|
||||
_selectedPresetMonths = months;
|
||||
_isConstrained = true;
|
||||
final now = DateTime.now();
|
||||
_expirationDate = DateTime(now.year, now.month + months, now.day);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _expirationDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_expirationDate = picked;
|
||||
_selectedPresetMonths = null;
|
||||
_isConstrained = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. GESTORE (Filtro intrattenimento)
|
||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
final filtered = state.activeProviders
|
||||
.where((p) => p.intrattenimento)
|
||||
.toList();
|
||||
return DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Fornitore (es: Sky, TIM)",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: filtered
|
||||
.map(
|
||||
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) => setState(() => _selectedProviderId = val),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. TIPO SERVIZIO (TextField con suggerimenti rapidi sotto)
|
||||
TextFormField(
|
||||
controller: _typeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Servizio",
|
||||
hintText: "es: Netflix, DAZN, Disney+",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (val) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Suggerimenti rapidi (Chip)
|
||||
FutureBuilder<List<String>>(
|
||||
future: GetIt.I<OperationsRepository>().fetchTopEntertainmentTypes(
|
||||
GetIt.I<SessionCubit>().state.company!.id!,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final suggestions = snapshot.data ?? ["Netflix", "DAZN", "Sky"];
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: suggestions.map((s) {
|
||||
return ActionChip(
|
||||
label: Text(s, style: const TextStyle(fontSize: 12)),
|
||||
onPressed: () => setState(() => _typeController.text = s),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. VINCOLO CONTRATTUALE
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Vincolo di permanenza",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Switch(
|
||||
value: _isConstrained,
|
||||
onChanged: (val) => setState(() {
|
||||
_isConstrained = val;
|
||||
if (!val) _selectedPresetMonths = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_isConstrained) ...[
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<int?>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 12, label: Text("12m")),
|
||||
ButtonSegment(value: 24, label: Text("24m")),
|
||||
ButtonSegment(
|
||||
value: null,
|
||||
label: Icon(Icons.calendar_month, size: 20),
|
||||
),
|
||||
],
|
||||
selected: {_selectedPresetMonths},
|
||||
onSelectionChanged: (val) {
|
||||
if (val.first == null) {
|
||||
_pickDate();
|
||||
} else {
|
||||
_applyPreset(val.first!);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Box data scadenza vincolo
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.event_busy, size: 18, color: Colors.redAccent),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Scadenza vincolo: ${_expirationDate.day.toString().padLeft(2, '0')}/${_expirationDate.month.toString().padLeft(2, '0')}/${_expirationDate.year}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// PULSANTI
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
(_selectedProviderId == null || _typeController.text.isEmpty)
|
||||
? null
|
||||
: () => widget.onSave(
|
||||
EntertainmentOperationModel(
|
||||
providerId: _selectedProviderId!,
|
||||
type: _typeController.text,
|
||||
constrained: _isConstrained,
|
||||
constrainExpiration: _expirationDate,
|
||||
),
|
||||
),
|
||||
child: const Text("Aggiungi"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||
import 'package:flux/features/master_data/products/models/model_model.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/operations/models/fin_operation_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
|
||||
// ===========================================================================
|
||||
// DIALOG PRINCIPALE
|
||||
// ===========================================================================
|
||||
class FinanceOperationDialog extends StatefulWidget {
|
||||
final List<FinOperationModel> initialOperations;
|
||||
final String currentStoreId;
|
||||
final ProductCubit productCubit;
|
||||
|
||||
const FinanceOperationDialog({
|
||||
super.key,
|
||||
required this.initialOperations,
|
||||
required this.currentStoreId,
|
||||
required this.productCubit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FinanceOperationDialog> createState() => _FinanceOperationDialogState();
|
||||
}
|
||||
|
||||
class _FinanceOperationDialogState extends State<FinanceOperationDialog> {
|
||||
late List<FinOperationModel> _tempList;
|
||||
bool _isAddingNew = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempList = List.from(widget.initialOperations);
|
||||
// Carichiamo i dati necessari dai Cubit
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
widget.currentStoreId,
|
||||
);
|
||||
context.read<ProductCubit>().loadBrands();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: widget.productCubit,
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.payments_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isAddingNew ? "Dettagli Finanziamento" : "Finanziamenti"),
|
||||
],
|
||||
),
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
child: _isAddingNew
|
||||
? _FinanceForm(
|
||||
onSave: (newFin) => setState(() {
|
||||
_tempList.add(newFin);
|
||||
_isAddingNew = false;
|
||||
}),
|
||||
onCancel: () => setState(() => _isAddingNew = false),
|
||||
)
|
||||
: BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, provState) {
|
||||
return BlocBuilder<ProductCubit, ProductState>(
|
||||
builder: (context, prodState) {
|
||||
return _FinanceList(
|
||||
operations: _tempList,
|
||||
allProviders:
|
||||
provState.allProviders, // Per vedere lo storico
|
||||
allModels: prodState.models,
|
||||
onDelete: (index) =>
|
||||
setState(() => _tempList.removeAt(index)),
|
||||
onAddTap: () => setState(() => _isAddingNew = true),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: !_isAddingNew
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _tempList),
|
||||
child: const Text("Conferma"),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// VISTA LISTA (STORICA)
|
||||
// ===========================================================================
|
||||
class _FinanceList extends StatelessWidget {
|
||||
final List<FinOperationModel> operations;
|
||||
final List<ProviderModel> allProviders;
|
||||
final List<ModelModel> allModels;
|
||||
final Function(int) onDelete;
|
||||
final VoidCallback onAddTap;
|
||||
|
||||
const _FinanceList({
|
||||
required this.operations,
|
||||
required this.allProviders,
|
||||
required this.allModels,
|
||||
required this.onDelete,
|
||||
required this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (operations.isEmpty) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text(
|
||||
"Nessun finanziamento inserito.",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi primo"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: operations.length,
|
||||
separatorBuilder: (_, _) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final s = operations[index];
|
||||
|
||||
// Cerchiamo il nome del provider in TUTTI quelli caricati (storico)
|
||||
final providerName = allProviders
|
||||
.firstWhere(
|
||||
(p) => p.id == s.providerId,
|
||||
orElse: () => ProviderModel(
|
||||
id: '',
|
||||
nome: 'Operatore Storico',
|
||||
companyId: '',
|
||||
isActive: false,
|
||||
energia: false,
|
||||
telefoniaFissa: false,
|
||||
telefoniaMobile: false,
|
||||
assicurazioni: false,
|
||||
altro: false,
|
||||
intrattenimento: false,
|
||||
finanziamenti: false,
|
||||
),
|
||||
)
|
||||
.nome;
|
||||
|
||||
// Cerchiamo il nome del modello
|
||||
final modelName = allModels
|
||||
.firstWhere(
|
||||
(m) => m.id == s.modelId,
|
||||
orElse: () => ModelModel(
|
||||
id: '',
|
||||
name: 'Prodotto',
|
||||
nameWithBrand: 'Prodotto Storico',
|
||||
brandId: '',
|
||||
),
|
||||
)
|
||||
.nameWithBrand;
|
||||
|
||||
final dateStr =
|
||||
"${s.expiration.day.toString().padLeft(2, '0')}/${s.expiration.month.toString().padLeft(2, '0')}/${s.expiration.year}";
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
modelName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text("$providerName • Scade: $dateStr"),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onDelete(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi altro"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// FORM CON OMNI-SEARCH
|
||||
// ===========================================================================
|
||||
class _FinanceForm extends StatefulWidget {
|
||||
final Function(FinOperationModel) onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _FinanceForm({required this.onSave, required this.onCancel});
|
||||
|
||||
@override
|
||||
State<_FinanceForm> createState() => _FinanceFormState();
|
||||
}
|
||||
|
||||
class _FinanceFormState extends State<_FinanceForm> {
|
||||
String? _selectedProviderId;
|
||||
ModelModel? _selectedModel;
|
||||
int _selectedMonths = 30; // Default richiesto
|
||||
Timer? _debounce;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
late DateTime _selectedExpirationDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final now = DateTime.now();
|
||||
_selectedExpirationDate = DateTime(
|
||||
now.year,
|
||||
now.month + _selectedMonths,
|
||||
now.day,
|
||||
); // Inizialmente 30 mesi dalla data attuale
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||
context.read<ProductCubit>().searchModels(query);
|
||||
});
|
||||
}
|
||||
|
||||
// Funzione per aggiornare la data quando si clicca sui segmenti 24, 30, 48
|
||||
void _updateExpirationByMonths(int months) {
|
||||
setState(() {
|
||||
_selectedMonths = months;
|
||||
final now = DateTime.now();
|
||||
// Calcolo preciso: aggiungiamo i mesi alla data attuale
|
||||
_selectedExpirationDate = DateTime(now.year, now.month + months, now.day);
|
||||
});
|
||||
}
|
||||
|
||||
// Funzione per il picker manuale
|
||||
Future<void> _selectManualDate() async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedExpirationDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(
|
||||
const Duration(days: 365 * 10),
|
||||
), // Fino a 10 anni
|
||||
);
|
||||
if (picked != null && picked != _selectedExpirationDate) {
|
||||
setState(() {
|
||||
_selectedExpirationDate = picked;
|
||||
_selectedMonths = 0; // Resettiamo i segmenti perché è una data custom
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. SCELTA ISTITUTO (Solo attivi)
|
||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
final finProviders = state.activeProviders
|
||||
.where((p) => p.finanziamenti)
|
||||
.toList(); // Già filtrati dal caricamento della dialog
|
||||
return DropdownButtonFormField<String>(
|
||||
initialValue: _selectedProviderId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Gestore",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: finProviders
|
||||
.map(
|
||||
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) => setState(() => _selectedProviderId = val),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. RICERCA MODELLO
|
||||
if (_selectedModel == null) ...[
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Cerca modello (es: iPhone...)",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () => _showQuickCreate(context),
|
||||
),
|
||||
),
|
||||
onChanged: (val) {
|
||||
_onSearchChanged(val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSearchSuggestions(),
|
||||
] else
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.phone_android),
|
||||
title: Text(
|
||||
_selectedModel!.nameWithBrand,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => setState(() => _selectedModel = null),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. DURATA PRESET
|
||||
const Text(
|
||||
"Durata Rate",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 24, label: Text("24m")),
|
||||
ButtonSegment(value: 30, label: Text("30m")),
|
||||
ButtonSegment(value: 48, label: Text("48m")),
|
||||
],
|
||||
selected: {_selectedMonths},
|
||||
onSelectionChanged: (val) => _updateExpirationByMonths(val.first),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// RIEPILOGO DATA E PICKER MANUALE (Stile Energia)
|
||||
const Text(
|
||||
"Scadenza Finanziamento",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
InkWell(
|
||||
onTap: _selectManualDate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 18,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
"${_selectedExpirationDate.day.toString().padLeft(2, '0')}/${_selectedExpirationDate.month.toString().padLeft(2, '0')}/${_selectedExpirationDate.year}",
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Icon(Icons.edit, size: 18, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text("Indietro"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: (_selectedProviderId == null || _selectedModel == null)
|
||||
? null
|
||||
: () {
|
||||
final now = DateTime.now();
|
||||
widget.onSave(
|
||||
FinOperationModel(
|
||||
providerId: _selectedProviderId!,
|
||||
modelId: _selectedModel!.id!,
|
||||
expiration: DateTime(
|
||||
now.year,
|
||||
now.month + _selectedMonths,
|
||||
now.day,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text("Salva"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchSuggestions() {
|
||||
return BlocBuilder<ProductCubit, ProductState>(
|
||||
builder: (context, state) {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
if (query.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final filtered = state.models
|
||||
.where((m) => m.nameWithBrand.toLowerCase().contains(query))
|
||||
.take(3)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
children: filtered
|
||||
.map(
|
||||
(m) => ListTile(
|
||||
title: Text(m.nameWithBrand),
|
||||
onTap: () => setState(() => _selectedModel = m),
|
||||
dense: true,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showQuickCreate(BuildContext context) {
|
||||
// Implementazione rapida dialog creazione Brand/Modello come discusso prima
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
|
||||
class GeneralInfoSection extends StatelessWidget {
|
||||
final OperationModel operation;
|
||||
const GeneralInfoSection({super.key, required this.operation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Info Generali",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Numero di Riferimento / Telefono
|
||||
TextFormField(
|
||||
initialValue: operation.reference,
|
||||
keyboardType: TextInputType
|
||||
.phone, // Fa aprire il tastierino numerico su mobile
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Numero di Telefono / Riferimento",
|
||||
hintText: "Es. 3331234567",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.phone),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Campo Note
|
||||
TextFormField(
|
||||
initialValue: operation.note,
|
||||
maxLines: 4,
|
||||
minLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Note Operazione",
|
||||
hintText:
|
||||
"Scrivi qui eventuali dettagli o richieste del cliente...",
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import 'dart:async'; // Necessario per il Timer
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> updateCountDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
int currentValue,
|
||||
Function(int) onSave,
|
||||
) async {
|
||||
int tempValue =
|
||||
currentValue; // Variabile locale per gestire il conteggio nella dialog
|
||||
|
||||
final result = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text("Imposta $title"),
|
||||
content: QuickCounter(
|
||||
initialValue: tempValue,
|
||||
onChanged: (val) => tempValue =
|
||||
val, // Aggiorna il valore locale quando il counter cambia
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, tempValue),
|
||||
child: const Text("Conferma"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
onSave(result);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Widget Interno Specifico per il Counter Veloce ---
|
||||
class QuickCounter extends StatefulWidget {
|
||||
final int initialValue;
|
||||
final ValueChanged<int>
|
||||
onChanged; // Callback per notificare il padre dei cambiamenti
|
||||
|
||||
const QuickCounter({
|
||||
super.key,
|
||||
required this.initialValue,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QuickCounter> createState() => _QuickCounterState();
|
||||
}
|
||||
|
||||
class _QuickCounterState extends State<QuickCounter> {
|
||||
late int _value;
|
||||
Timer? _longPressTimer; // Il timer per l'auto-incremento
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_value = widget.initialValue;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_longPressTimer
|
||||
?.cancel(); // IMPORTANTE: Annulla sempre il timer alla distruzione
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Logica comune per incremento/decremento singolo o rapido
|
||||
void _update(int delta) {
|
||||
setState(() {
|
||||
_value += delta;
|
||||
if (_value < 0) _value = 0; // Impedisci numeri negativi
|
||||
});
|
||||
widget.onChanged(_value); // Notifica il padre
|
||||
}
|
||||
|
||||
// Gestione dell'inizio della pressione prolungata
|
||||
void _startLongPress(int delta) {
|
||||
_update(delta); // Esegui subito il primo aggiornamento al tocco iniziale
|
||||
_longPressTimer = Timer.periodic(const Duration(milliseconds: 100), (
|
||||
timer,
|
||||
) {
|
||||
_update(delta); // Aggiorna velocemente finché la pressione continua
|
||||
});
|
||||
}
|
||||
|
||||
// Gestione della fine della pressione prolungata
|
||||
void _stopLongPress() {
|
||||
_longPressTimer?.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canDecrement = _value > 0;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// --- Pulsante MENO ---
|
||||
GestureDetector(
|
||||
onLongPressStart: canDecrement ? (_) => _startLongPress(-1) : null,
|
||||
onLongPressEnd: (_) => _stopLongPress(),
|
||||
onLongPressCancel: () => _stopLongPress(),
|
||||
onTap: canDecrement ? () => _update(-1) : null,
|
||||
child: Opacity(
|
||||
// Visivamente disabilitato se < 0
|
||||
opacity: canDecrement ? 1.0 : 0.4,
|
||||
child: const ActionButton(icon: Icons.remove, color: Colors.red),
|
||||
),
|
||||
),
|
||||
|
||||
// --- Valore Centrale ---
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
_value.toString(),
|
||||
style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// --- Pulsante PIU' ---
|
||||
GestureDetector(
|
||||
onLongPressStart: (_) => _startLongPress(1),
|
||||
onLongPressEnd: (_) => _stopLongPress(),
|
||||
onLongPressCancel: () => _stopLongPress(),
|
||||
onTap: () => _update(1),
|
||||
child: const ActionButton(icon: Icons.add, color: Colors.green),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Piccolo widget di utilità per l'aspetto del pulsante
|
||||
class ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const ActionButton({super.key, required this.icon, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color, width: 2),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 30),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:flux/features/operations/ui/operation_form_screen/attachment_section.dart';
|
||||
import 'package:flux/features/operations/ui/operation_form_screen/customer_section.dart';
|
||||
import 'package:flux/features/operations/ui/operation_form_screen/general_info_section.dart';
|
||||
|
||||
class OperationFormScreen extends StatefulWidget {
|
||||
final String? operationId;
|
||||
final OperationModel? existingOperation; // <-- AGGIUNTO
|
||||
|
||||
const OperationFormScreen({
|
||||
super.key,
|
||||
this.operationId,
|
||||
this.existingOperation, // <-- AGGIUNTO
|
||||
});
|
||||
|
||||
@override
|
||||
State<OperationFormScreen> createState() => _OperationFormScreenState();
|
||||
}
|
||||
|
||||
class _OperationFormScreenState extends State<OperationFormScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Diamo in pasto al Cubit tutto quello che abbiamo!
|
||||
context.read<OperationsCubit>().initOperationForm(
|
||||
existingOperation: widget.existingOperation,
|
||||
operationId: widget.operationId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _performSave(
|
||||
BuildContext context, {
|
||||
required OperationStatus targetStatus,
|
||||
required bool shouldPop,
|
||||
}) {
|
||||
FocusScope.of(context).unfocus();
|
||||
context.read<OperationsCubit>().saveCurrentOperation(
|
||||
targetStatus: targetStatus,
|
||||
shouldPop: shouldPop,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<OperationsCubit, OperationsState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == OperationsStatus.saved) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Pratica salvata con successo!"),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
if (state.status == OperationsStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Errore: ${state.errorMessage ?? ''}"),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.status == OperationsStatus.savedNoPop) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Pratica salvata con successo!"),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final operation = state.currentOperation;
|
||||
final isSaving = state.status == OperationsStatus.saving;
|
||||
final isEditMode = widget.operationId != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(isEditMode ? "Modifica Pratica" : "Nuova Pratica"),
|
||||
actions: [
|
||||
if (isSaving)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 20.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (operation != null) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_note),
|
||||
tooltip: "Salva come Bozza",
|
||||
onPressed: () => _performSave(
|
||||
context,
|
||||
targetStatus: OperationStatus.draft,
|
||||
shouldPop: false,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: Colors.green,
|
||||
),
|
||||
tooltip: "Conferma Pratica",
|
||||
onPressed: () => _performSave(
|
||||
context,
|
||||
targetStatus: OperationStatus.ok,
|
||||
shouldPop: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: (operation == null)
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomerSection(operation: operation),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
GeneralInfoSection(operation: operation),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
AttachmentsSection(),
|
||||
const SizedBox(height: 32),
|
||||
_buildBottomActionButtons(context, isSaving: isSaving),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActionButtons(
|
||||
BuildContext context, {
|
||||
required bool isSaving,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
icon: const Icon(Icons.edit_note),
|
||||
label: const Text("Salva in Bozza"),
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => _performSave(
|
||||
context,
|
||||
targetStatus: OperationStatus.draft,
|
||||
shouldPop: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green.shade600,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text(
|
||||
"CONFERMA PRATICA",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1),
|
||||
),
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => _performSave(
|
||||
context,
|
||||
targetStatus: OperationStatus.ok,
|
||||
shouldPop: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
|
||||
|
||||
class OperationMobileUploadScreen extends StatefulWidget {
|
||||
final String operationId;
|
||||
@@ -16,7 +16,7 @@ import 'package:flux/core/data/core_repository.dart';
|
||||
import 'package:flux/core/routes/app_router.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/core/theme/bloc/theme_bloc.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||
import 'package:flux/features/customers/data/customer_repository.dart';
|
||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||
import 'package:flux/features/master_data/products/data/product_repository.dart';
|
||||
@@ -48,7 +48,7 @@ void main() async {
|
||||
|
||||
// Cubit delle feature
|
||||
BlocProvider<StoreCubit>(create: (_) => StoreCubit()),
|
||||
BlocProvider<CustomerCubit>(create: (_) => CustomerCubit()),
|
||||
BlocProvider<CustomersCubit>(create: (_) => CustomersCubit()),
|
||||
BlocProvider<ProductCubit>(create: (_) => ProductCubit()),
|
||||
BlocProvider<StaffCubit>(create: (_) => StaffCubit()),
|
||||
BlocProvider<OperationsCubit>(create: (_) => OperationsCubit()),
|
||||
|
||||
Reference in New Issue
Block a user