feat-tickets (#14)
Some checks failed
Deploy to Cloudflare Pages / build-and-deploy (push) Has been cancelled

Reviewed-on: #14
Co-authored-by: mark-cachy <marco@catelli.it>
Co-committed-by: mark-cachy <marco@catelli.it>
This commit is contained in:
2026-05-07 16:28:01 +02:00
committed by brontomark
parent 94ad524bae
commit 7d03d0dea5
38 changed files with 3594 additions and 1486 deletions

View File

@@ -0,0 +1,766 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/attachments/data/attachments_repository.dart';
import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart';
import 'package:flux/features/attachments/ui/quick_rename_dialog.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
class _ExportItem {
final Uint8List bytes;
final String sourceName;
final bool isMultiPage;
final int pageIndex;
_ExportItem({
required this.bytes,
required this.sourceName,
required this.isMultiPage,
required this.pageIndex,
});
}
class SharedAttachmentsSection extends StatefulWidget {
final String? parentId;
final String customerDisplayName;
final AttachmentParentType parentType;
const SharedAttachmentsSection({
super.key,
this.parentId,
this.customerDisplayName = 'Cliente_sconosciuto',
required this.parentType,
});
@override
State<SharedAttachmentsSection> createState() =>
_SharedAttachmentsSectionState();
}
class _SharedAttachmentsSectionState extends State<SharedAttachmentsSection> {
String? _exportDirectory;
@override
void initState() {
super.initState();
_loadExportDirectory();
}
// --- GESTIONE CARTELLA CITRIX (SOLO DESKTOP) ---
Future<void> _loadExportDirectory() async {
if (kIsWeb) return;
final prefs = await SharedPreferences.getInstance();
setState(() {
_exportDirectory = prefs.getString('citrix_export_path');
});
}
Future<void> _selectExportDirectory() async {
final String? selectedDirectory = await FilePicker.getDirectoryPath(
dialogTitle: 'Seleziona la cartella di esportazione per TIM/Citrix',
);
if (selectedDirectory != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('citrix_export_path', selectedDirectory);
setState(() {
_exportDirectory = selectedDirectory;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cartella Export impostata: $selectedDirectory'),
),
);
}
}
}
// --- SELEZIONE FILE DAL PC/TELEFONO ---
Future<void> _pickFiles() async {
final result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'],
withData: true,
);
if (result != null && mounted) {
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
context.read<AttachmentsBloc>().add(AddAttachmentsEvent(result.files));
}
}
// --- APERTURA VIEWER ---
void _openFile(AttachmentModel file) {
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
final operationFilesBloc = context.read<AttachmentsBloc>();
Navigator.push(
context,
MaterialPageRoute(
builder: (viewerContext) => BlocProvider.value(
value: operationFilesBloc,
child: AttachmentViewerScreen(
attachment: file,
onRename: (newName) {
// Spara l'evento al BLoC e lui farà il resto!
operationFilesBloc.add(RenameAttachmentEvent(file, newName));
},
onDelete: () {
operationFilesBloc.add(DeleteSpecificAttachmentEvent(file));
},
),
),
),
);
}
Future<void> _exportMergedPdf(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" DI TUTTO (Stessa magia di prima)
List<Uint8List> allPagesAsImages = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!,
);
}
if (fileBytes == null) continue;
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
allPagesAsImages.add(pageImage.bytes);
}
await page.close();
}
await document.close();
} else {
// È un'immagine
allPagesAsImages.add(fileBytes);
}
}
if (mounted) Navigator.pop(context); // Togliamo il loading
// Se per qualche motivo la lista è vuota, usciamo
if (allPagesAsImages.isEmpty) return;
// 2. LOGICA DEL NOME SUGGERITO
String suggestedName;
if (selectedFiles.length == 1) {
// Se c'è un solo file (es. ho selezionato 3 foto ma poi ho deselezionato le altre)
suggestedName = selectedFiles.first.name;
} else {
// Se sono più file uniti
suggestedName = '${widget.customerDisplayName}_Unito';
}
if (!mounted) return;
// 3. DIALOG DI CONFERMA (Mostriamo la PRIMA pagina come anteprima per fargli capire cos'è)
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(
allPagesAsImages.first,
fit: BoxFit.contain,
),
),
);
if (finalName == null || finalName.isEmpty) return; // Ha annullato
// 4. CREAZIONE DEL PDF UNICO (IL MERGE VERO E PROPRIO)
final pdf = pw.Document();
// Cicliamo su tutte le immagini estratte e creiamo una pagina per ognuna
for (var imageBytes in allPagesAsImages) {
final pdfImage = pw.MemoryImage(imageBytes);
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
}
final mergedPdfBytes = await pdf.save();
// 5. SALVATAGGIO SUL DISCO
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(mergedPdfBytes);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF Multi-pagina creato e salvato con successo!'),
),
);
}
} catch (e) {
if (mounted) {
// Se il loading è ancora aperto, lo chiudiamo
if (Navigator.canPop(context)) Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore durante l\'unione: $e')));
}
}
}
Future<void> _exportSplitPdfs(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" INTELLIGENTE (Ora usiamo la nostra classe _ExportItem)
List<_ExportItem> itemsToExport = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!,
);
}
if (fileBytes == null) continue;
// Recuperiamo il nome che l'utente ha (magari) già impostato
final baseName = file.name;
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
final isMulti =
document.pagesCount > 1; // Controlliamo se è multipagina!
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
// Salviamo l'immagine CON il suo contesto storico
itemsToExport.add(
_ExportItem(
bytes: pageImage.bytes,
sourceName: baseName,
isMultiPage: isMulti,
pageIndex: i,
),
);
}
await page.close();
}
await document.close();
} else {
// SE È UN'IMMAGINE, la salviamo come singola pagina
itemsToExport.add(
_ExportItem(
bytes: fileBytes,
sourceName: baseName,
isMultiPage: false,
pageIndex: 1,
),
);
}
}
if (mounted) Navigator.pop(context);
// 2. IL CICLO UX
for (var item in itemsToExport) {
if (!mounted) return;
// LA TUA MAGIA UX SUI NOMI:
// Se è singolo (foto o PDF da 1 pag) -> Usa il nome originale nudo e crudo!
// Se è multipagina -> Usa il nome originale + il numero di pagina
String suggestedName = item.sourceName;
if (item.isMultiPage) {
suggestedName = '${item.sourceName}_Pag_${item.pageIndex}';
}
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(item.bytes, fit: BoxFit.contain),
),
);
if (finalName == null || finalName.isEmpty) continue;
// CREAZIONE DEL PDF SINGOLO
final pdf = pw.Document();
final pdfImage = pw.MemoryImage(item.bytes); // Usiamo item.bytes!
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
final singlePdfBytes = await pdf.save();
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(singlePdfBytes);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Esportazione completata con successo!'),
),
);
}
} catch (e) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore: $e')));
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// USIAMO IL TUO BLOC!
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
final allFiles = state.allFiles;
final selectedFiles = state.selectedFiles;
final hasSelection = selectedFiles.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. SETTINGS CARTELLA (Solo visibile su Desktop)
if (!kIsWeb)
Card(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
elevation: 0,
margin: const EdgeInsets.only(bottom: 16),
child: ListTile(
leading: Icon(
Icons.folder_special,
color: theme.colorScheme.primary,
),
title: const Text(
'Cartella Export (Es. Citrix TIM)',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
_exportDirectory ??
'Nessuna cartella selezionata. Clicca per impostare.',
style: TextStyle(
color: _exportDirectory == null
? theme.colorScheme.error
: null,
),
),
trailing: const Icon(Icons.settings),
onTap: _selectExportDirectory,
),
),
// 2. ACTION BAR DINAMICA
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
// Bottone di Aggiunta
ElevatedButton.icon(
icon: const Icon(Icons.add_photo_alternate),
label: const Text('Aggiungi File'),
onPressed: state.status == AttachmentsStatus.uploading
? null
: _pickFiles,
),
const SizedBox(width: 12),
// NUOVO: SELEZIONA / DESELEZIONA TUTTO
if (allFiles.isNotEmpty) ...[
TextButton.icon(
icon: Icon(
selectedFiles.length == allFiles.length
? Icons.deselect
: Icons.select_all,
),
label: Text(
selectedFiles.length == allFiles.length
? 'Deseleziona Tutto'
: 'Seleziona Tutto',
),
onPressed: () {
if (selectedFiles.length == allFiles.length) {
context.read<AttachmentsBloc>().add(
ClearAttachmentSelectionEvent(),
);
} else {
context.read<AttachmentsBloc>().add(
SelectAllAttachmentsEvent(),
);
}
},
),
],
const SizedBox(width: 12),
// Loader di upload
if (state.status == AttachmentsStatus.uploading)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const Spacer(),
// Azioni visibili SOLO se c'è una selezione!
if (hasSelection) ...[
// Bottone Elimina
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Elimina selezionati',
onPressed: () {
context.read<AttachmentsBloc>().add(
DeleteAttachmentsEvent(),
);
},
),
// Bottone Associa a Cliente
if (widget.parentId != null && widget.parentId != '')
IconButton(
icon: const Icon(Icons.person_add, color: Colors.blue),
tooltip: 'Copia nei documenti del Cliente',
onPressed: () {
context.read<AttachmentsBloc>().add(
LinkAttachmentsToEntityEvent(
targetId: widget.parentId!,
targetType: AttachmentParentType.customer,
),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('File copiati nella scheda cliente!'),
),
);
},
),
// IL NUOVO BOTTONE ESPORTA CON MENU A TENDINA
PopupMenuButton<String>(
tooltip: 'Opzioni di esportazione',
position: PopupMenuPosition
.under, // Opzionale: fa aprire il menu sotto al bottone
onSelected: (value) {
if (value == 'merge') {
_exportMergedPdf(selectedFiles);
} else if (value == 'split') {
_exportSplitPdfs(selectedFiles);
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: 'merge',
child: ListTile(
leading: Icon(
Icons.merge_type,
color: Colors.blue,
),
title: Text('Unisci in un singolo PDF'),
),
),
const PopupMenuItem<String>(
value: 'split',
child: ListTile(
leading: Icon(
Icons.splitscreen,
color: Colors.orange,
),
title: Text(
'Dividi: un PDF per ogni pagina/foto',
),
),
),
],
// IL FIX È QUI SOTTO: AbsorbPointer + onPressed vuoto
child: AbsorbPointer(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
icon: const Icon(Icons.picture_as_pdf),
label: Text('Esporta (${selectedFiles.length})'),
onPressed: () {}, // Manteniamo vivo il colore!
),
),
),
],
],
),
const SizedBox(height: 16),
// 3. GRIGLIA DEI FILE
if (allFiles.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
border: Border.all(
color: theme.dividerColor,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.upload_file, size: 48, color: Colors.grey),
SizedBox(height: 8),
Text(
'Nessun file allegato. Usa il pulsante per aggiungere documenti o foto.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: allFiles.length,
itemBuilder: (context, index) {
final file = allFiles[index];
final isPdf = file.extension == 'pdf';
final isSelected = selectedFiles.contains(file);
final isLocal =
file.localBytes !=
null; // Per capire se è un file in bozza
return Stack(
children: [
// CARD DEL FILE
InkWell(
onTap: () => _openFile(file),
onLongPress: () {
// Selezione rapida con long press!
context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: isSelected ? 3 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Anteprima
Expanded(
child: Container(
decoration: BoxDecoration(
color: theme
.colorScheme
.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
),
),
child: isPdf
? const Icon(
Icons.picture_as_pdf,
size: 48,
color: Colors.red,
)
: isLocal
? ClipRRect(
borderRadius:
const BorderRadius.vertical(
top: Radius.circular(8),
),
child: Image.memory(
file.localBytes!,
fit: BoxFit.cover,
),
)
: const Icon(
Icons.image,
size: 48,
color: Colors.blue,
), // Da remoto metterai il tuo NetworkImage se vuoi
),
),
// Nome File
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
file.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
),
),
// CHECKBOX DI SELEZIONE
Positioned(
top: 4,
right: 4,
child: InkWell(
onTap: () {
context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
);
},
child: Container(
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: Colors.white.withValues(alpha: 0.8),
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary,
),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Icon(
isSelected ? Icons.check : Icons.circle,
size: 16,
color: isSelected
? Colors.white
: Colors.transparent,
),
),
),
),
),
// BADGE "IN ATTESA" (Se è locale ma la pratica è salvata)
if (isLocal)
Positioned(
top: 4,
left: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Bozza',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
},
),
],
);
},
);
}
}

View File

@@ -0,0 +1,213 @@
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/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
class SharedCustomerSection extends StatelessWidget {
final String? customerId;
final String? customerName;
final ValueChanged<CustomerModel> onCustomerSelected;
const SharedCustomerSection({
super.key,
this.customerId,
this.customerName,
required this.onCustomerSelected,
});
@override
Widget build(BuildContext context) {
final hasCustomer = customerId != null && customerId!.isNotEmpty;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Cliente',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
InkWell(
onTap: () => _showCustomerModal(context), // Passiamo il context!
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.primary),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.2),
),
child: Row(
children: [
const Icon(Icons.person),
const SizedBox(width: 12),
Expanded(
child: Text(
hasCustomer ? customerName! : 'Seleziona Cliente *',
style: TextStyle(
fontWeight: hasCustomer
? FontWeight.bold
: FontWeight.normal,
color: hasCustomer ? null : Colors.grey,
),
),
),
const Icon(Icons.search),
],
),
),
),
],
);
}
// --- MODALE SELEZIONE CLIENTE ---
void _showCustomerModal(BuildContext context) {
String currentSearchQuery = '';
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(
autofocus: true,
decoration: InputDecoration(
hintText: 'Cerca per nome, telefono o email...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) {
currentSearchQuery = query;
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: () async {
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
final newCustomer = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<CustomersCubit>(),
child: QuickCustomerDialog(
initialQuery:
currentSearchQuery, // <-- Passiamo quello che ha digitato!
),
);
},
);
// Se l'ha creato davvero (e non ha premuto annulla)...
if (newCustomer != null) {
// 1. Aggiorniamo il form delle operazioni
onCustomerSelected(newCustomer);
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
if (context.mounted) {
Navigator.pop(modalContext);
}
}
},
),
),
const Divider(),
// Lista Clienti dal Bloc
Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>(
builder: (context, state) {
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: state.customers.length,
itemBuilder: (context, index) {
final customer = state.customers[index];
return ListTile(
leading: CircleAvatar(
child: Text(
customer.name.substring(0, 1).toUpperCase(),
),
),
title: Text(
customer.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'${customer.phoneNumber}${customer.email}',
),
onTap: () {
onCustomerSelected(customer);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,310 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
class SharedMobileUploadScreen extends StatefulWidget {
final String title;
const SharedMobileUploadScreen({super.key, required this.title});
@override
State<SharedMobileUploadScreen> createState() =>
_SharedMobileUploadScreenState();
}
class _SharedMobileUploadScreenState extends State<SharedMobileUploadScreen> {
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
final List<PlatformFile> _stagedFiles = [];
// 2. STATO DI CARICAMENTO GLOBALE
bool _isUploading = false;
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
bool _isImage(String path) {
final ext = path.split('.').last.toLowerCase();
return ['jpg', 'jpeg', 'png', 'webp'].contains(ext);
}
@override
Widget build(BuildContext context) {
return BlocListener<AttachmentsBloc, AttachmentsState>(
listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == AttachmentsStatus.success && _isUploading) {
// CONTROLLO MAGICO: C'è una pagina dietro di noi?
if (Navigator.of(context).canPop()) {
// Modalità "App Nativa": siamo entrati dal tasto "Aggiungi"
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("File caricati con successo! ✅")),
);
Navigator.of(context).pop();
} else {
// Modalità "Web/QR Code": Navighiamo alla pagina di successo!
// Assicurati di aver importato go_router in questo file
context.go('/upload-success');
}
}
if (state.status == AttachmentsStatus.failure) {
setState(() => _isUploading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Errore: ${state.error}")));
}
},
child: Scaffold(
appBar: AppBar(
title: Text("Upload: ${widget.title}"),
// Togliamo la freccia indietro se stiamo caricando per evitare disastri
automaticallyImplyLeading: !_isUploading,
),
body: Stack(
children: [
Column(
children: [
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isUploading ? null : _handleCamera,
icon: const Icon(Icons.camera_alt),
label: const Text("SCATTA"),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isUploading ? null : _handleFilePicker,
icon: const Icon(Icons.folder),
label: const Text("GALLERIA"),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
),
const Divider(),
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
Expanded(
child: _stagedFiles.isEmpty
? const Center(
child: Text(
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 3 colonne stile galleria
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _stagedFiles.length,
itemBuilder: (context, index) {
final file = _stagedFiles[index];
final isImg = _isImage(file.name);
return Stack(
clipBehavior: Clip.none,
children: [
// L'ANTEPRIMA
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: isImg
? (file.bytes != null
// Se abbiamo i bytes (es. scatto da fotocamera) usiamo quelli (a prova di Web!)
? Image.memory(
file.bytes!,
fit: BoxFit.cover,
)
// Altrimenti andiamo di file fisico
: Image.file(
File(file.path!),
fit: BoxFit.cover,
))
: const Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.picture_as_pdf,
color: Colors.red,
size: 36,
),
SizedBox(height: 4),
Text(
"PDF",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
// IL PULSANTE CESTINO (In alto a destra)
Positioned(
top: -8,
right: -8,
child: GestureDetector(
onTap: () {
setState(() {
_stagedFiles.removeAt(index);
});
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
);
},
),
),
// --- SEZIONE INVIA E CHIUDI ---
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
// Il pulsante si accende SOLO se ci sono file nel carrello
onPressed: _stagedFiles.isEmpty || _isUploading
? null
: _submitAllFiles,
icon: const Icon(Icons.cloud_upload),
label: Text(
"INVIA ${_stagedFiles.length} FILE E CHIUDI",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
),
),
],
),
// --- OVERLAY DI CARICAMENTO ---
if (_isUploading)
Container(
// Usa il metodo non deprecato che hai giustamente suggerito!
color: Colors.black.withValues(alpha: 0.5),
child: const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
"Caricamento in corso...",
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
),
),
),
],
),
),
);
}
// --- LOGICA FOTOCAMERA E LIBRERIA ---
Future<void> _handleCamera() async {
final picker = ImagePicker();
final photo = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
if (photo != null) {
final photoBytes = await photo.readAsBytes();
final photoSize = await photo.length();
final platformFile = PlatformFile(
name: photo.name,
size: photoSize,
path: photo.path,
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
);
setState(() {
_stagedFiles.add(platformFile);
});
}
}
Future<void> _handleFilePicker() async {
final result = await FilePicker.pickFiles(allowMultiple: true);
if (result != null) {
setState(() {
_stagedFiles.addAll(result.files);
});
}
}
// --- LOGICA DI INVIO AL BLoC ---
void _submitAllFiles() {
setState(() => _isUploading = true);
// Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico!
context.read<AttachmentsBloc>().add(
UploadAttachmentsEvent(pickedFiles: _stagedFiles),
);
}
}

View File

@@ -0,0 +1,165 @@
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/ui/quick_product_dialog.dart';
class SharedModelSection extends StatelessWidget {
final String? modelId;
final String? modelName;
final String label;
// Usiamo una callback che passa direttamente ID e Nome
// così non dobbiamo preoccuparci di importare la classe esatta del modello ovunque
final void Function(String id, String name) onModelSelected;
const SharedModelSection({
super.key,
required this.modelId,
required this.modelName,
required this.onModelSelected,
this.label = 'Seleziona Modello',
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasModel = modelId != null && modelId!.isNotEmpty;
return ListTile(
title: Text(label),
subtitle: Text(
hasModel ? modelName! : 'Nessun modello selezionato',
style: TextStyle(
color: hasModel ? null : Colors.grey,
fontWeight: hasModel ? FontWeight.bold : FontWeight.normal,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showModelModal(context),
);
}
void _showModelModal(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Modello',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
decoration: InputDecoration(
hintText: 'Cerca modello (es. iPhone 15...)',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) =>
context.read<ProductsCubit>().searchModels(query),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.add),
label: const Text('Aggiungi Modello al Volo'),
onPressed: () async {
// Leggiamo i brand dal Cubit per passarli alla dialog
final existingBrands = context
.read<ProductsCubit>()
.state
.brands;
final newModel = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<ProductsCubit>(),
child: QuickProductDialog(
existingBrands: existingBrands,
),
);
},
);
if (newModel != null) {
// CHIAMIAMO LA CALLBACK!
onModelSelected(newModel.id, newModel.nameWithBrand);
if (context.mounted) Navigator.pop(modalContext);
}
},
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProductsCubit, ProductState>(
builder: (context, state) {
return ListView.builder(
controller: scrollController,
itemCount: state.models.length,
itemBuilder: (context, index) {
final deviceModel = state.models[index];
return ListTile(
leading: const Icon(Icons.devices),
title: Text(
deviceModel.nameWithBrand,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
// CHIAMIAMO LA CALLBACK!
onModelSelected(
deviceModel.id!,
deviceModel.nameWithBrand,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,230 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/core/widgets/shared_forms/mobile_upload_screen.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
class SharedFilesSection extends StatelessWidget {
final String titleNameForUpload;
// LA NOSTRA CALLBACK MAGICA
final Future<String?> Function()? onGenerateIdForQr;
const SharedFilesSection({
super.key,
required this.titleNameForUpload,
this.onGenerateIdForQr,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Allegati e Foto',
style: TextStyle(fontWeight: FontWeight.bold),
),
BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
return Row(
children: [
// --- IL TASTO QR CODE (Ora sempre attivo!) ---
Tooltip(
message: 'Carica foto con lo smartphone',
child: IconButton(
icon: const Icon(Icons.qr_code_scanner),
color: theme.colorScheme.primary, // Sempre colorato!
onPressed: () async {
String? targetId = state.parentId;
// SE L'ID NON C'È, CHIAMIAMO IL SALVATAGGIO IN BACKGROUND!
if (targetId == null) {
if (onGenerateIdForQr != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Salvataggio rapido scheda in corso... ⏳',
),
duration: Duration(seconds: 1),
),
);
// Aspettiamo che il TicketFormCubit faccia il suo lavoro
targetId = await onGenerateIdForQr!();
}
// Se fallisce (es. validazione form non passata), ci fermiamo
if (targetId == null) return;
}
// GENERAZIONE DEL DEEP LINK AGNOSTICO
final deepLink =
'flux://app/upload/${state.parentType.name}/$targetId';
if (context.mounted) {
showDialog(
context: context,
builder: (_) => QrUploadDialog(
deepLinkUrl: deepLink,
title: 'Carica File: $titleNameForUpload',
),
);
}
},
),
),
const SizedBox(width: 8),
// --- IL TASTO AGGIUNGI CLASSICO (da PC) ---
TextButton.icon(
icon: const Icon(Icons.add_a_photo),
label: const Text('Aggiungi'),
onPressed: () {
final bloc = context.read<AttachmentsBloc>();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: bloc,
child: SharedMobileUploadScreen(
title: titleNameForUpload,
),
),
),
);
},
),
],
);
},
),
],
),
const SizedBox(height: 8),
// --- LA VETRINA DEI FILE (Identica a prima) ---
BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
final files = state.allFiles;
if (state.status == AttachmentsStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (files.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border.all(
color: theme.dividerColor,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(12),
),
child: const Column(
children: [
Icon(
Icons.image_not_supported_outlined,
color: Colors.grey,
size: 32,
),
SizedBox(height: 8),
Text(
'Nessun file allegato',
style: TextStyle(color: Colors.grey),
),
],
),
);
}
return Wrap(
spacing: 12,
runSpacing: 12,
children: files.map((file) {
final isImage = [
'jpg',
'jpeg',
'png',
'webp',
].contains(file.extension.toLowerCase());
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor),
),
child: Stack(
children: [
Center(
child: isImage
? const Icon(
Icons.image,
color: Colors.blue,
size: 40,
)
: const Icon(
Icons.picture_as_pdf,
color: Colors.red,
size: 40,
),
),
if (file.id == null)
Positioned(
bottom: 4,
left: 4,
right: 4,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Da salvare',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
),
Positioned(
top: -8,
right: -8,
child: IconButton(
icon: const Icon(
Icons.cancel,
color: Colors.redAccent,
size: 20,
),
onPressed: () {
context.read<AttachmentsBloc>().add(
DeleteSpecificAttachmentEvent(file),
);
},
),
),
],
),
);
}).toList(),
);
},
),
],
);
}
}

View File

@@ -0,0 +1,156 @@
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/staff/blocs/staff_cubit.dart';
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:get_it/get_it.dart';
class StaffSection extends StatelessWidget {
final String? label;
final String? staffId;
final String? staffName;
final ValueChanged<StaffMemberModel> onStaffSelected;
const StaffSection({
super.key,
required this.onStaffSelected,
this.label,
this.staffId,
this.staffName,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Se staffId è nullo, proviamo a preselezionare l'utente loggato
final selectedStaffId =
staffId ?? GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
label ??
'Operatore', // <-- FIX: Ora usa l'etichetta passata dal form!
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
// FIX: Aggiunto un controllo se sta caricando
if (state.status == StaffStatus.loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final staffMembers = state.storeStaff;
// FIX: Feedback visivo se la lista è vuota
if (staffMembers.isEmpty) {
return const Text(
'Nessun operatore caricato. Controlla il Cubit!',
style: TextStyle(color: Colors.red),
);
}
final currentLoggedStaffMember = GetIt.I
.get<SessionCubit>()
.state
.currentStaffMember;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: staffMembers.map((staff) {
final isSelected = staff.id == selectedStaffId;
return GestureDetector(
onTap: () {
onStaffSelected(staff);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 12.0),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(
alpha: 0.3,
),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 12,
backgroundColor: isSelected
? Colors.white
: theme.colorScheme.primaryContainer,
child: Text(
staff.name.substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 8),
Text(
staff == currentLoggedStaffMember
? 'Tu (${staff.name})'
: staff.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w500,
color: isSelected
? Colors.white
: theme.colorScheme.onSurface,
),
),
],
),
),
);
}).toList(),
),
);
},
),
],
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class UploadSuccessScreen extends StatelessWidget {
const UploadSuccessScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.green.shade50,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.green.withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: const Icon(Icons.check, size: 80, color: Colors.white),
),
const SizedBox(height: 32),
const Text(
"Upload Completato!",
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 16),
const Text(
"I file sono stati caricati con successo sulla pratica.\nPuoi chiudere questa pagina o finestra del browser.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.black54),
),
],
),
),
),
);
}
}