Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-04 12:50:00 +02:00
parent 212f33ff51
commit 68b075f0b1
15 changed files with 1345 additions and 15 deletions

View File

@@ -0,0 +1,23 @@
import 'dart:typed_data';
import 'package:supabase_flutter/supabase_flutter.dart';
class AttachmentsRepository {
final _supabase = Supabase.instance.client;
/// Scarica i byte di un file direttamente da Supabase Storage
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
try {
// ATTENZIONE: Sostituisci 'attachments' con il nome VERO del tuo bucket su Supabase!
// Se il tuo storagePath contiene già il nome del bucket all'inizio,
// assicurati di passargli solo il percorso interno.
final Uint8List bytes = await _supabase.storage
.from('attachments') // <--- NOME DEL TUO BUCKET
.download(storagePath);
return bytes;
} catch (e) {
throw Exception("Impossibile scaricare il documento dal cloud: $e");
}
}
}

View File

@@ -9,7 +9,7 @@ class AttachmentModel extends Equatable {
final String? operationId; final String? operationId;
final String name; final String name;
final String extension; final String extension;
final String storagePath; final String? storagePath;
final int fileSize; final int fileSize;
final Uint8List? localBytes; final Uint8List? localBytes;
final String companyId; final String companyId;
@@ -21,7 +21,7 @@ class AttachmentModel extends Equatable {
this.operationId, this.operationId,
required this.name, required this.name,
required this.extension, required this.extension,
required this.storagePath, this.storagePath,
required this.fileSize, required this.fileSize,
this.localBytes, this.localBytes,
required this.companyId, required this.companyId,
@@ -88,7 +88,7 @@ class AttachmentModel extends Equatable {
operationId: map['operation_id'] as String?, operationId: map['operation_id'] as String?,
name: map['name'] as String, name: map['name'] as String,
extension: map['extension'] as String, extension: map['extension'] as String,
storagePath: map['storage_path'] as String, storagePath: map['storage_path'] as String?,
fileSize: map['file_size'] is int fileSize: map['file_size'] is int
? map['file_size'] ? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0, : int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,

View File

@@ -0,0 +1,220 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flux/core/utils/functions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdfx/pdfx.dart';
import 'package:internet_file/internet_file.dart';
class AttachmentViewerScreen extends StatefulWidget {
final AttachmentModel attachment;
final Function(String newName)? onRename;
final VoidCallback? onDelete;
const AttachmentViewerScreen({
super.key,
required this.attachment,
this.onRename,
this.onDelete,
});
@override
State<AttachmentViewerScreen> createState() => _AttachmentViewerScreenState();
}
class _AttachmentViewerScreenState extends State<AttachmentViewerScreen> {
PdfControllerPinch? _pdfController;
bool _isLoading = true;
String? _errorMessage;
Uint8List? _fileBytes;
late String _fileName;
bool get isPdf => widget.attachment.extension.toLowerCase() == 'pdf';
@override
void initState() {
super.initState();
_fileName = widget.attachment.name;
_loadFile();
}
Future<void> _loadFile() async {
try {
// 1. Capiamo da dove prendere i dati
if (widget.attachment.localBytes != null) {
_fileBytes = widget.attachment.localBytes;
} else if (widget.attachment.storagePath != null &&
widget.attachment.storagePath!.isNotEmpty) {
final signedUrl = await getSignedUrl(widget.attachment.storagePath!);
_fileBytes = await InternetFile.get(signedUrl);
} else {
throw Exception("Nessun documento trovato o byte mancanti.");
}
// 2. Se è PDF, inizializziamo il controller
if (isPdf && _fileBytes != null) {
_pdfController = PdfControllerPinch(
document: PdfDocument.openData(_fileBytes!),
);
}
if (mounted) setState(() => _isLoading = false);
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
}
}
@override
void dispose() {
_pdfController?.dispose();
super.dispose();
}
void _showRenameDialog() {
final ctrl = TextEditingController(text: _fileName);
ctrl.selection = TextSelection(
baseOffset: 0,
extentOffset: ctrl.text.length,
);
final focusNode = FocusNode();
showDialog(
context: context,
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => focusNode.requestFocus(),
);
return AlertDialog(
title: const Text('Rinomina File'),
content: TextField(
controller: ctrl,
focusNode: focusNode,
decoration: InputDecoration(
labelText: 'Nuovo nome',
suffixText: '.${widget.attachment.extension}',
),
onSubmitted: (val) {
Navigator.pop(context);
if (val.trim().isNotEmpty && widget.onRename != null) {
setState(() {
_fileName = val.trim();
});
widget.onRename!(val.trim());
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annulla'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
if (ctrl.text.trim().isNotEmpty && widget.onRename != null) {
setState(() {
_fileName = ctrl.text.trim();
});
widget.onRename!(ctrl.text.trim());
}
},
child: const Text('Salva'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black87, // Sfondo scuro per i viewer è il top
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
if (widget.onRename != null)
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Rinomina',
onPressed: _showRenameDialog,
),
if (widget.onDelete != null)
IconButton(
icon: const Icon(Icons.delete, color: Colors.redAccent),
tooltip: 'Elimina',
onPressed: () {
// Chiediamo conferma
showDialog(
context: context,
builder: (c) => AlertDialog(
title: const Text('Eliminare file?'),
content: const Text(
'Sei sicuro di voler eliminare questo allegato?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annulla'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
onPressed: () {
Navigator.pop(c); // Chiude dialog
widget.onDelete!(); // Lancia eliminazione
Navigator.pop(context); // Chiude il viewer
},
child: const Text('Elimina'),
),
],
),
);
},
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_errorMessage != null) {
return Center(
child: Text(
'Errore: $_errorMessage',
style: const TextStyle(color: Colors.redAccent),
),
);
}
if (_fileBytes == null) {
return const Center(
child: Text(
'File non disponibile',
style: TextStyle(color: Colors.white),
),
);
}
if (isPdf && _pdfController != null) {
return PdfViewPinch(controller: _pdfController!);
} else {
return InteractiveViewer(
maxScale: 5.0,
child: Center(child: Image.memory(_fileBytes!)),
);
}
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
class QuickRenameDialog extends StatefulWidget {
final String suggestedName;
final Widget previewWidget; // Può essere Image.memory o un'icona PDF
const QuickRenameDialog({
super.key,
required this.suggestedName,
required this.previewWidget,
});
@override
State<QuickRenameDialog> createState() => _QuickRenameDialogState();
}
class _QuickRenameDialogState extends State<QuickRenameDialog> {
late TextEditingController _nameCtrl;
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.suggestedName);
// MAGIA UX: Selezioniamo tutto il testo di default appena si apre!
_nameCtrl.selection = TextSelection(
baseOffset: 0,
extentOffset: widget.suggestedName.length,
);
// Richiediamo il focus appena il widget è costruito
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_nameCtrl.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Rinomina per Export'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Anteprima del documento (limitiamo l'altezza)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(border: Border.all(color: Colors.grey)),
child: widget.previewWidget,
),
const SizedBox(height: 16),
TextField(
controller: _nameCtrl,
focusNode: _focusNode,
decoration: const InputDecoration(
labelText: 'Nome del file',
suffixText: '.pdf', // Facciamo capire che sarà un PDF
border: OutlineInputBorder(),
),
// MAGIA UX 2: Se preme invio sulla tastiera, salva e chiude!
onSubmitted: (value) => Navigator.of(context).pop(value),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), // Ritorna null
child: const Text('Salta'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(_nameCtrl.text),
child: const Text('Esporta (Invio)'),
),
],
);
}
}

View File

@@ -168,7 +168,7 @@ class CustomerRepository {
for (var file in files) { for (var file in files) {
if (file.operationId == null) { if (file.operationId == null) {
idsToDelete.add(file.id!); idsToDelete.add(file.id!);
storagePathsToDelete.add(file.storagePath); storagePathsToDelete.add(file.storagePath!);
} else { } else {
idsToEdit.add(file.id!); idsToEdit.add(file.id!);
} }

View File

@@ -31,6 +31,10 @@ class OperationFilesBloc
on<DeleteOperationFilesEvent>(_onDeleteOperationFiles); on<DeleteOperationFilesEvent>(_onDeleteOperationFiles);
on<ToggleOperationFileSelectionEvent>(_onToggleOperationFileSelection); on<ToggleOperationFileSelectionEvent>(_onToggleOperationFileSelection);
on<LinkFilesToCustomerEvent>(_onLinkFilesToCustomer); on<LinkFilesToCustomerEvent>(_onLinkFilesToCustomer);
on<RenameOperationFileEvent>(_onRenameOperationFile);
on<DeleteSpecificOperationFileEvent>(_onDeleteSpecificOperationFiles);
on<SelectAllOperationFilesEvent>(_onSelectAllOperationFiles);
on<ClearOperationFileSelectionEvent>(_onClearOperationFileSelection);
// Se il BLoC nasce con un ID, accendiamo subito lo stream! // Se il BLoC nasce con un ID, accendiamo subito lo stream!
if (operationId != null) { if (operationId != null) {
@@ -266,6 +270,22 @@ class OperationFilesBloc
emit(state.copyWith(selectedFiles: selectedFiles)); emit(state.copyWith(selectedFiles: selectedFiles));
} }
void _onSelectAllOperationFiles(
SelectAllOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) {
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
emit(state.copyWith(selectedFiles: state.allFiles));
}
void _onClearOperationFileSelection(
ClearOperationFileSelectionEvent event,
Emitter<OperationFilesState> emit,
) {
// Svuotiamo brutalmente la lista
emit(state.copyWith(selectedFiles: []));
}
FutureOr<void> _onLinkFilesToCustomer( FutureOr<void> _onLinkFilesToCustomer(
LinkFilesToCustomerEvent event, LinkFilesToCustomerEvent event,
Emitter<OperationFilesState> emit, Emitter<OperationFilesState> emit,
@@ -323,4 +343,47 @@ class OperationFilesBloc
); );
} }
} }
FutureOr<void> _onRenameOperationFile(
RenameOperationFileEvent event,
Emitter<OperationFilesState> emit,
) async {
// BIVIO 1: File Locale (Bozza)
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles.map((f) {
if (f == event.file) {
return f.copyWith(name: event.newName);
}
return f;
}).toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
return;
}
// BIVIO 2: File Remoto (Salvato su DB)
emit(state.copyWith(status: OperationFilesStatus.loading));
try {
await _repository.renameAttachment(event.file.id!, event.newName);
emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: "Errore rinomina: $e",
),
);
}
}
FutureOr<void> _onDeleteSpecificOperationFiles(
DeleteSpecificOperationFileEvent event,
Emitter<OperationFilesState> emit,
) {
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles
.where((f) => f != event.file)
.toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
}
}
} }

View File

@@ -56,3 +56,26 @@ class ToggleOperationFileSelectionEvent extends OperationFilesEvent {
final AttachmentModel file; final AttachmentModel file;
const ToggleOperationFileSelectionEvent(this.file); const ToggleOperationFileSelectionEvent(this.file);
} }
class RenameOperationFileEvent extends OperationFilesEvent {
final AttachmentModel file;
final String newName;
const RenameOperationFileEvent(this.file, this.newName);
@override
List<Object?> get props => [file, newName];
}
class DeleteSpecificOperationFileEvent extends OperationFilesEvent {
final AttachmentModel file;
const DeleteSpecificOperationFileEvent(this.file);
@override
List<Object?> get props => [file];
}
class SelectAllOperationFilesEvent extends OperationFilesEvent {}
class ClearOperationFileSelectionEvent extends OperationFilesEvent {}

View File

@@ -217,6 +217,7 @@ class OperationsCubit extends Cubit<OperationsState> {
String? providerId, String? providerId,
String? providerDisplayName, String? providerDisplayName,
String? subtype, String? subtype,
String? description,
DateTime? expirationDate, DateTime? expirationDate,
int? quantity, int? quantity,
String? modelId, String? modelId,
@@ -227,6 +228,7 @@ class OperationsCubit extends Cubit<OperationsState> {
bool clearProvider = false, bool clearProvider = false,
bool clearType = false, bool clearType = false,
bool clearSubtype = false, bool clearSubtype = false,
bool clearDescription = false,
bool clearExpiration = false, bool clearExpiration = false,
bool clearQuantity = false, bool clearQuantity = false,
bool clearModel = false, bool clearModel = false,
@@ -258,6 +260,9 @@ class OperationsCubit extends Cubit<OperationsState> {
: (providerDisplayName ?? current.providerDisplayName), : (providerDisplayName ?? current.providerDisplayName),
quantity: newQuantity, quantity: newQuantity,
type: clearType ? null : (type ?? current.type), type: clearType ? null : (type ?? current.type),
description: clearDescription
? null
: (description ?? current.description),
subtype: clearSubtype ? null : (subtype ?? current.subtype), subtype: clearSubtype ? null : (subtype ?? current.subtype),
expirationDate: clearExpiration expirationDate: clearExpiration
? null ? null

View File

@@ -247,6 +247,30 @@ class OperationsRepository {
.eq('id', file.id!); .eq('id', file.id!);
} }
Future<void> renameAttachment(String id, String newName) async {
try {
await _supabase.from('attachment').update({'name': newName}).eq('id', id);
} catch (e) {
throw '$e';
}
}
Future<void> deleteSpecificOperationFile(AttachmentModel file) async {
try {
if (file.customerId == null) {
await _supabase.from('attachment').delete().eq('id', file.id!);
await _supabase.storage.from('documents').remove([file.storagePath!]);
} else {
await _supabase
.from('attachment')
.update({'operation_id': null})
.eq('id', file.id!);
}
} catch (e) {
throw '$e';
}
}
Future<void> deleteOperationFiles(List<AttachmentModel> files) async { Future<void> deleteOperationFiles(List<AttachmentModel> files) async {
if (files.isEmpty) return; if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi // 1. Prepariamo le liste di ID e di Percorsi
@@ -256,7 +280,7 @@ class OperationsRepository {
for (var file in files) { for (var file in files) {
if (file.customerId == null) { if (file.customerId == null) {
idsToDelete.add(file.id!); idsToDelete.add(file.id!);
storagePathsToDelete.add(file.storagePath); storagePathsToDelete.add(file.storagePath!);
} else { } else {
idsToEdit.add(file.id!); idsToEdit.add(file.id!);
} }

View File

@@ -5,6 +5,7 @@ import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/widgets/customer_section.dart'; import 'package:flux/features/operations/ui/widgets/customer_section.dart';
import 'package:flux/features/operations/ui/widgets/details_section.dart'; import 'package:flux/features/operations/ui/widgets/details_section.dart';
import 'package:flux/features/operations/ui/widgets/operation_files_section.dart';
import 'package:flux/features/operations/ui/widgets/staff_section.dart'; import 'package:flux/features/operations/ui/widgets/staff_section.dart';
import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH
// import 'package:flux/features/attachments/ui/operation_files_section.dart'; // import 'package:flux/features/attachments/ui/operation_files_section.dart';
@@ -29,6 +30,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
final _referenceController = TextEditingController(); final _referenceController = TextEditingController();
final _noteController = TextEditingController(); final _noteController = TextEditingController();
final _freeTextSubtypeController = TextEditingController(); final _freeTextSubtypeController = TextEditingController();
final _freeTextDescriptionController = TextEditingController();
final List<String> _availableTypes = [ final List<String> _availableTypes = [
'AL', 'AL',
@@ -89,6 +91,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
model.subtype!.isNotEmpty) { model.subtype!.isNotEmpty) {
_freeTextSubtypeController.text = model.subtype!; _freeTextSubtypeController.text = model.subtype!;
} }
if (_freeTextDescriptionController.text.isEmpty &&
model.description != null &&
model.description!.isNotEmpty) {
_freeTextDescriptionController.text = model.description!;
}
_isInitialized = true; _isInitialized = true;
} }
@@ -103,6 +110,9 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
subtype: ['Entertainment', 'Custom'].contains(currentOperation.type) subtype: ['Entertainment', 'Custom'].contains(currentOperation.type)
? _freeTextSubtypeController.text ? _freeTextSubtypeController.text
: currentOperation.subtype, : currentOperation.subtype,
description: ['Energy', 'Custom'].contains(currentOperation.type)
? _freeTextDescriptionController.text
: currentOperation.description,
); );
cubit.initOperationForm(existingOperation: operationToSave); cubit.initOperationForm(existingOperation: operationToSave);
@@ -138,6 +148,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
), ),
); );
_freeTextSubtypeController.clear(); _freeTextSubtypeController.clear();
_freeTextDescriptionController.clear();
} else if (state.status == OperationsStatus.failure) { } else if (state.status == OperationsStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -168,8 +179,51 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
key: _formKey, key: _formKey,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isUltraWide = constraints.maxWidth > 1400;
final isDesktop = constraints.maxWidth > 900; final isDesktop = constraints.maxWidth > 900;
if (isDesktop) { if (isUltraWide) {
// --- LAYOUT 3 COLONNE (Schermi giganti) ---
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. FORM PRINCIPALE (40%)
Expanded(
flex: 4,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
// Attenzione: devi togliere la sezione file dal _buildMainFormContent!
child: _buildMainFormContent(
theme,
state,
showFiles: false,
),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
// 2. NOTE (30%)
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildNotesSection(isDesktop: true),
),
),
VerticalDivider(width: 1, color: theme.dividerColor),
// 3. FILE (30%)
Expanded(
flex: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: OperationFilesSection(
currentOp: state.currentOperation!,
),
),
),
],
);
} else if (isDesktop) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -252,7 +306,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
); );
} }
Widget _buildMainFormContent(ThemeData theme, OperationsState state) { Widget _buildMainFormContent(
ThemeData theme,
OperationsState state, {
bool showFiles = true,
}) {
final currentOp = state.currentOperation; final currentOp = state.currentOperation;
final currentType = currentOp?.type ?? 'AL'; final currentType = currentOp?.type ?? 'AL';
@@ -296,6 +354,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
currentOp: currentOp, currentOp: currentOp,
currentType: currentType, currentType: currentType,
freeTextSubtypeController: _freeTextSubtypeController, freeTextSubtypeController: _freeTextSubtypeController,
freeTextDescriptionController: _freeTextDescriptionController,
durationQuickPicks: _buildDurationQuickPicks(currentOp), durationQuickPicks: _buildDurationQuickPicks(currentOp),
), ),
@@ -331,13 +390,7 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
), ),
const Divider(height: 32), const Divider(height: 32),
_buildSectionTitle('Documenti & Foto'), if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)],
const Center(
child: Text(
"Widget File in arrivo...",
style: TextStyle(color: Colors.grey),
),
),
], ],
); );
} }

View File

@@ -10,6 +10,7 @@ class DetailsSection extends StatelessWidget {
final OperationModel? currentOp; final OperationModel? currentOp;
final String currentType; final String currentType;
final TextEditingController freeTextSubtypeController; final TextEditingController freeTextSubtypeController;
final TextEditingController freeTextDescriptionController;
final Widget durationQuickPicks; final Widget durationQuickPicks;
const DetailsSection({ const DetailsSection({
@@ -17,6 +18,7 @@ class DetailsSection extends StatelessWidget {
required this.currentOp, required this.currentOp,
required this.currentType, required this.currentType,
required this.freeTextSubtypeController, required this.freeTextSubtypeController,
required this.freeTextDescriptionController,
required this.durationQuickPicks, required this.durationQuickPicks,
}); });
@@ -309,7 +311,6 @@ class DetailsSection extends StatelessWidget {
items: [ items: [
'Luce', 'Luce',
'Gas', 'Gas',
'Dual',
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(), ].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (val) { onChanged: (val) {
if (val != null) { if (val != null) {
@@ -320,6 +321,15 @@ class DetailsSection extends StatelessWidget {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField(
controller: freeTextDescriptionController,
decoration: InputDecoration(
labelText: currentType == 'Energy'
? 'Offerta scelta'
: 'Nome del servizio/offerta',
),
),
const SizedBox(height: 16),
], ],
// 2. SCENARIO FIN (Ricerca Modello/Prodotto) // 2. SCENARIO FIN (Ricerca Modello/Prodotto)

View File

@@ -0,0 +1,762 @@
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/operations/blocs/operation_files_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as p; // Se ti serve formattazione core
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 OperationFilesSection extends StatefulWidget {
final OperationModel currentOp;
const OperationFilesSection({super.key, required this.currentOp});
@override
State<OperationFilesSection> createState() => _OperationFilesSectionState();
}
class _OperationFilesSectionState extends State<OperationFilesSection> {
String? _exportDirectory;
double _maxMbLimit = 1.0;
@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<OperationFilesBloc>().add(
AddOperationFilesEvent(result.files),
);
}
}
// --- APERTURA VIEWER ---
void _openFile(AttachmentModel file) {
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
final operationFilesBloc = context.read<OperationFilesBloc>();
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(RenameOperationFileEvent(file, newName));
},
onDelete: () {
operationFilesBloc.add(DeleteSpecificOperationFileEvent(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.currentOp.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 ?? 'Documento';
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<OperationFilesBloc, OperationFilesState>(
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 == OperationFilesStatus.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<OperationFilesBloc>().add(
ClearOperationFileSelectionEvent(),
);
} else {
context.read<OperationFilesBloc>().add(
SelectAllOperationFilesEvent(),
);
}
},
),
],
const SizedBox(width: 12),
// Loader di upload
if (state.status == OperationFilesStatus.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<OperationFilesBloc>().add(
DeleteOperationFilesEvent(),
);
},
),
// Bottone Associa a Cliente
if (widget.currentOp.customerId != null &&
widget.currentOp.customerId!.isNotEmpty)
IconButton(
icon: const Icon(Icons.person_add, color: Colors.blue),
tooltip: 'Copia nei documenti del Cliente',
onPressed: () {
context.read<OperationFilesBloc>().add(
LinkFilesToCustomerEvent(
customerId: widget.currentOp.customerId!,
),
);
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<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(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<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(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

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flux/features/attachments/data/attachments_repository.dart';
import 'package:flux/features/auth/bloc/auth_cubit.dart'; import 'package:flux/features/auth/bloc/auth_cubit.dart';
import 'package:flux/features/operations/data/operations_repository.dart'; import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/l10n/app_localizations.dart'; import 'package:flux/l10n/app_localizations.dart';
@@ -88,6 +89,9 @@ Future<void> setupLocator() async {
() => OperationsRepository(), () => OperationsRepository(),
); );
getIt.registerLazySingleton<ProviderRepository>(() => ProviderRepository()); getIt.registerLazySingleton<ProviderRepository>(() => ProviderRepository());
getIt.registerLazySingleton<AttachmentsRepository>(
() => AttachmentsRepository(),
);
// NOTA: CompanyRepository l'ho tolto perché la logica della Company // NOTA: CompanyRepository l'ho tolto perché la logica della Company
// ora è gestita dal CoreRepository durante l'Onboarding. // ora è gestita dal CoreRepository durante l'Onboarding.

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" version: "2.13.1"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
bloc: bloc:
dependency: transitive dependency: transitive
description: description:
@@ -365,6 +389,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -637,6 +669,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
url: "https://pub.dev"
source: hosted
version: "3.12.0"
pdfx: pdfx:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -733,6 +773,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
postgrest: postgrest:
dependency: transitive dependency: transitive
description: description:
@@ -962,6 +1010,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
universal_io:
dependency: "direct main"
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
url: "https://pub.dev"
source: hosted
version: "2.3.1"
universal_platform: universal_platform:
dependency: transitive dependency: transitive
description: description:

View File

@@ -29,6 +29,8 @@ dependencies:
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
supabase_flutter: ^2.12.2 supabase_flutter: ^2.12.2
uuid: ^4.5.3 uuid: ^4.5.3
pdf: ^3.12.0
universal_io: ^2.3.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: