forse forse

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-24 14:20:31 +02:00
parent 1f0004a16e
commit a634e05052
3 changed files with 229 additions and 193 deletions

View File

@@ -131,9 +131,13 @@ class AppRouter {
// Recuperiamo l'ID se presente nell'URL // Recuperiamo l'ID se presente nell'URL
final serviceId = state.uri.queryParameters['serviceId']; final serviceId = state.uri.queryParameters['serviceId'];
return ServiceFormScreen( return BlocProvider(
serviceId: serviceId ?? existingService?.id, create: (context) =>
existingService: existingService, ServiceFilesBloc(serviceId: serviceId ?? existingService?.id),
child: ServiceFormScreen(
serviceId: serviceId ?? existingService?.id,
existingService: existingService,
),
); );
}, },
), ),

View File

@@ -36,17 +36,34 @@ class ServiceFilesBloc extends Bloc<ServiceFilesEvent, ServiceFilesState> {
ServiceSavedEvent event, ServiceSavedEvent event,
Emitter<ServiceFilesState> emit, Emitter<ServiceFilesState> emit,
) { ) {
emit(state.copyWith(serviceId: event.serviceId)); // 1. Aggiorniamo l'ID nello stato
// 2. PIALLIAMO i file locali: ormai sono partiti per Supabase!
// Così la UI si pulisce all'istante e aspetta quelli remoti.
emit(
state.copyWith(
serviceId: event.serviceId,
localFiles: [], // <-- LA MAGIA ANTI-DUPLICATI
),
);
// Lanciamo il caricamento
add(LoadServiceFilesEvent(serviceId: event.serviceId));
} }
FutureOr<void> _onLoadServiceFiles( FutureOr<void> _onLoadServiceFiles(
LoadServiceFilesEvent event, LoadServiceFilesEvent event,
Emitter<ServiceFilesState> emit, Emitter<ServiceFilesState> emit,
) async { ) async {
if (serviceId != null) { // Usiamo l'ID dell'evento, e se non c'è usiamo quello dello stato
final currentId = event.serviceId ?? state.serviceId;
if (currentId != null) {
emit(state.copyWith(status: ServiceFilesStatus.loading)); emit(state.copyWith(status: ServiceFilesStatus.loading));
await emit.forEach( await emit.forEach(
_repository.getServiceFilesStream(serviceId!), _repository.getServiceFilesStream(
currentId,
), // <-- Usiamo l'ID corretto!
onData: (data) => state.copyWith( onData: (data) => state.copyWith(
status: ServiceFilesStatus.success, status: ServiceFilesStatus.success,
remoteFiles: data, remoteFiles: data,

View File

@@ -5,6 +5,7 @@ import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/widgets/image_viewer_widget.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart'; import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart'; import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
import 'package:flux/features/services/blocs/service_files_bloc.dart'; import 'package:flux/features/services/blocs/service_files_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_file_model.dart'; import 'package:flux/features/services/models/service_file_model.dart';
@@ -32,207 +33,221 @@ class AttachmentsSection extends StatelessWidget {
context, context,
); );
return BlocBuilder<ServiceFilesBloc, ServiceFilesState>( return BlocListener<ServicesCubit, ServicesState>(
builder: (context, state) { listenWhen: (previous, current) =>
return Column( previous.currentService?.id == null &&
crossAxisAlignment: CrossAxisAlignment.start, current.currentService?.id != null,
children: [ listener: (context, state) {
// --- HEADER SEZIONE --- // FIGASSA! La pratica è stata salvata e ora ha un ID.
Row( // Diciamo al Bloc dei file di agganciarsi al database.
mainAxisAlignment: MainAxisAlignment.spaceBetween, final newId = state.currentService!.id!;
children: [ context.read<ServiceFilesBloc>().add(ServiceSavedEvent(newId));
Text( },
"DOCUMENTI ALLEGATI", child: BlocBuilder<ServiceFilesBloc, ServiceFilesState>(
style: TextStyle( builder: (context, state) {
fontWeight: FontWeight.bold, return Column(
color: Theme.of(context).colorScheme.primary, crossAxisAlignment: CrossAxisAlignment.start,
letterSpacing: 1.2, 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(
Row( children: [
children: [ OutlinedButton.icon(
OutlinedButton.icon( icon: const Icon(Icons.attach_file),
icon: const Icon(Icons.attach_file), label: const Text("Aggiungi File"),
label: const Text("Aggiungi File"), onPressed: () => _pickFiles(context),
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,
),
), ),
], if (!context
], .read<SessionCubit>()
), .state
], .isMobileDevice) ...[
), const SizedBox(width: 12),
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: () => serviceFilesBloc.add(
ToggleServiceFileSelectionEvent(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)" : "$sizeMb 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: serviceFilesBloc.add(DeleteSelectedFilesEvent());
},
),
const SizedBox(width: 8),
// Bottone Copia
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.copy), onPressed: () => _handleGenerateQr(context),
label: const Text("Copia in Cliente"), icon: const Icon(Icons.qr_code),
onPressed: () => saveAndCopyFilesToCustomer( label: const Text("GENERA QR"),
context, style: ElevatedButton.styleFrom(
state.selectedFiles, 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: () => serviceFilesBloc.add(
ToggleServiceFileSelectionEvent(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: serviceFilesBloc.add(DeleteSelectedFilesEvent());
},
),
const SizedBox(width: 8),
// Bottone Copia
ElevatedButton.icon(
icon: const Icon(Icons.copy),
label: const Text("Copia in Cliente"),
onPressed: () => saveAndCopyFilesToCustomer(
context,
state.selectedFiles,
),
),
],
),
), ),
), ),
), ],
], ],
], );
); },
}, ),
); );
} }
Future<void> _handleGenerateQr(BuildContext context) async { Future<void> _handleGenerateQr(BuildContext context) async {
final cubit = context.read<ServicesCubit>(); final cubit = context.read<ServicesCubit>();
var currentService = cubit.state.currentService; var currentService = cubit.state.currentService;
final Navigator = Navigator.of(context); final navigator = Navigator.of(context);
// 1. SE LA PRATICA E' NUOVA (Manca l'ID) // 1. SE LA PRATICA E' NUOVA (Manca l'ID)
if (currentService == null || currentService.id == null) { if (currentService == null || currentService.id == null) {
@@ -242,7 +257,7 @@ class AttachmentsSection extends StatelessWidget {
builder: (ctx) => BlocListener<ServiceFilesBloc, ServiceFilesState>( builder: (ctx) => BlocListener<ServiceFilesBloc, ServiceFilesState>(
listener: (context, state) { listener: (context, state) {
if (state.status == ServiceFilesStatus.success) { if (state.status == ServiceFilesStatus.success) {
Navigator.of.context(ctx).pop(); navigator.pop();
} }
}, },
child: AlertDialog( child: AlertDialog(