feature aggiunta

This commit is contained in:
2026-04-20 11:18:22 +02:00
parent 023665ae58
commit 78012fdbf3
13 changed files with 631 additions and 80 deletions

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
@@ -129,6 +130,7 @@ class ServicesCubit extends Cubit<ServicesState> {
companyId: _sessionBloc.state.company!.id,
),
status: ServicesStatus.ready,
localAttachments: [],
),
);
}
@@ -208,7 +210,7 @@ class ServicesCubit extends Cubit<ServicesState> {
final serviceToSave = state.currentService!.copyWith(isBozza: isBozza);
// 2. Salvataggio corazzato
await _repository.saveFullService(serviceToSave);
await _repository.saveFullService(serviceToSave, state.localAttachments);
// 3. Reset e ricaricamento
emit(state.copyWith(status: ServicesStatus.saved, currentService: null));
@@ -222,4 +224,18 @@ class ServicesCubit extends Cubit<ServicesState> {
);
}
}
// --- GESTIONE ALLEGATI LOCALI ---
void addAttachments(List<PlatformFile> files) {
// Aggiungiamo i nuovi file a quelli già presenti in memoria
final updatedList = [...state.localAttachments, ...files];
emit(state.copyWith(localAttachments: updatedList));
}
void removeLocalAttachment(int index) {
final updatedList = List<PlatformFile>.from(state.localAttachments);
updatedList.removeAt(index);
emit(state.copyWith(localAttachments: updatedList));
}
}

View File

@@ -10,6 +10,7 @@ class ServicesState extends Equatable {
final String query;
final DateTimeRange? dateRange;
final bool hasReachedMax;
final List<PlatformFile> localAttachments;
const ServicesState({
required this.status,
@@ -19,6 +20,7 @@ class ServicesState extends Equatable {
this.query = '',
this.dateRange,
this.hasReachedMax = false,
this.localAttachments = const [],
});
ServicesState copyWith({
@@ -29,6 +31,7 @@ class ServicesState extends Equatable {
String? query,
DateTimeRange? dateRange,
bool? hasReachedMax,
List<PlatformFile>? localAttachments,
}) {
return ServicesState(
status: status ?? this.status,
@@ -38,6 +41,7 @@ class ServicesState extends Equatable {
query: query ?? this.query,
dateRange: dateRange ?? this.dateRange,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
localAttachments: localAttachments ?? this.localAttachments,
);
}
@@ -50,5 +54,6 @@ class ServicesState extends Equatable {
query,
dateRange,
hasReachedMax,
localAttachments,
];
}

View File

@@ -1,10 +1,15 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/services/models/service_file_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/service_model.dart';
class ServicesRepository {
final _supabase = Supabase.instance.client;
final companyId = GetIt.I.get<SessionBloc>().state.company!.id;
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
Future<ServiceModel> fetchServiceById(String id) async {
@@ -16,7 +21,8 @@ class ServicesRepository {
customer(nome),
energy_service(*),
fin_service(*),
entertainment_service(*)
entertainment_service(*),
service_file(*)
''')
.eq('id', id)
.single();
@@ -44,7 +50,8 @@ class ServicesRepository {
customer(nome),
energy_service(*),
fin_service(*),
entertainment_service(*)
entertainment_service(*),
service_file(*)
''')
.eq('company_id', companyId);
@@ -75,7 +82,10 @@ class ServicesRepository {
}
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<void> saveFullService(ServiceModel service) async {
Future<void> saveFullService(
ServiceModel service,
List<PlatformFile> localFiles,
) async {
try {
// 1. Upsert del record principale
final serviceData = await _supabase
@@ -142,6 +152,63 @@ class ServicesRepository {
if (insertTasks.isNotEmpty) {
await Future.wait(insertTasks);
}
if (localFiles.isNotEmpty) {
final List<Future> uploadTasks = [];
for (var file in localFiles) {
// Puliamo il nome del file per evitare problemi con spazi o caratteri strani
final cleanFileName = file.name.replaceAll(
RegExp(r'[^a-zA-Z0-9\.\-]'),
'_',
);
final storagePath =
'$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = file.size;
final fileToSave = ServiceFileModel(
name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(),
url: '',
serviceId: newId,
fileSize: fileSize,
);
// Creiamo una funzione asincrona per caricare file e scrivere nel DB
Future<void> uploadAndLink() async {
// Determiniamo il MIME type corretto in base all'estensione
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
? 'application/pdf'
: 'image/${fileToSave.extension}';
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
await _supabase.storage
.from('documents')
.uploadBinary(
storagePath,
file.bytes!,
fileOptions: FileOptions(
contentType:
mimeType, // Diciamo a Supabase esattamente cos'è!
upsert:
true, // Opzionale: sovrascrive se esiste già un file con lo stesso nome
),
);
// B. Otteniamo l'URL pubblico e scriviamo il record del file nel DB
final String publicUrl = _supabase.storage
.from('documents')
.getPublicUrl(storagePath);
await _supabase
.from('service_file')
.insert(fileToSave.copyWith(url: publicUrl).toMap());
}
uploadTasks.add(uploadAndLink());
}
// Eseguiamo tutti gli upload in parallelo per la massima velocità
await Future.wait(uploadTasks);
}
} catch (e) {
// Qui potresti aggiungere una logica di "rollback manuale" se necessario
throw Exception('Errore durante il salvataggio corazzato: $e');
@@ -188,28 +255,4 @@ class ServicesRepository {
]; // Fallback se non c'è ancora storia
}
}
Future<void> uploadAttachment({
required String serviceId,
required String fileName,
required Uint8List fileBytes,
}) async {
try {
// 1. Upload fisico nel bucket 'service_documents'
final path = '$serviceId/$fileName';
await _supabase.storage
.from('service_documents')
.uploadBinary(path, fileBytes);
// 2. Registriamo l'esistenza del file nel database
await _supabase.from('service_attachment').insert({
'service_id': serviceId,
'file_path': path,
'file_name': fileName,
'created_at': DateTime.now().toIso8601String(),
});
} catch (e) {
throw "Errore upload: $e";
}
}
}

View File

@@ -0,0 +1,91 @@
import 'package:equatable/equatable.dart';
class ServiceFileModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String name;
final String extension;
final String url;
final String serviceId;
final int fileSize; // <--- Aggiunto
const ServiceFileModel({
this.id,
this.createdAt,
required this.name,
required this.extension,
required this.url,
required this.serviceId,
required this.fileSize,
});
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (fileSize.toString().length - 1) ~/ 3;
if (i >= suffixes.length) i = suffixes.length - 1;
double num = fileSize / (1 << (i * 10));
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
ServiceFileModel copyWith({
String? id,
DateTime? createdAt,
String? name,
String? extension,
String? url,
String? serviceId,
int? fileSize,
}) {
return ServiceFileModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
name: name ?? this.name,
extension: extension ?? this.extension,
url: url ?? this.url,
serviceId: serviceId ?? this.serviceId,
fileSize: fileSize ?? this.fileSize,
);
}
factory ServiceFileModel.fromMap(Map<String, dynamic> map) {
return ServiceFileModel(
id: map['id'] as String,
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
name: map['name'] ?? '',
extension: map['extension'] ?? '',
url: map['url'] ?? '',
serviceId: map['service_id']?.toString() ?? '',
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'name': name,
'extension': extension,
'url': url,
'service_id': serviceId,
'file_size': fileSize,
};
}
@override
List<Object?> get props => [
id,
createdAt,
name,
extension,
url,
serviceId,
fileSize,
];
}

View File

@@ -0,0 +1,120 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
class AttachmentsSection extends StatelessWidget {
const AttachmentsSection({super.key});
Future<void> _pickFiles(BuildContext context) async {
// Usiamo withData: true fondamentale per avere i bytes e caricare su Supabase Storage
FilePickerResult? result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
withData: true,
);
if (result != null && context.mounted) {
context.read<ServicesCubit>().addAttachments(result.files);
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ServicesCubit, ServicesState>(
builder: (context, state) {
final localFiles = state.localAttachments;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"DOCUMENTI ALLEGATI",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
letterSpacing: 1.2,
),
),
OutlinedButton.icon(
icon: const Icon(Icons.attach_file),
label: const Text("Aggiungi File"),
onPressed: () => _pickFiles(context),
),
],
),
const SizedBox(height: 12),
if (localFiles.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),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: localFiles.length,
itemBuilder: (context, index) {
final file = localFiles[index];
// Calcoliamo la dimensione in MB
final sizeMb = (file.size / (1024 * 1024)).toStringAsFixed(2);
// Scegliamo un'icona in base al tipo di file
final isPdf = file.extension?.toLowerCase() == 'pdf';
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Colors.grey.shade300),
),
child: ListTile(
leading: Icon(
isPdf ? Icons.picture_as_pdf : Icons.image,
color: isPdf ? Colors.red : Colors.blue,
size: 32,
),
title: Text(
file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text("$sizeMb MB"),
trailing: IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.red,
),
onPressed: () => context
.read<ServicesCubit>()
.removeLocalAttachment(index),
),
),
);
},
),
],
);
},
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:flux/features/services/ui/service_form_screen/attachment_section.dart';
import 'package:flux/features/services/ui/service_form_screen/customer_section.dart';
import 'package:flux/features/services/ui/service_form_screen/general_info_section.dart';
import 'package:flux/features/services/ui/service_form_screen/services_grid.dart';
@@ -113,7 +114,8 @@ class _ServiceFormScreenState extends State<ServiceFormScreen> {
ServicesGrid(service: service),
const SizedBox(height: 32),
// TODO: _AttachmentsSection(),
AttachmentsSection(),
const SizedBox(height: 32),
_buildBottomActionButtons(context, isSaving: isSaving),
const SizedBox(height: 32),
],