@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user