feat-add-files-from-qr #8

Merged
brontomark merged 13 commits from feat-add-files-from-qr into main 2026-04-26 10:15:35 +02:00
46 changed files with 2376 additions and 327 deletions

View File

@@ -24,6 +24,12 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<intent-filter android:label="flux_deep_link">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fluxapp" />
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
@@ -31,6 +37,17 @@
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
</application> </application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
@@ -42,4 +59,5 @@
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@@ -66,5 +66,20 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>NSCameraUsageDescription</key>
<string>FLUX ha bisogno della fotocamera per scansionare i QR e caricare i documenti.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>FLUX ha bisogno di accedere alla galleria per permetterti di allegare file esistenti.</string>
<key>NSMicrophoneUsageDescription</key>
<string>FLUX ha bisogno del microfono (se intendi registrare video o note vocali).</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>fluxapp</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -130,4 +130,8 @@ class SessionCubit extends Cubit<SessionState> {
await _supabase.auth.signOut(); await _supabase.auth.signOut();
// Non serve emettere stato qui, ci pensa il listener nel costruttore! // Non serve emettere stato qui, ci pensa il listener nel costruttore!
} }
void setIsMobileDevice(bool isMobile) {
emit(state.copyWith(isMobileDevice: isMobile));
}
} }

View File

@@ -24,6 +24,7 @@ class SessionState extends Equatable {
final StoreModel? currentStore; final StoreModel? currentStore;
final StaffMemberModel? currentStaff; final StaffMemberModel? currentStaff;
final OnboardingStep onboardingStep; final OnboardingStep onboardingStep;
final bool isMobileDevice;
const SessionState({ const SessionState({
this.status = SessionStatus.initial, this.status = SessionStatus.initial,
@@ -32,6 +33,7 @@ class SessionState extends Equatable {
this.currentStore, this.currentStore,
this.currentStaff, this.currentStaff,
this.onboardingStep = OnboardingStep.none, this.onboardingStep = OnboardingStep.none,
this.isMobileDevice = false,
}); });
/// Metodo per creare una copia dello stato modificando solo i campi necessari /// Metodo per creare una copia dello stato modificando solo i campi necessari
@@ -42,6 +44,7 @@ class SessionState extends Equatable {
StoreModel? currentStore, StoreModel? currentStore,
StaffMemberModel? currentStaff, StaffMemberModel? currentStaff,
OnboardingStep? onboardingStep, OnboardingStep? onboardingStep,
bool? isMobileDevice,
}) { }) {
return SessionState( return SessionState(
status: status ?? this.status, status: status ?? this.status,
@@ -50,6 +53,7 @@ class SessionState extends Equatable {
currentStore: currentStore ?? this.currentStore, currentStore: currentStore ?? this.currentStore,
currentStaff: currentStaff ?? this.currentStaff, currentStaff: currentStaff ?? this.currentStaff,
onboardingStep: onboardingStep ?? this.onboardingStep, onboardingStep: onboardingStep ?? this.onboardingStep,
isMobileDevice: isMobileDevice ?? this.isMobileDevice,
); );
} }
@@ -61,6 +65,7 @@ class SessionState extends Equatable {
currentStore, currentStore,
currentStaff, currentStaff,
onboardingStep, onboardingStep,
isMobileDevice,
]; ];
// Helper rapidi per la UI // Helper rapidi per la UI

View File

@@ -5,15 +5,19 @@ import 'package:flutter_bloc/flutter_bloc.dart';
// Importa il tuo SessionCubit e lo State // Importa il tuo SessionCubit e lo State
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/data/core_repository.dart'; import 'package:flux/core/data/core_repository.dart';
import 'package:flux/features/customers/ui/customer_mobile_upload_screen.dart';
import 'package:flux/features/auth/ui/auth_screen.dart'; import 'package:flux/features/auth/ui/auth_screen.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_detail_screen.dart'; import 'package:flux/features/customers/ui/customer_detail_screen.dart';
import 'package:flux/features/home/ui/home_screen.dart'; import 'package:flux/features/home/ui/home_screen.dart';
import 'package:flux/features/master_data/products/ui/products_screen.dart'; import 'package:flux/features/master_data/products/ui/products_screen.dart';
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
import 'package:flux/features/onboarding/ui/onboarding_screen.dart'; import 'package:flux/features/onboarding/ui/onboarding_screen.dart';
import 'package:flux/features/services/blocs/service_files_bloc.dart';
import 'package:flux/features/services/models/service_model.dart'; import 'package:flux/features/services/models/service_model.dart';
import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart'; import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart';
import 'package:flux/features/services/ui/service_form_screen/service_mobile_upload_screen.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -90,7 +94,27 @@ class AppRouter {
builder: (context, state) { builder: (context, state) {
// Recuperiamo l'oggetto customer passato tramite extra // Recuperiamo l'oggetto customer passato tramite extra
final customer = state.extra as CustomerModel; final customer = state.extra as CustomerModel;
return CustomerDetailScreen(customer: customer); return BlocProvider(
create: (context) => CustomerFilesBloc(customer.id!),
child: CustomerDetailScreen(customer: customer),
);
},
),
GoRoute(
path: '/customer/:id/upload',
builder: (context, state) {
final customerId = state.pathParameters['id']!;
// Recuperiamo il nome dalle query se vogliamo mostrarlo nel titolo,
// oppure lo caricherà il bloc.
final customerName = state.uri.queryParameters['name'] ?? 'Cliente';
return BlocProvider(
create: (context) => CustomerFilesBloc(customerId),
child: CustomerMobileUploadScreen(
customerId: customerId,
customerName: customerName,
),
);
}, },
), ),
GoRoute( GoRoute(
@@ -107,9 +131,29 @@ class AppRouter {
// Recuperiamo l'ID se presente nell'URL // Recuperiamo l'ID se presente nell'URL
final serviceId = state.uri.queryParameters['serviceId']; final serviceId = state.uri.queryParameters['serviceId'];
return ServiceFormScreen( return BlocProvider(
serviceId: serviceId ?? existingService?.id, create: (context) =>
existingService: existingService, ServiceFilesBloc(serviceId: serviceId ?? existingService?.id),
child: ServiceFormScreen(
serviceId: serviceId ?? existingService?.id,
existingService: existingService,
),
);
},
),
GoRoute(
path: '/service/:id/upload',
builder: (context, state) {
final serviceId = state.pathParameters['id']!;
final serviceName = state.uri.queryParameters['name'] ?? 'Pratica';
return BlocProvider(
// Inizializziamo il bloc col serviceId corretto!
create: (context) => ServiceFilesBloc(serviceId: serviceId),
child: ServiceMobileUploadScreen(
serviceId: serviceId,
serviceName: serviceName,
),
); );
}, },
), ),

View File

@@ -0,0 +1,9 @@
// Funzione che chiede le chiavi a Supabase
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<String> getSignedUrl(String storagePath) async {
return await GetIt.I<SupabaseClient>().storage
.from('documents')
.createSignedUrl(storagePath, 60); // Link che si autodistrugge in 60s
}

View File

@@ -1,6 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; // <--- AGGIUNGI QUESTO import 'package:flux/core/utils/functions.dart';
class ImageViewerWidget extends StatelessWidget { class ImageViewerWidget extends StatelessWidget {
final String? storagePath; // ATTENZIONE: Ora contiene lo storagePath! final String? storagePath; // ATTENZIONE: Ora contiene lo storagePath!
@@ -12,13 +12,6 @@ class ImageViewerWidget extends StatelessWidget {
'Errore: Devi fornire un Path valido o i bytes del file!', 'Errore: Devi fornire un Path valido o i bytes del file!',
); );
// Funzione che chiede le chiavi a Supabase
Future<String> _getSignedUrl() async {
return await Supabase.instance.client.storage
.from('documents')
.createSignedUrl(storagePath!, 60); // Link che si autodistrugge in 60s
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -37,7 +30,7 @@ class ImageViewerWidget extends StatelessWidget {
child: bytes != null child: bytes != null
? Image.memory(bytes!) ? Image.memory(bytes!)
: FutureBuilder<String>( : FutureBuilder<String>(
future: _getSignedUrl(), future: getSignedUrl(storagePath!),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator(); return const CircularProgressIndicator();

View File

@@ -1,9 +1,8 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart'; import 'package:flux/core/utils/functions.dart';
import 'package:pdfx/pdfx.dart'; import 'package:pdfx/pdfx.dart';
import 'package:internet_file/internet_file.dart'; import 'package:internet_file/internet_file.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class PdfViewerWidget extends StatefulWidget { class PdfViewerWidget extends StatefulWidget {
final String? storagePath; final String? storagePath;
@@ -39,11 +38,7 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
pdfData = widget.bytes!; pdfData = widget.bytes!;
} else if (widget.storagePath != null && widget.storagePath!.isNotEmpty) { } else if (widget.storagePath != null && widget.storagePath!.isNotEmpty) {
// SCENARIO 2: Pratica salvata, scarichiamo da Supabase (Remoto) // SCENARIO 2: Pratica salvata, scarichiamo da Supabase (Remoto)
final signedUrl = await GetIt.I final signedUrl = await getSignedUrl(widget.storagePath!);
.get<SupabaseClient>()
.storage
.from('documents')
.createSignedUrl(widget.storagePath!, 60);
pdfData = await InternetFile.get(signedUrl); pdfData = await InternetFile.get(signedUrl);
} else { } else {
throw Exception("Nessun documento trovato"); throw Exception("Nessun documento trovato");

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
class QrUploadDialog extends StatelessWidget {
final String deepLinkUrl;
final String title;
const QrUploadDialog({
super.key,
required this.deepLinkUrl,
required this.title,
});
@override
Widget build(BuildContext context) {
// Usiamo i colori del tema per renderlo coerente col tuo design
final theme = Theme.of(context);
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: theme.colorScheme.surface,
title: Column(
children: [
Icon(
Icons.qr_code_scanner,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
content: SizedBox(
height: 400,
width: 350,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, // Fondamentale per i dialog
children: [
const Text(
"Inquadra questo codice con la fotocamera del tuo telefono per scattare e caricare i documenti direttamente qui.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
// IL CUORE DELLA MAGIA
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors
.white, // Lo sfondo bianco salva la vita sui temi scuri
borderRadius: BorderRadius.circular(16),
),
child: QrImageView(
data: deepLinkUrl,
version: QrVersions.auto,
size: 200.0,
//Opzionale: puoi metterci il logo di FLUX in mezzo!
embeddedImage: const AssetImage('assets/images/logo.png'),
embeddedImageStyle: const QrEmbeddedImageStyle(
size: Size(40, 40),
),
),
),
const SizedBox(height: 16),
Text(
"In attesa di file...",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 8),
const LinearProgressIndicator(), // Per far capire che è "in ascolto"
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text("CHIUDI"),
),
],
actionsAlignment: MainAxisAlignment.center,
);
}
}

View File

@@ -293,8 +293,9 @@ extension CompanyLimits on CompanyModel {
bool get hasActiveAccess { bool get hasActiveAccess {
// 1. Priorità all'override manuale (is_paid e payment_expiration) // 1. Priorità all'override manuale (is_paid e payment_expiration)
if (isPaid) { if (isPaid) {
if (paymentExpiration == null) if (paymentExpiration == null) {
return true; // Pagato "a vita" o senza scadenza return true; // Pagato "a vita" o senza scadenza
}
if (DateTime.now().isBefore(paymentExpiration!)) return true; if (DateTime.now().isBefore(paymentExpiration!)) return true;
} }

View File

@@ -1,6 +1,5 @@
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:flux/features/auth/bloc/auth_cubit.dart';
import 'package:flux/features/company/bloc/company_bloc.dart'; import 'package:flux/features/company/bloc/company_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/theme.dart';

View File

@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';

View File

@@ -0,0 +1,139 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:get_it/get_it.dart';
part 'customer_files_events.dart';
part 'customer_files_state.dart';
class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final String customerId;
CustomerFilesBloc(this.customerId)
: super(const CustomerFilesState(status: CustomerFilesStatus.initial)) {
on<LoadCustomerFilesEvent>(_loadCustomerFiles);
on<UploadCustomerFileEvent>(_uploadCustomerFile);
on<UploadMultipleCustomerFilesEvent>(_uploadMultipleCustomerFiles);
on<DeleteCustomerFilesEvent>(_deleteCustomerFiles);
on<ToggleCustomerFileSelectionEvent>(_toggleCustomerFileSelection);
}
void _loadCustomerFiles(
LoadCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
await emit.forEach<List<CustomerFileModel>>(
_repository.getCustomerFilesStream(customerId),
onData: (customerFiles) => CustomerFilesState(
status: CustomerFilesStatus.success,
customerFiles: customerFiles,
),
onError: (error, stackTrace) => CustomerFilesState(
status: CustomerFilesStatus.failure,
error: error.toString(),
),
);
}
Future<void> _uploadCustomerFile(
UploadCustomerFileEvent event,
Emitter<CustomerFilesState> emit,
) async {
emit(state.copyWith(status: CustomerFilesStatus.uploading));
if (event.pickedFile != null) {
try {
await _repository.uploadAndRegisterFile(
customerId: customerId,
pickedFile: event.pickedFile!,
);
emit(state.copyWith(status: CustomerFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: e.toString(),
),
);
}
}
}
FutureOr<void> _uploadMultipleCustomerFiles(
UploadMultipleCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
if (event.files.isEmpty) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: "Nessun file selezionato",
),
);
return;
}
emit(state.copyWith(status: CustomerFilesStatus.uploading, error: null));
try {
// 2. Creiamo una lista di "Promesse" (Futures) per il repository
final List<Future<void>> uploadTasks = [];
for (var file in event.files) {
// Aggiungiamo il task alla lista, ma NON usiamo await qui dentro!
uploadTasks.add(
_repository.uploadAndRegisterFile(
customerId: customerId,
pickedFile: file,
),
);
}
// 3. ESECUZIONE PARALLELA!
// Aspettiamo che tutti i file siano caricati contemporaneamente.
await Future.wait(uploadTasks);
// 4. GRAN FINALE: Tutto caricato, emettiamo il success!
emit(state.copyWith(status: CustomerFilesStatus.success));
} catch (e) {
// Se anche un solo file fallisce, catturiamo l'errore
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: "Errore durante l'upload multiplo: $e",
),
);
}
}
Future<void> _deleteCustomerFiles(
DeleteCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
emit(state.copyWith(status: CustomerFilesStatus.loading));
try {
await _repository.deleteDocuments(state.selectedFiles);
emit(
state.copyWith(status: CustomerFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: e.toString(),
),
);
}
}
void _toggleCustomerFileSelection(
ToggleCustomerFileSelectionEvent event,
Emitter<CustomerFilesState> emit,
) {
List<CustomerFileModel> selectedFiles = List.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {
selectedFiles.add(event.file);
}
emit(state.copyWith(selectedFiles: selectedFiles));
}
}

View File

@@ -0,0 +1,30 @@
part of 'customer_files_bloc.dart';
abstract class CustomerFilesEvent extends Equatable {
const CustomerFilesEvent();
@override
List<Object> get props => [];
}
class LoadCustomerFilesEvent extends CustomerFilesEvent {}
class UploadCustomerFileEvent extends CustomerFilesEvent {
final PlatformFile? pickedFile;
final File? photo;
const UploadCustomerFileEvent({this.pickedFile, this.photo});
}
class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent {
final List<PlatformFile> files;
const UploadMultipleCustomerFilesEvent(this.files);
@override
List<Object> get props => [files];
}
class DeleteCustomerFilesEvent extends CustomerFilesEvent {}
class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent {
final CustomerFileModel file;
const ToggleCustomerFileSelectionEvent(this.file);
}

View File

@@ -0,0 +1,34 @@
part of 'customer_files_bloc.dart';
enum CustomerFilesStatus { initial, loading, uploading, success, failure }
class CustomerFilesState extends Equatable {
const CustomerFilesState({
required this.status,
this.error,
this.customerFiles = const [],
this.selectedFiles = const [],
});
final CustomerFilesStatus status;
final String? error;
final List<CustomerFileModel> customerFiles;
final List<CustomerFileModel> selectedFiles;
@override
List<Object?> get props => [status, error, customerFiles, selectedFiles];
CustomerFilesState copyWith({
CustomerFilesStatus? status,
String? error,
List<CustomerFileModel>? customerFiles,
List<CustomerFileModel>? selectedFiles,
}) {
return CustomerFilesState(
status: status ?? this.status,
error: error,
customerFiles: customerFiles ?? this.customerFiles,
selectedFiles: selectedFiles ?? this.selectedFiles,
);
}
}

View File

@@ -1,18 +1,27 @@
part of 'customer_cubit.dart'; part of 'customer_cubit.dart';
enum CustomerStatus { initial, loading, success, failure } enum CustomerStatus {
initial,
loading,
filesLoading,
filesUploading,
success,
failure,
}
class CustomerState extends Equatable { class CustomerState extends Equatable {
final CustomerStatus status; final CustomerStatus status;
final List<CustomerModel> customers; final List<CustomerModel> customers;
final CustomerModel? lastCreatedCustomer; final CustomerModel? lastCreatedCustomer;
final String? errorMessage; final String? errorMessage;
final List<CustomerFileModel> customerFiles;
const CustomerState({ const CustomerState({
this.status = CustomerStatus.initial, this.status = CustomerStatus.initial,
this.customers = const [], this.customers = const [],
this.lastCreatedCustomer, this.lastCreatedCustomer,
this.errorMessage, this.errorMessage,
this.customerFiles = const [],
}); });
CustomerState copyWith({ CustomerState copyWith({
@@ -20,12 +29,14 @@ class CustomerState extends Equatable {
List<CustomerModel>? customers, List<CustomerModel>? customers,
CustomerModel? lastCreatedCustomer, CustomerModel? lastCreatedCustomer,
String? errorMessage, String? errorMessage,
List<CustomerFileModel>? customerFiles,
}) { }) {
return CustomerState( return CustomerState(
status: status ?? this.status, status: status ?? this.status,
customers: customers ?? this.customers, customers: customers ?? this.customers,
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer, lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
customerFiles: customerFiles ?? this.customerFiles,
); );
} }
@@ -35,5 +46,6 @@ class CustomerState extends Equatable {
customers, customers,
lastCreatedCustomer, lastCreatedCustomer,
errorMessage, errorMessage,
customerFiles,
]; ];
} }

View File

@@ -1,5 +1,7 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/functions.dart';
import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
@@ -76,6 +78,19 @@ class CustomerRepository {
} }
} }
/// Ascolta in tempo reale i file caricati per un cliente
Stream<List<CustomerFileModel>> getCustomerFilesStream(String customerId) {
return _supabase
.from('customer_file')
.stream(primaryKey: ['id'])
.eq('customer_id', customerId)
.order('created_at', ascending: false)
.map(
(listOfMaps) =>
listOfMaps.map((map) => CustomerFileModel.fromMap(map)).toList(),
);
}
/// Recupera i file di un cliente specifico /// Recupera i file di un cliente specifico
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async { Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async {
try { try {
@@ -92,11 +107,6 @@ class CustomerRepository {
} }
} }
/// Salva il riferimento del file nel DB
Future<void> saveCustomerFile(CustomerFileModel file) async {
await _supabase.from('customer_file').insert(file.toMap());
}
/// Carica un file e salva il riferimento nel database /// Carica un file e salva il riferimento nel database
Future<CustomerFileModel> uploadAndRegisterFile({ Future<CustomerFileModel> uploadAndRegisterFile({
required String customerId, required String customerId,
@@ -113,7 +123,7 @@ class CustomerRepository {
customerId: customerId, customerId: customerId,
name: cleanFileName.fileNameWithoutExtension(), name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(), extension: cleanFileName.fileExtension(),
url: storagePath, storagePath: storagePath,
fileSize: fileSize, fileSize: fileSize,
); );
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf' final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
@@ -160,10 +170,31 @@ class CustomerRepository {
.eq('id', id); .eq('id', id);
} }
/// Elimina un file dallo storage Future<void> deleteDocuments(List<CustomerFileModel> files) async {
Future<void> deleteDocument(String fullPath) async { if (files.isEmpty) return;
// Il path dovrebbe essere ricavato dall'URL
final path = fullPath.split('documents/').last; // 1. Prepariamo le liste di ID e di Percorsi
await _supabase.storage.from('documents').remove([path]); final List<String> idsToDelete = files.map((f) => f.id!).toList();
final List<String> storagePaths = files.map((f) => f.storagePath).toList();
try {
// 2. Cancellazione MASSIVA dal DB (una sola chiamata invece di un ciclo!)
// .in_ dice: "cancella tutti i record il cui ID è contenuto in questa lista"
await _supabase
.from('customer_file')
.delete()
.inFilter('id', idsToDelete);
// 3. Cancellazione MASSIVA dallo Storage
await _supabase.storage.from('documents').remove(storagePaths);
debugPrint("Eliminati con successo ${files.length} file.");
} on PostgrestException catch (e) {
debugPrint("Errore DB: ${e.message}");
throw 'Errore database: ${e.message}';
} catch (e) {
debugPrint("Errore generico: $e");
throw 'Errore durante l\'eliminazione dei file: $e';
}
} }
} }

View File

@@ -4,7 +4,7 @@ class CustomerFileModel extends Equatable {
final String? id; final String? id;
final String customerId; // Riferimento UUID final String customerId; // Riferimento UUID
final String name; final String name;
final String url; final String storagePath;
final String extension; final String extension;
final DateTime? createdAt; final DateTime? createdAt;
final int fileSize; final int fileSize;
@@ -13,7 +13,7 @@ class CustomerFileModel extends Equatable {
this.id, this.id,
required this.customerId, required this.customerId,
required this.name, required this.name,
required this.url, required this.storagePath,
required this.extension, required this.extension,
this.createdAt, this.createdAt,
required this.fileSize, required this.fileSize,
@@ -35,7 +35,7 @@ class CustomerFileModel extends Equatable {
String? id, String? id,
String? customerId, String? customerId,
String? name, String? name,
String? url, String? storagePath,
String? extension, String? extension,
DateTime? createdAt, DateTime? createdAt,
int? fileSize, int? fileSize,
@@ -44,7 +44,7 @@ class CustomerFileModel extends Equatable {
id: id ?? this.id, id: id ?? this.id,
customerId: customerId ?? this.customerId, customerId: customerId ?? this.customerId,
name: name ?? this.name, name: name ?? this.name,
url: url ?? this.url, storagePath: storagePath ?? this.storagePath,
extension: extension ?? this.extension, extension: extension ?? this.extension,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
fileSize: fileSize ?? this.fileSize, fileSize: fileSize ?? this.fileSize,
@@ -56,7 +56,7 @@ class CustomerFileModel extends Equatable {
id: map['id'] as String, id: map['id'] as String,
customerId: map['customer_id'], customerId: map['customer_id'],
name: map['name'], name: map['name'],
url: map['url'], storagePath: map['storage_path'],
extension: map['extension'] ?? '', extension: map['extension'] ?? '',
createdAt: map['created_at'] != null createdAt: map['created_at'] != null
? DateTime.parse(map['created_at']) ? DateTime.parse(map['created_at'])
@@ -72,7 +72,7 @@ class CustomerFileModel extends Equatable {
if (id != null) 'id': id, if (id != null) 'id': id,
'customer_id': customerId, 'customer_id': customerId,
'name': name, 'name': name,
'url': url, 'storage_path': storagePath,
'extension': extension, 'extension': extension,
'file_size': fileSize, 'file_size': fileSize,
}; };
@@ -83,7 +83,7 @@ class CustomerFileModel extends Equatable {
id, id,
customerId, customerId,
name, name,
url, storagePath,
extension, extension,
createdAt, createdAt,
fileSize, fileSize,

View File

@@ -1,10 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:get_it/get_it.dart';
class CustomerDetailScreen extends StatefulWidget { class CustomerDetailScreen extends StatefulWidget {
final CustomerModel customer; final CustomerModel customer;
@@ -15,36 +19,19 @@ class CustomerDetailScreen extends StatefulWidget {
} }
class _CustomerDetailScreenState extends State<CustomerDetailScreen> { class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
final _repository = GetIt.I<CustomerRepository>();
List<CustomerFileModel> _files = [];
bool _isLoadingFiles = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadFiles(); _loadFiles();
} }
Future<void> _loadFiles() async { void _loadFiles() {
try { context.read<CustomerFilesBloc>().add(LoadCustomerFilesEvent());
final files = await _repository.getCustomerFiles(
widget.customer.id.toString(),
);
setState(() {
_files = files;
_isLoadingFiles = false;
});
} catch (e) {
setState(() => _isLoadingFiles = false);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
} }
Future<void> _pickAndUpload() async { Future<void> _pickAndUpload() async {
CustomerFilesBloc customerFilesBloc = context.read<CustomerFilesBloc>();
// Chiamata statica pulita // Chiamata statica pulita
FilePickerResult? result = await FilePicker.pickFiles( FilePickerResult? result = await FilePicker.pickFiles(
allowMultiple: true, allowMultiple: true,
@@ -55,11 +42,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
if (result != null) { if (result != null) {
for (var pickedFile in result.files) { for (var pickedFile in result.files) {
try { try {
final newFile = await _repository.uploadAndRegisterFile( customerFilesBloc.add(
customerId: widget.customer.id.toString(), UploadCustomerFileEvent(pickedFile: pickedFile),
pickedFile: pickedFile,
); );
setState(() => _files.add(newFile));
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -158,46 +143,97 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
} }
Widget _buildDocumentSection() { Widget _buildDocumentSection() {
return Column( return BlocBuilder<CustomerFilesBloc, CustomerFilesState>(
crossAxisAlignment: CrossAxisAlignment.start, builder: (context, state) {
children: [ return Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Row(
"DOCUMENTI", mainAxisAlignment: MainAxisAlignment.spaceBetween,
style: TextStyle( children: [
fontSize: 18, Text(
fontWeight: FontWeight.bold, "DOCUMENTI",
color: context.accent, style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: context.accent,
),
),
// ZONA BOTTONI: Li mettiamo in una Row
Row(
children: [
// Bottone classico: c'è sempre (carica da disco locale)
ElevatedButton.icon(
onPressed: _pickAndUpload,
icon: const Icon(Icons.add_circle_outline),
label: const Text("CARICA FILE"),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: state.selectedFiles.isEmpty
? null
: () => _showDeleteConfirmationDialog(
context: context,
files: state.selectedFiles,
),
icon: const Icon(Icons.delete_outline),
label: const Text("ELIMINA FILE"),
),
// Controlliamo se siamo su Desktop/Web per mostrare il QR
if (!context.read<SessionCubit>().state.isMobileDevice) ...[
const SizedBox(
width: 12,
), // Un po' di respiro tra i bottoni
ElevatedButton.icon(
onPressed: () {
showDialog(
context: context,
builder: (context) => QrUploadDialog(
deepLinkUrl:
'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.nome)}',
title: 'Scatta per ${widget.customer.nome}',
),
);
},
icon: const Icon(Icons.qr_code),
label: const Text("GENERA QR"),
style: ElevatedButton.styleFrom(
// Lo facciamo di un colore leggermente diverso per distinguerlo
backgroundColor: context.accent.withValues(
alpha: 0.1,
),
foregroundColor: context.accent,
elevation: 0,
),
),
],
],
),
],
),
const SizedBox(height: 20),
if (state.status == CustomerFilesStatus.loading)
const Center(child: CircularProgressIndicator())
else if (state.customerFiles.isEmpty)
const Center(child: Text("Nessun documento presente"))
else
Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: state.customerFiles.length,
itemBuilder: (context, index) =>
_FileCard(file: state.customerFiles[index], state: state),
),
), ),
),
ElevatedButton.icon(
onPressed: _pickAndUpload,
icon: const Icon(Icons.add_circle_outline),
label: const Text("CARICA FILE"),
),
], ],
), );
const SizedBox(height: 20), },
if (_isLoadingFiles)
const Center(child: CircularProgressIndicator())
else if (_files.isEmpty)
const Center(child: Text("Nessun documento presente"))
else
Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: _files.length,
itemBuilder: (context, index) => _FileCard(file: _files[index]),
),
),
],
); );
} }
@@ -223,34 +259,63 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
), ),
); );
} }
void _showDeleteConfirmationDialog({
required BuildContext context,
required List<CustomerFileModel> files,
}) {}
} }
class _FileCard extends StatelessWidget { class _FileCard extends StatelessWidget {
final CustomerFileModel file; final CustomerFileModel file;
const _FileCard({required this.file}); final CustomerFilesState state;
const _FileCard({required this.file, required this.state});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return GestureDetector(
decoration: BoxDecoration( onTap: () => context.read<CustomerFilesBloc>().add(
color: context.background, ToggleCustomerFileSelectionEvent(file),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: context.accent.withValues(alpha: 0.1)),
), ),
child: Column( onDoubleTap: () => _handleDoubleClickOnFile(context, file),
mainAxisAlignment: MainAxisAlignment.center, child: Stack(
children: [ children: [
Icon(_getFileIcon(file.extension), size: 48, color: context.accent), Container(
const SizedBox(height: 8), decoration: BoxDecoration(
Padding( color: context.background,
padding: const EdgeInsets.symmetric(horizontal: 8), borderRadius: BorderRadius.circular(12),
child: Text( border: Border.all(color: context.accent.withValues(alpha: 0.1)),
file.name, ),
maxLines: 1, child: Column(
overflow: TextOverflow.ellipsis, mainAxisAlignment: MainAxisAlignment.center,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), children: [
Icon(
_getFileIcon(file.extension),
size: 48,
color: context.accent,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
), ),
), ),
if (state.selectedFiles.contains(file))
Positioned(
top: 10,
left: 10,
child: Icon(Icons.check_circle, color: context.accent, size: 24),
),
], ],
), ),
); );
@@ -268,4 +333,25 @@ class _FileCard extends StatelessWidget {
return Icons.insert_drive_file; return Icons.insert_drive_file;
} }
} }
void _handleDoubleClickOnFile(BuildContext context, CustomerFileModel file) {
showDialog(
context: context,
barrierDismissible: true,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height * 0.8,
child: file.isPdf
? PdfViewerWidget(storagePath: file.storagePath)
: ImageViewerWidget(storagePath: file.storagePath),
),
),
),
);
}
} }

View File

@@ -0,0 +1,304 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
class CustomerMobileUploadScreen extends StatefulWidget {
final String customerId;
final String customerName;
const CustomerMobileUploadScreen({
super.key,
required this.customerId,
required this.customerName,
});
@override
State<CustomerMobileUploadScreen> createState() =>
_CustomerMobileUploadScreenState();
}
class _CustomerMobileUploadScreenState
extends State<CustomerMobileUploadScreen> {
// 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<CustomerFilesBloc, CustomerFilesState>(
listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == CustomerFilesStatus.success && _isUploading) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Tutti i file caricati con successo! ✅"),
),
);
Navigator.of(context).pop();
}
if (state.status == CustomerFilesStatus.failure) {
setState(() => _isUploading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Errore: ${state.error}")));
}
},
child: Scaffold(
appBar: AppBar(
title: Text("Upload: ${widget.customerName}"),
// 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 come la galleria dell'iPhone
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
? 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 (Impedisce tap multipli) ---
if (_isUploading)
Container(
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(); // Sicuro anche per Web!
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); // Unifichiamo tutto in un dart:io File
});
}
}
Future<void> _handleFilePicker() async {
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
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);
// Diciamo al BLoC di caricare tutti i file.
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
final bloc = context.read<CustomerFilesBloc>();
bloc.add(UploadMultipleCustomerFilesEvent(_stagedFiles));
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
}
}

View File

@@ -37,39 +37,6 @@ class _CustomersContentState extends State<CustomersContent> {
} }
} }
/// Funzione unica per gestire Creazione e Modifica
void _openCustomerForm({CustomerModel? customer}) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
backgroundColor: context.background,
content: SizedBox(
width: 500, // Larghezza ottimale per desktop
child: CustomerForm(
customer: customer,
onSave: (customerFromForm) {
final session = context.read<SessionCubit>().state;
final companyId = session.company?.id;
if (companyId == null) return;
if (customer == null) {
// CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create
context.read<CustomerCubit>().createCustomer(
customerFromForm.copyWith(companyId: companyId),
);
} else {
// CASO MODIFICA: L'ID e il companyId sono già nel modello
context.read<CustomerCubit>().updateCustomer(customerFromForm);
}
Navigator.pop(dialogContext);
},
),
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -85,7 +52,7 @@ class _CustomersContentState extends State<CustomersContent> {
Padding( Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => _openCustomerForm(), onPressed: () => openCustomerForm(context: context),
icon: const Icon(Icons.person_add_alt_1_rounded, size: 20), icon: const Icon(Icons.person_add_alt_1_rounded, size: 20),
label: const Text('NUOVO'), label: const Text('NUOVO'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -244,8 +211,48 @@ class _CustomerTile extends StatelessWidget {
], ],
), ),
), ),
trailing: Icon(Icons.edit_note_rounded, color: context.accent), trailing: IconButton(
onPressed: () =>
openCustomerForm(context: context, customer: customer),
icon: Icon(Icons.edit_note_rounded, color: context.accent),
),
), ),
); );
} }
} }
/// Funzione unica per gestire Creazione e Modifica
void openCustomerForm({
CustomerModel? customer,
required BuildContext context,
}) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
backgroundColor: context.background,
content: SizedBox(
width: 500, // Larghezza ottimale per desktop
child: CustomerForm(
customer: customer,
onSave: (customerFromForm) {
final session = context.read<SessionCubit>().state;
final companyId = session.company?.id;
if (companyId == null) return;
if (customer == null) {
// CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create
context.read<CustomerCubit>().createCustomer(
customerFromForm.copyWith(companyId: companyId),
);
} else {
// CASO MODIFICA: L'ID e il companyId sono già nel modello
context.read<CustomerCubit>().updateCustomer(customerFromForm);
}
Navigator.pop(dialogContext);
},
),
),
),
);
}

View File

@@ -57,7 +57,7 @@ class ProvidersCubit extends Cubit<ProvidersState> {
ProvidersCubit() : super(const ProvidersState()); ProvidersCubit() : super(const ProvidersState());
// Carica i provider della company e quelli associati a uno store specifico // Carica i provider della company e quelli associati a uno store specifico
Future<void> loadProviders(StoreModel? store) async { Future<void> loadProviders({StoreModel? store}) async {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
try { try {
final all = await _repository.fetchAllCompanyProviders( final all = await _repository.fetchAllCompanyProviders(
@@ -149,7 +149,7 @@ class ProvidersCubit extends Cubit<ProvidersState> {
await _repository.syncProviderStores(pId!, selectedStoreIds); await _repository.syncProviderStores(pId!, selectedStoreIds);
// 3. Ricarichiamo tutto // 3. Ricarichiamo tutto
await loadProviders(null); await loadProviders();
} catch (e) { } catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString())); emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
} }
@@ -168,7 +168,7 @@ class ProvidersCubit extends Cubit<ProvidersState> {
// o fare un confronto tra i presenti e i nuovi) // o fare un confronto tra i presenti e i nuovi)
await _repository.syncProviderStores(provider.id!, storeIds); await _repository.syncProviderStores(provider.id!, storeIds);
await loadProviders(null); await loadProviders();
} catch (e) { } catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString())); emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
} }

View File

@@ -6,6 +6,7 @@ import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; // Tuo percorso import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; // Tuo percorso
import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
import 'package:get_it/get_it.dart';
class StaffScreen extends StatefulWidget { class StaffScreen extends StatefulWidget {
const StaffScreen({super.key}); const StaffScreen({super.key});
@@ -268,25 +269,24 @@ class _StaffScreenState extends State<StaffScreen> {
height: 50, height: 50,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
final companyId = context final updatedMember = StaffMemberModel(
.read<SessionCubit>()
.state
.company!
.id!;
//TODO sistemare StaffScreen per il nuovo modello
/* final updatedMember = StaffMemberModel(
id: member?.id, id: member?.id,
name: nameController.text, name: nameController.text,
email: emailController.text, email: emailController.text,
phoneNumber: phoneController.text, phoneNumber: phoneController.text,
companyId: companyId, companyId: GetIt.I
.get<SessionCubit>()
.state
.company!
.id!,
userId: GetIt.I.get<SessionCubit>().state.user!.id,
); );
// Chiamiamo il metodo atomico nel Cubit // Chiamiamo il metodo atomico nel Cubit
context.read<StaffCubit>().saveStaffWithStores( context.read<StaffCubit>().saveStaffWithStores(
member: updatedMember, member: updatedMember,
selectedStoreIds: tempSelectedStores, selectedStoreIds: tempSelectedStores,
); */ );
Navigator.pop(context); Navigator.pop(context);
}, },

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/utils/validators.dart'; import 'package:flux/core/utils/validators.dart';
import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/core/widgets/flux_text_field.dart';
import 'package:flux/features/company/models/company_model.dart';
import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart';
import 'package:flux/features/onboarding/blocs/onboarding_state.dart'; import 'package:flux/features/onboarding/blocs/onboarding_state.dart';

View File

@@ -0,0 +1,232 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/services/data/services_repository.dart';
import 'package:flux/features/services/models/service_file_model.dart';
import 'package:flux/features/services/models/service_model.dart';
import 'package:get_it/get_it.dart';
part 'service_files_events.dart';
part 'service_files_state.dart';
class ServiceFilesBloc extends Bloc<ServiceFilesEvent, ServiceFilesState> {
final _repository = GetIt.I.get<ServicesRepository>();
final String? serviceId;
ServiceFilesBloc({this.serviceId})
: super(
ServiceFilesState(
status: ServiceFilesStatus.initial,
serviceId: serviceId,
),
) {
on<ServiceSavedEvent>(_onServiceSaved);
on<LoadServiceFilesEvent>(_onLoadServiceFiles);
on<AddServiceFilesEvent>(_onAddServiceFiles);
on<UploadServiceFilesEvent>(_onUploadServiceFiles);
on<UploadMultipleServiceFilesEvent>(_onUploadMultipleServiceFiles);
on<DeleteServiceFilesEvent>(_onDeleteServiceFiles);
on<ToggleServiceFileSelectionEvent>(_onToggleServiceFileSelection);
// Se il BLoC nasce con un ID, accendiamo subito lo stream!
if (serviceId != null) {
add(LoadServiceFilesEvent(serviceId: serviceId));
}
}
FutureOr<void> _onServiceSaved(
ServiceSavedEvent event,
Emitter<ServiceFilesState> emit,
) {
// 1. Aggiorniamo l'ID nello stato
// 2. PIALLIAMO i file locali: ormai sono partiti per Supabase!
// Così la UI si pulisce all'istante e aspetta quelli remoti.
emit(
state.copyWith(
serviceId: event.serviceId,
localFiles: [], // <-- LA MAGIA ANTI-DUPLICATI
),
);
// Lanciamo il caricamento
add(LoadServiceFilesEvent(serviceId: event.serviceId));
}
FutureOr<void> _onLoadServiceFiles(
LoadServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
// Usiamo l'ID dell'evento, e se non c'è usiamo quello dello stato
final currentId = event.serviceId ?? state.serviceId;
if (currentId != null) {
emit(state.copyWith(status: ServiceFilesStatus.loading));
await emit.forEach(
_repository.getServiceFilesStream(
currentId,
), // <-- Usiamo l'ID corretto!
onData: (data) => state.copyWith(
status: ServiceFilesStatus.success,
remoteFiles: data,
),
onError: (error, stackTrace) => state.copyWith(
status: ServiceFilesStatus.failure,
error: error.toString(),
),
);
}
}
void _onAddServiceFiles(
AddServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
final currentId = state.serviceId;
// BIVIO 1: PRATICA NUOVA (Nessun ID)
if (currentId == null) {
// Mettiamo i file nel "parcheggio" locale dello State
final newLocalFiles = event.files.map((file) {
return ServiceFileModel(
id: null,
serviceId: serviceId ?? '',
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: file.bytes,
);
}).toList();
final List<ServiceFileModel> updatedLocalFiles = [
...state.localFiles,
...newLocalFiles,
];
emit(
state.copyWith(
localFiles: updatedLocalFiles,
status: ServiceFilesStatus.success,
),
);
return;
}
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID)
emit(state.copyWith(status: ServiceFilesStatus.uploading));
try {
// Logica identica a quella che abbiamo fatto per i clienti
for (var file in event.files) {
await _repository.uploadAndRegisterServiceFile(
serviceId: serviceId!,
pickedFile: file,
);
}
emit(state.copyWith(status: ServiceFilesStatus.success));
} catch (e) {
emit(
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onUploadServiceFiles(
UploadServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
if (event.pickedFiles == null && event.photos == null) return;
if (event.pickedFiles!.isEmpty && event.photos!.isEmpty) return;
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID
emit(state.copyWith(status: ServiceFilesStatus.uploading));
try {
// Logica identica a quella che abbiamo fatto per i clienti
if (event.pickedFiles != null && event.pickedFiles!.isNotEmpty) {
for (var file in event.pickedFiles!) {
await _repository.uploadAndRegisterServiceFile(
serviceId: state.serviceId!,
pickedFile: file,
);
}
}
emit(state.copyWith(status: ServiceFilesStatus.success));
} catch (e) {
emit(
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onUploadMultipleServiceFiles(
UploadMultipleServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
if (event.files.isEmpty) {
emit(
state.copyWith(
status: ServiceFilesStatus.failure,
error: "Nessun file selezionato",
),
);
return;
}
emit(state.copyWith(status: ServiceFilesStatus.uploading, error: null));
try {
// 2. Creiamo una lista di "Promesse" (Futures) per il repository
final List<Future<void>> uploadTasks = [];
for (var file in event.files) {
// Aggiungiamo il task alla lista, ma NON usiamo await qui dentro!
uploadTasks.add(
_repository.uploadAndRegisterServiceFile(
serviceId: state.serviceId!,
pickedFile: file,
),
);
}
// 3. ESECUZIONE PARALLELA!
// Aspettiamo che tutti i file siano caricati contemporaneamente.
await Future.wait(uploadTasks);
// 4. GRAN FINALE: Tutto caricato, emettiamo il success!
emit(state.copyWith(status: ServiceFilesStatus.success));
} catch (e) {
// Se anche un solo file fallisce, catturiamo l'errore
emit(
state.copyWith(
status: ServiceFilesStatus.failure,
error: "Errore durante l'upload multiplo: $e",
),
);
}
}
FutureOr<void> _onDeleteServiceFiles(
DeleteServiceFilesEvent event,
Emitter<ServiceFilesState> emit,
) async {
emit(state.copyWith(status: ServiceFilesStatus.loading));
try {
await _repository.deleteServiceFiles(state.selectedFiles);
emit(
state.copyWith(status: ServiceFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()),
);
}
}
FutureOr<void> _onToggleServiceFileSelection(
ToggleServiceFileSelectionEvent event,
Emitter<ServiceFilesState> emit,
) {
List<ServiceFileModel> selectedFiles = List.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {
selectedFiles.add(event.file);
}
emit(state.copyWith(selectedFiles: selectedFiles));
}
}

View File

@@ -0,0 +1,56 @@
part of 'service_files_bloc.dart';
abstract class ServiceFilesEvent extends Equatable {
const ServiceFilesEvent();
@override
List<Object?> get props => [];
}
class ServiceSavedEvent extends ServiceFilesEvent {
final String serviceId;
const ServiceSavedEvent(this.serviceId);
@override
List<Object?> get props => [serviceId];
}
class LoadServiceFilesEvent extends ServiceFilesEvent {
final String? serviceId;
final ServiceModel? service;
const LoadServiceFilesEvent({this.serviceId, this.service});
@override
List<Object?> get props => [serviceId, service];
}
class AddServiceFilesEvent extends ServiceFilesEvent {
final List<PlatformFile> files;
const AddServiceFilesEvent(this.files);
@override
List<Object?> get props => [files];
}
class UploadServiceFilesEvent extends ServiceFilesEvent {
final List<PlatformFile>? pickedFiles;
final List<File>? photos;
const UploadServiceFilesEvent({this.pickedFiles, this.photos});
@override
List<Object?> get props => [pickedFiles, photos];
}
class UploadMultipleServiceFilesEvent extends ServiceFilesEvent {
final List<PlatformFile> files;
const UploadMultipleServiceFilesEvent(this.files);
@override
List<Object?> get props => [files];
}
class DeleteServiceFilesEvent extends ServiceFilesEvent {}
class ToggleServiceFileSelectionEvent extends ServiceFilesEvent {
final ServiceFileModel file;
const ToggleServiceFileSelectionEvent(this.file);
}

View File

@@ -0,0 +1,52 @@
part of 'service_files_bloc.dart';
enum ServiceFilesStatus { initial, loading, uploading, success, failure }
class ServiceFilesState extends Equatable {
const ServiceFilesState({
this.serviceId,
required this.status,
this.error,
this.localFiles = const [],
this.remoteFiles = const [],
this.selectedFiles = const [],
});
final String? serviceId;
final ServiceFilesStatus status;
final String? error;
final List<ServiceFileModel> localFiles;
final List<ServiceFileModel> remoteFiles;
final List<ServiceFileModel> selectedFiles;
@override
List<Object?> get props => [
serviceId,
status,
error,
localFiles,
remoteFiles,
selectedFiles,
];
List<ServiceFileModel> get allFiles => [...remoteFiles, ...localFiles];
ServiceFilesState copyWith({
String? serviceId,
ServiceFilesStatus? status,
String? error,
List<ServiceFileModel>? localFiles,
List<ServiceFileModel>? remoteFiles,
List<ServiceFileModel>? selectedFiles,
}) {
return ServiceFilesState(
serviceId: serviceId ?? this.serviceId,
status: status ?? this.status,
error: error,
localFiles: localFiles ?? this.localFiles,
remoteFiles: remoteFiles ?? this.remoteFiles,
selectedFiles: selectedFiles ?? this.selectedFiles,
);
}
}

View File

@@ -12,6 +12,7 @@ import 'package:flux/features/services/models/service_file_model.dart';
import 'package:flux/features/services/models/service_model.dart'; import 'package:flux/features/services/models/service_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:permission_handler/permission_handler.dart';
part 'services_state.dart'; part 'services_state.dart';
class ServicesCubit extends Cubit<ServicesState> { class ServicesCubit extends Cubit<ServicesState> {
@@ -202,19 +203,31 @@ class ServicesCubit extends Cubit<ServicesState> {
// --- PERSISTENZA --- // --- PERSISTENZA ---
Future<void> saveCurrentService({required bool isBozza}) async { Future<void> saveCurrentService({
required bool isBozza,
bool shouldPop = true,
List<ServiceFileModel>? files,
}) async {
if (state.currentService == null) return; if (state.currentService == null) return;
emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null)); emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null));
try { try {
// 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente // 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente
final serviceToSave = state.currentService!.copyWith(isBozza: isBozza); final serviceToSave = state.currentService!.copyWith(
isBozza: isBozza,
files: files,
);
// 2. Salvataggio corazzato // 2. Salvataggio corazzato
await _repository.saveFullService(serviceToSave); final updatedService = await _repository.saveFullService(serviceToSave);
// 3. Reset e ricaricamento // 3. Reset e ricaricamento
emit(state.copyWith(status: ServicesStatus.saved, currentService: null)); emit(
state.copyWith(
status: shouldPop ? ServicesStatus.saved : ServicesStatus.savedNoPop,
currentService: shouldPop ? null : updatedService,
),
);
await loadServices(refresh: true); await loadServices(refresh: true);
} catch (e) { } catch (e) {
emit( emit(
@@ -235,7 +248,7 @@ class ServicesCubit extends Cubit<ServicesState> {
serviceId: state.currentService?.id ?? '', serviceId: state.currentService?.id ?? '',
name: file.name.fileNameWithoutExtension(), name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(), extension: file.name.fileExtension(),
url: '', storagePath: '',
fileSize: file.size, fileSize: file.size,
localBytes: file.bytes, localBytes: file.bytes,
createdAt: DateTime.now(), createdAt: DateTime.now(),
@@ -273,49 +286,62 @@ class ServicesCubit extends Cubit<ServicesState> {
); );
} }
void saveAndCopyFileToCustomer(ServiceFileModel file) async { void saveAndCopyFileToCustomer(List<ServiceFileModel> selectedFiles) async {
final currentService = state.currentService; final currentService = state.currentService;
// 1. Check di sicurezza: se non c'è il cliente, non sappiamo dove copiare
if (currentService == null || currentService.customerId == null) { if (currentService == null || currentService.customerId == null) {
// Magari mostra un errore: non posso copiare al cliente se non c'è un cliente! emit(
state.copyWith(
status: ServicesStatus.failure,
errorMessage:
"Impossibile copiare: nessun cliente associato alla pratica.",
),
);
return; return;
} }
emit(state.copyWith(status: ServicesStatus.loading)); emit(state.copyWith(status: ServicesStatus.loading));
try { try {
// 1. Salviamo la pratica (Bozza o definitiva che sia) // 2. SALVATAGGIO CORAZZATO
// Questo assicura che il file sia stato caricato su Storage e censito su DB // Chiamiamo il repo e otteniamo la pratica con TUTTI i file ora dotati di ID e storagePath
await saveCurrentService(isBozza: currentService.isBozza); final updatedService = await _repository.saveFullService(currentService);
// 2. Recuperiamo il file "aggiornato" // 3. COPIA RELAZIONALE
// Dopo il saveCurrentService, il file che prima era "locale" ora ha un URL. // Per ogni file che l'utente ha selezionato nella UI, cerchiamo la sua versione
// Lo cerchiamo nella lista aggiornata per nome o estensione. // "ufficiale" (quella con lo storagePath) nel modello appena tornato dal DB.
final savedFile = state.currentService!.files.firstWhere( for (var selectedFile in selectedFiles) {
(f) => f.name == file.name && f.extension == file.extension, // Cerchiamo il match nel modello aggiornato
orElse: () => file, final persistedFile = updatedService.files.firstWhere(
); (f) =>
f.name == selectedFile.name &&
f.extension == selectedFile.extension,
orElse: () => throw Exception(
"File ${selectedFile.name} non trovato dopo il salvataggio.",
),
);
if (savedFile.url.isEmpty) { // Creiamo il link nel database del cliente
throw Exception( await _repository.copyFileToCustomer(
"Errore: URL del file non trovato dopo il salvataggio.", file: persistedFile,
customerId: currentService.customerId!,
); );
} }
// 3. Chiamiamo il repository per la copia fisica nel database del cliente // 4. AGGIORNAMENTO STATO
// Passiamo l'URL del file e l'ID del cliente // Aggiorniamo il Cubit con il servizio salvato così la UI mostra i file come "Remoti"
await _repository.copyFileToCustomer( emit(
file: savedFile, state.copyWith(
customerId: currentService.customerId!, status: ServicesStatus.success,
currentService: updatedService,
),
); );
// 4. Feedback all'utente
// Potresti emettere un successo o mostrare un toast
emit(state.copyWith(status: ServicesStatus.success));
} catch (e) { } catch (e) {
emit( emit(
state.copyWith( state.copyWith(
status: ServicesStatus.failure, status: ServicesStatus.failure,
errorMessage: "Errore durante la copia del file: $e", errorMessage: "Errore durante il salvataggio e copia: $e",
), ),
); );
} }

View File

@@ -1,6 +1,15 @@
part of 'services_cubit.dart'; part of 'services_cubit.dart';
enum ServicesStatus { initial, loading, ready, saving, saved, success, failure } enum ServicesStatus {
initial,
loading,
ready,
saving,
saved,
savedNoPop,
success,
failure,
}
class ServicesState extends Equatable { class ServicesState extends Equatable {
final ServicesStatus status; final ServicesStatus status;
@@ -10,6 +19,7 @@ class ServicesState extends Equatable {
final String query; final String query;
final DateTimeRange? dateRange; final DateTimeRange? dateRange;
final bool hasReachedMax; final bool hasReachedMax;
final bool isSavingDraft;
const ServicesState({ const ServicesState({
required this.status, required this.status,
@@ -19,6 +29,7 @@ class ServicesState extends Equatable {
this.query = '', this.query = '',
this.dateRange, this.dateRange,
this.hasReachedMax = false, this.hasReachedMax = false,
this.isSavingDraft = false,
}); });
ServicesState copyWith({ ServicesState copyWith({
@@ -29,6 +40,7 @@ class ServicesState extends Equatable {
String? query, String? query,
DateTimeRange? dateRange, DateTimeRange? dateRange,
bool? hasReachedMax, bool? hasReachedMax,
bool? isSavingDraft,
}) { }) {
return ServicesState( return ServicesState(
status: status ?? this.status, status: status ?? this.status,
@@ -38,6 +50,7 @@ class ServicesState extends Equatable {
query: query ?? this.query, query: query ?? this.query,
dateRange: dateRange ?? this.dateRange, dateRange: dateRange ?? this.dateRange,
hasReachedMax: hasReachedMax ?? this.hasReachedMax, hasReachedMax: hasReachedMax ?? this.hasReachedMax,
isSavingDraft: isSavingDraft ?? this.isSavingDraft,
); );
} }
@@ -50,5 +63,6 @@ class ServicesState extends Equatable {
query, query,
dateRange, dateRange,
hasReachedMax, hasReachedMax,
isSavingDraft,
]; ];
} }

View File

@@ -1,5 +1,7 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/features/services/models/service_file_model.dart'; import 'package:flux/features/services/models/service_file_model.dart';
@@ -83,7 +85,7 @@ class ServicesRepository {
} }
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) --- // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<void> saveFullService(ServiceModel service) async { Future<ServiceModel> saveFullService(ServiceModel service) async {
try { try {
// 1. Upsert del record principale // 1. Upsert del record principale
final serviceData = await _supabase final serviceData = await _supabase
@@ -150,35 +152,40 @@ class ServicesRepository {
if (insertTasks.isNotEmpty) { if (insertTasks.isNotEmpty) {
await Future.wait(insertTasks); await Future.wait(insertTasks);
} }
if (service.files.isNotEmpty) {
// 4. UPLOAD DEI FILE LOCALI (Nuovi)
// Filtriamo solo i file che non hanno ancora un ID (quindi sono locali)
final localFilesToUpload = service.files
.where((f) => f.id == null)
.toList();
if (localFilesToUpload.isNotEmpty) {
final List<Future> uploadTasks = []; final List<Future> uploadTasks = [];
for (var file in service.files) { for (var file in localFilesToUpload) {
final storagePath = final storagePath =
'$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}'; '$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}';
final String mimeType = file.extension.toLowerCase() == 'pdf' final String mimeType = file.extension.toLowerCase() == 'pdf'
? 'application/pdf' ? 'application/pdf'
: 'image/${file.extension}'; : 'image/${file.extension}';
final fileToSave = file.copyWith(serviceId: newId, url: storagePath);
final fileToSave = file.copyWith(
serviceId: newId,
storagePath: storagePath,
);
// Creiamo una funzione asincrona per caricare file e scrivere nel DB // Creiamo una funzione asincrona per caricare file e scrivere nel DB
Future<void> uploadAndLink() async { Future<void> uploadAndLink() async {
// Determiniamo il MIME type corretto in base all'estensione
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!) // A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
await _supabase.storage await _supabase.storage
.from('documents') .from('documents')
.uploadBinary( .uploadBinary(
storagePath, storagePath,
fileToSave.localBytes!, fileToSave.localBytes!,
fileOptions: FileOptions( fileOptions: FileOptions(contentType: mimeType, upsert: true),
contentType:
mimeType, // Diciamo a Supabase esattamente cos'è!
upsert:
true, // Opzionale: sovrascrive se esiste già un file con lo stesso nome
),
); );
// B. Inserimento riga nel DB relazionale
await _supabase.from('service_file').insert(fileToSave.toMap()); await _supabase.from('service_file').insert(fileToSave.toMap());
} }
@@ -188,6 +195,23 @@ class ServicesRepository {
// Eseguiamo tutti gli upload in parallelo per la massima velocità // Eseguiamo tutti gli upload in parallelo per la massima velocità
await Future.wait(uploadTasks); await Future.wait(uploadTasks);
} }
// 5. GRAN FINALE: RECUPERO DEL MODELLO COMPLETO E AGGIORNATO
// Interroghiamo Supabase per farci restituire la pratica con TUTTI gli ID generati
// (inclusi quelli della tabella service_file appena inseriti)
final updatedServiceData = await _supabase
.from('service')
.select('''
*,
energy_service(*),
fin_service(*),
entertainment_service(*),
service_file(*)
''')
.eq('id', newId)
.single();
return ServiceModel.fromMap(updatedServiceData);
} catch (e) { } catch (e) {
// Qui potresti aggiungere una logica di "rollback manuale" se necessario // Qui potresti aggiungere una logica di "rollback manuale" se necessario
throw Exception('Errore durante il salvataggio corazzato: $e'); throw Exception('Errore durante il salvataggio corazzato: $e');
@@ -235,6 +259,69 @@ class ServicesRepository {
} }
} }
/// Ascolta in tempo reale i file caricati per una pratica
Stream<List<ServiceFileModel>> getServiceFilesStream(String serviceId) {
return _supabase
.from('service_file')
.stream(primaryKey: ['id'])
.eq('service_id', serviceId)
.order('created_at', ascending: false)
.map(
(listOfMaps) =>
listOfMaps.map((map) => ServiceFileModel.fromMap(map)).toList(),
);
}
Future<ServiceFileModel> uploadAndRegisterServiceFile({
required String serviceId,
required PlatformFile pickedFile,
}) async {
final cleanFileName = pickedFile.name.replaceAll(
RegExp(r'[^a-zA-Z0-9\.\-]'),
'_',
);
final storagePath =
'$companyId/services/$serviceId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = pickedFile.size;
final fileToSave = ServiceFileModel(
serviceId: serviceId,
name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(),
storagePath: storagePath,
fileSize: fileSize,
);
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
? 'application/pdf'
: 'image/${fileToSave.extension}';
try {
// Usiamo bytes invece del path per massima compatibilità
if (pickedFile.bytes == null && pickedFile.path == null) {
throw 'Impossibile leggere il contenuto del file';
}
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
if (pickedFile.bytes != null) {
await _supabase.storage
.from('documents')
.uploadBinary(
storagePath,
pickedFile.bytes!,
fileOptions: FileOptions(contentType: mimeType, upsert: true),
);
}
final response = await _supabase
.from('service_file')
.insert(fileToSave.toMap())
.select()
.single();
return ServiceFileModel.fromMap(response);
} catch (e) {
throw 'Errore durante l\'upload: $e';
}
}
Future<void> copyFileToCustomer({ Future<void> copyFileToCustomer({
required ServiceFileModel file, required ServiceFileModel file,
required String customerId, required String customerId,
@@ -242,10 +329,31 @@ class ServicesRepository {
CustomerFileModel fileToCopy = CustomerFileModel( CustomerFileModel fileToCopy = CustomerFileModel(
customerId: customerId, customerId: customerId,
name: file.name, name: file.name,
url: file.url, storagePath: file.storagePath,
extension: file.extension, extension: file.extension,
fileSize: file.fileSize, fileSize: file.fileSize,
); );
await _customerRepository.saveCustomerFile(fileToCopy); await _customerRepository.saveFileReference(fileToCopy);
}
Future<void> deleteServiceFiles(List<ServiceFileModel> files) async {
if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi
final List<String> idsToDelete = files.map((f) => f.id!).toList();
final List<String> storagePaths = files.map((f) => f.storagePath).toList();
try {
await _supabase.from('service_file').delete().inFilter('id', idsToDelete);
await _supabase.storage.from('documents').remove(storagePaths);
debugPrint("Eliminati con successo ${files.length} file.");
} on PostgrestException catch (e) {
debugPrint("Errore DB: ${e.message}");
throw 'Errore database: ${e.message}';
} catch (e) {
debugPrint("Errore generico: $e");
throw 'Errore durante l\'eliminazione dei file: $e';
}
} }
} }

View File

@@ -7,7 +7,7 @@ class ServiceFileModel extends Equatable {
final DateTime? createdAt; final DateTime? createdAt;
final String name; final String name;
final String extension; final String extension;
final String url; final String storagePath;
final String serviceId; final String serviceId;
final int fileSize; final int fileSize;
final Uint8List? localBytes; final Uint8List? localBytes;
@@ -17,12 +17,14 @@ class ServiceFileModel extends Equatable {
this.createdAt, this.createdAt,
required this.name, required this.name,
required this.extension, required this.extension,
required this.url, required this.storagePath,
required this.serviceId, required this.serviceId,
required this.fileSize, required this.fileSize,
this.localBytes, this.localBytes,
}); });
bool get isLocal => localBytes != null;
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB) // Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
String get sizeFormatted { String get sizeFormatted {
if (fileSize <= 0) return "0 B"; if (fileSize <= 0) return "0 B";
@@ -40,7 +42,7 @@ class ServiceFileModel extends Equatable {
DateTime? createdAt, DateTime? createdAt,
String? name, String? name,
String? extension, String? extension,
String? url, String? storagePath,
String? serviceId, String? serviceId,
int? fileSize, int? fileSize,
Uint8List? localBytes, Uint8List? localBytes,
@@ -50,7 +52,7 @@ class ServiceFileModel extends Equatable {
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
name: name ?? this.name, name: name ?? this.name,
extension: extension ?? this.extension, extension: extension ?? this.extension,
url: url ?? this.url, storagePath: storagePath ?? this.storagePath,
serviceId: serviceId ?? this.serviceId, serviceId: serviceId ?? this.serviceId,
fileSize: fileSize ?? this.fileSize, fileSize: fileSize ?? this.fileSize,
localBytes: localBytes ?? this.localBytes, localBytes: localBytes ?? this.localBytes,
@@ -65,7 +67,7 @@ class ServiceFileModel extends Equatable {
: null, : null,
name: map['name'] ?? '', name: map['name'] ?? '',
extension: map['extension'] ?? '', extension: map['extension'] ?? '',
url: map['url'] ?? '', storagePath: map['storage_path'] ?? '',
serviceId: map['service_id']?.toString() ?? '', serviceId: map['service_id']?.toString() ?? '',
fileSize: map['file_size'] is int fileSize: map['file_size'] is int
? map['file_size'] ? map['file_size']
@@ -78,7 +80,7 @@ class ServiceFileModel extends Equatable {
if (id != null) 'id': id, if (id != null) 'id': id,
'name': name, 'name': name,
'extension': extension, 'extension': extension,
'url': url, 'storage_path': storagePath,
'service_id': serviceId, 'service_id': serviceId,
'file_size': fileSize, 'file_size': fileSize,
}; };
@@ -90,7 +92,7 @@ class ServiceFileModel extends Equatable {
createdAt, createdAt,
name, name,
extension, extension,
url, storagePath,
serviceId, serviceId,
fileSize, fileSize,
localBytes, localBytes,

View File

@@ -1,8 +1,11 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.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:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/widgets/image_viewer_widget.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart'; import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/services/blocs/service_files_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_file_model.dart'; import 'package:flux/features/services/models/service_file_model.dart';
@@ -19,121 +22,315 @@ class AttachmentsSection extends StatelessWidget {
); );
if (result != null && context.mounted) { if (result != null && context.mounted) {
context.read<ServicesCubit>().addAttachments(result.files); context.read<ServiceFilesBloc>().add(AddServiceFilesEvent(result.files));
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<ServicesCubit, ServicesState>( ServiceFilesBloc serviceFilesBloc = BlocProvider.of<ServiceFilesBloc>(
builder: (context, state) { context,
final files = state.currentService?.files ?? []; );
return Column( return BlocListener<ServicesCubit, ServicesState>(
crossAxisAlignment: CrossAxisAlignment.start, listenWhen: (previous, current) =>
children: [ previous.currentService?.id == null &&
Row( current.currentService?.id != null,
mainAxisAlignment: MainAxisAlignment.spaceBetween, listener: (context, state) {
children: [ // FIGASSA! La pratica è stata salvata e ora ha un ID.
Text( // Diciamo al Bloc dei file di agganciarsi al database.
"DOCUMENTI ALLEGATI", final newId = state.currentService!.id!;
style: TextStyle( context.read<ServiceFilesBloc>().add(ServiceSavedEvent(newId));
fontWeight: FontWeight.bold, },
color: Theme.of(context).colorScheme.primary, child: BlocBuilder<ServiceFilesBloc, ServiceFilesState>(
letterSpacing: 1.2, builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER SEZIONE ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"DOCUMENTI ALLEGATI",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
letterSpacing: 1.2,
),
), ),
), Row(
OutlinedButton.icon( children: [
icon: const Icon(Icons.attach_file), OutlinedButton.icon(
label: const Text("Aggiungi File"), icon: const Icon(Icons.attach_file),
onPressed: () => _pickFiles(context), label: const Text("Aggiungi File"),
), onPressed: () => _pickFiles(context),
],
),
const SizedBox(height: 12),
if (files.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: files.length,
itemBuilder: (context, index) {
final file = files[index];
// Calcoliamo la dimensione in MB
final sizeMb = (file.fileSize / (1024 * 1024))
.toStringAsFixed(2);
// Scegliamo un'icona in base al tipo di file
final isPdf = file.extension.toLowerCase() == 'pdf';
return GestureDetector(
onTap: () => _handleSingleClick(context, file),
onDoubleTap: () => _handleDoubleClick(context, file),
child: Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Colors.grey.shade300),
), ),
child: ListTile( if (!context
leading: Icon( .read<SessionCubit>()
isPdf ? Icons.picture_as_pdf : Icons.image, .state
color: isPdf ? Colors.red : Colors.blue, .isMobileDevice) ...[
size: 32, const SizedBox(width: 12),
), ElevatedButton.icon(
title: Text( onPressed: () => _handleGenerateQr(context),
file.name, icon: const Icon(Icons.qr_code),
maxLines: 1, label: const Text("GENERA QR"),
overflow: TextOverflow.ellipsis, style: ElevatedButton.styleFrom(
), backgroundColor: Theme.of(
subtitle: Text("$sizeMb MB"), context,
trailing: IconButton( ).colorScheme.primary.withValues(alpha: 0.1),
icon: const Icon( foregroundColor: Theme.of(
Icons.delete_outline, context,
color: Colors.red, ).colorScheme.primary,
elevation: 0,
), ),
onPressed: () => context
.read<ServicesCubit>()
.removeAttachment(index),
), ),
],
],
),
],
),
const SizedBox(height: 12),
// --- LISTA VUOTA ---
if (state.allFiles.isEmpty) // Furbata: usiamo allFiles.isEmpty!
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade300,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade50,
),
child: const Text(
"Nessun documento allegato alla bozza.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
// --- LISTA PIENA ---
else ...[
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: state.allFiles.length,
itemBuilder: (context, index) {
final file = state.allFiles[index];
final sizeMb = (file.fileSize / (1024 * 1024))
.toStringAsFixed(2);
final isPdf = file.extension.toLowerCase() == 'pdf';
final isSelected = state.selectedFiles.contains(file);
return GestureDetector(
onTap: () => serviceFilesBloc.add(
ToggleServiceFileSelectionEvent(file),
),
onDoubleTap: () => _handleDoubleClick(context, file),
child: Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
// UX Fina: cambiamo colore del bordo se selezionato
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
),
// UX Fina: Sfondo leggermente colorato se selezionato
color: isSelected
? Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.05)
: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: Icon(
isSelected
? Icons.check_box
: Icons.check_box_outline_blank,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
title: Text(
file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
file.isLocal ? "$sizeMb MB • (Nuovo)" : " MB",
),
trailing: Icon(
isPdf ? Icons.picture_as_pdf : Icons.image,
color: isPdf ? Colors.red : Colors.blue,
size: 32,
),
),
),
);
},
),
// --- PANNELLO AZIONI CONTESTUALI (LA MAGIA) ---
// Appare SOLO se c'è almeno un file selezionato
if (state.selectedFiles.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
children: [
// Contatore
Text(
"${state.selectedFiles.length} file selezionati",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const Spacer(),
// Bottone Elimina
TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
icon: const Icon(Icons.delete_outline),
label: const Text("Elimina"),
onPressed: () {
// Qui lancerai l'evento per eliminare i file selezionati!
// Es: serviceFilesBloc.add(DeleteSelectedFilesEvent());
},
),
const SizedBox(width: 8),
// Bottone Copia
ElevatedButton.icon(
icon: const Icon(Icons.copy),
label: const Text("Copia in Cliente"),
onPressed: () => saveAndCopyFilesToCustomer(
context,
state.selectedFiles,
),
),
],
), ),
), ),
); ),
}, ],
), ],
], );
); },
}, ),
); );
} }
Future<void> _handleGenerateQr(BuildContext context) async {
final cubit = context.read<ServicesCubit>();
var currentService = cubit.state.currentService;
// 1. CATTURIAMO IL BLOC MENTRE SIAMO ANCORA NELLA PAGINA
final serviceFilesBloc = context.read<ServiceFilesBloc>();
// 2. SE LA PRATICA E' NUOVA (Manca l'ID)
if (currentService == null || currentService.id == null) {
// NIENTE BlocListener qui! Solo un semplice Dialog di conferma
final bool? confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Salvataggio Necessario"),
content: const Text(
"Per generare il QR Code e caricare file dal telefono, la pratica deve essere prima salvata in BOZZA.\n\nVuoi salvare ora?",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text("Salva in Bozza"),
),
],
),
);
if (confirm != true) return; // Utente ha annullato
// Salviamo forzatamente in bozza
await cubit.saveCurrentService(
isBozza: true,
shouldPop: false,
files: serviceFilesBloc.state.localFiles,
);
// Recuperiamo il servizio aggiornato con l'ID!
currentService = cubit.state.currentService;
if (currentService?.id == null) return;
}
// 3. MOSTRIAMO IL QR CODE (Con il Ponte e l'Auto-Chiusura!)
if (context.mounted) {
final nomePratica = "Pratica ${currentService?.customerDisplayName ?? ''}"
.trim();
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
// INIETTIAMO IL BLOC NEL CONTESTO DEL DIALOG ALIENO
value: serviceFilesBloc,
// ORA METTIAMO L'AUTO-CHIUSURA SUL QR CODE!
child: BlocListener<ServiceFilesBloc, ServiceFilesState>(
listener: (context, state) {
// Se arrivano file remoti e lo stato è success, chiudiamo il QR!
// (Nota: usiamo dialogContext per assicurarci di chiudere il popup giusto)
if (state.status == ServiceFilesStatus.success &&
state.remoteFiles.isNotEmpty) {
Navigator.of(dialogContext).pop();
}
},
child: QrUploadDialog(
deepLinkUrl:
'fluxapp:///service/${currentService!.id}/upload?name=${Uri.encodeComponent(nomePratica)}',
title: 'Scatta per\n$nomePratica',
),
),
),
);
}
}
// --- LOGICA DI COPIA AL CLIENTE --- // --- LOGICA DI COPIA AL CLIENTE ---
void _handleSingleClick(BuildContext context, ServiceFileModel file) { void saveAndCopyFilesToCustomer(
BuildContext context,
List<ServiceFileModel> files,
) {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text("Copia nei documenti Cliente"), title: const Text("Copia nei documenti Cliente"),
content: const Text( content: const Text(
"Vuoi copiare questo file nell'anagrafica del cliente? \n\n" "Vuoi copiare i file selezionati nell'anagrafica del cliente? \n\n"
"Attenzione: per procedere, la pratica attuale verrà prima salvata in stato BOZZA.", "Attenzione: per procedere, la pratica attuale verrà prima salvata in stato BOZZA.",
), ),
actions: [ actions: [
@@ -145,7 +342,7 @@ class AttachmentsSection extends StatelessWidget {
onPressed: () { onPressed: () {
Navigator.pop(ctx); Navigator.pop(ctx);
// 1. Diciamo al Cubit di salvare in Bozza e fare la copia // 1. Diciamo al Cubit di salvare in Bozza e fare la copia
context.read<ServicesCubit>().saveAndCopyFileToCustomer(file); context.read<ServicesCubit>().saveAndCopyFileToCustomer(files);
}, },
child: const Text("Salva e Copia"), child: const Text("Salva e Copia"),
), ),
@@ -169,11 +366,15 @@ class AttachmentsSection extends StatelessWidget {
height: MediaQuery.of(context).size.height * 0.8, height: MediaQuery.of(context).size.height * 0.8,
child: file.isPdf child: file.isPdf
? PdfViewerWidget( ? PdfViewerWidget(
storagePath: file.url.isNotEmpty ? file.url : null, storagePath: file.storagePath.isNotEmpty
? file.storagePath
: null,
bytes: file.localBytes, bytes: file.localBytes,
) )
: ImageViewerWidget( : ImageViewerWidget(
storagePath: file.url.isNotEmpty ? file.url : null, storagePath: file.storagePath.isNotEmpty
? file.storagePath
: null,
bytes: file.localBytes, bytes: file.localBytes,
), ),
), ),

View File

@@ -51,7 +51,8 @@ class _ServiceFormScreenState extends State<ServiceFormScreen> {
), ),
); );
Navigator.pop(context); Navigator.pop(context);
} else if (state.status == ServicesStatus.failure) { }
if (state.status == ServicesStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text("Errore: ${state.errorMessage ?? ''}"), content: Text("Errore: ${state.errorMessage ?? ''}"),
@@ -59,6 +60,14 @@ class _ServiceFormScreenState extends State<ServiceFormScreen> {
), ),
); );
} }
if (state.status == ServicesStatus.savedNoPop) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Pratica salvata con successo!"),
backgroundColor: Colors.green,
),
);
}
}, },
builder: (context, state) { builder: (context, state) {
final service = state.currentService; final service = state.currentService;

View File

@@ -0,0 +1,302 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/services/blocs/service_files_bloc.dart';
class ServiceMobileUploadScreen extends StatefulWidget {
final String serviceId;
final String serviceName;
const ServiceMobileUploadScreen({
super.key,
required this.serviceId,
required this.serviceName,
});
@override
State<ServiceMobileUploadScreen> createState() =>
_ServiceMobileUploadScreenState();
}
class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
// 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<ServiceFilesBloc, ServiceFilesState>(
listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == ServiceFilesStatus.success && _isUploading) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Tutti i file caricati con successo! ✅"),
),
);
Navigator.of(context).pop();
}
if (state.status == ServiceFilesStatus.failure) {
setState(() => _isUploading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Errore: ${state.error}")));
}
},
child: Scaffold(
appBar: AppBar(
title: Text("Upload Pratica:\n${widget.serviceName}"),
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 come la galleria dell'iPhone
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
? 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 (Impedisce tap multipli) ---
if (_isUploading)
Container(
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(); // Sicuro anche per Web!
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); // Unifichiamo tutto in un dart:io File
});
}
}
Future<void> _handleFilePicker() async {
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
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);
// Diciamo al BLoC di caricare tutti i file.
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
final bloc = context.read<ServiceFilesBloc>();
bloc.add(UploadMultipleServiceFilesEvent(_stagedFiles));
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
}
}

View File

@@ -1,3 +1,6 @@
import 'dart:io';
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';
@@ -109,11 +112,32 @@ class _FluxAppState extends State<FluxApp> {
super.initState(); super.initState();
// Creiamo il router passandogli il Cubit per i redirect // Creiamo il router passandogli il Cubit per i redirect
_router = AppRouter.createRouter(context.read<SessionCubit>()); _router = AppRouter.createRouter(context.read<SessionCubit>());
GetIt.I.get<SessionCubit>().setIsMobileDevice(isMobileDevice(context));
}
bool isMobileDevice(BuildContext context) {
if (kIsWeb) {
return false; // Il web non lo consideriamo "mobile nativo" per i deep link
}
return Platform.isAndroid || Platform.isIOS;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<SessionCubit, SessionState>( // Il BlocConsumer unisce Listener e Builder in un colpo solo!
return BlocConsumer<SessionCubit, SessionState>(
// --- PARTE LISTENER (Il colpo di clacson in background) ---
listenWhen: (previous, current) =>
previous.status != SessionStatus.authenticated &&
current.status == SessionStatus.authenticated,
listener: (context, state) {
// BAM! L'utente è dentro. Pre-carichiamo i Cubit leggeri.
context.read<StoreCubit>().loadStores();
context.read<StaffCubit>().loadAllStaff();
context.read<ProvidersCubit>().loadProviders();
},
// --- PARTE BUILDER (La UI che viene disegnata a schermo) ---
builder: (context, sessionState) { builder: (context, sessionState) {
if (sessionState.status == SessionStatus.initial) { if (sessionState.status == SessionStatus.initial) {
return _buildLoadingScreen(); return _buildLoadingScreen();

View File

@@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <gtk/gtk_plugin.h> #include <gtk/gtk_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar = g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar); gtk_plugin_register_with_registrar(gtk_registrar);

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
gtk gtk
url_launcher_linux url_launcher_linux
) )

View File

@@ -7,6 +7,7 @@ import Foundation
import app_links import app_links
import file_picker import file_picker
import file_selector_macos
import pdfx import pdfx
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
@@ -14,6 +15,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin")) PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

View File

@@ -3,6 +3,8 @@ PODS:
- FlutterMacOS - FlutterMacOS
- file_picker (0.0.1): - file_picker (0.0.1):
- FlutterMacOS - FlutterMacOS
- file_selector_macos (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
- pdfx (1.0.0): - pdfx (1.0.0):
- FlutterMacOS - FlutterMacOS
@@ -15,6 +17,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- pdfx (from `Flutter/ephemeral/.symlinks/plugins/pdfx/macos`) - pdfx (from `Flutter/ephemeral/.symlinks/plugins/pdfx/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -25,6 +28,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/app_links/macos :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos
file_picker: file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
FlutterMacOS: FlutterMacOS:
:path: Flutter/ephemeral :path: Flutter/ephemeral
pdfx: pdfx:
@@ -37,6 +42,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
pdfx: 1e79f57f7a6ce2f4a4c30f21fa54d3dc82441b51 pdfx: 1e79f57f7a6ce2f4a4c30f21fa54d3dc82441b51
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb

View File

@@ -18,5 +18,10 @@
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -28,5 +28,7 @@
<string>MainMenu</string> <string>MainMenu</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<key>NSCameraUsageDescription</key>
<string>FLUX ha bisogno della fotocamera per scansionare i QR Code e acquisire documenti.</string>
</dict> </dict>
</plist> </plist>

View File

@@ -6,13 +6,18 @@
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key> <key>com.apple.security.files.downloads.read-write</key>
<true/> <true/>
<key>com.apple.security.device.camera</key>
<key>com.apple.security.network.client</key>
<true/> <true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -185,6 +185,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.0.2" version: "11.0.2"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -308,10 +340,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: hooks name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.2" version: "1.0.3"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -328,6 +360,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622"
url: "https://pub.dev"
source: hosted
version: "0.8.13+16"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
internet_file: internet_file:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -544,6 +640,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.9.2" version: "2.9.2"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -608,6 +752,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
realtime_client: realtime_client:
dependency: transitive dependency: transitive
description: description:
@@ -616,6 +776,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.3" version: "2.7.3"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
retry: retry:
dependency: transitive dependency: transitive
description: description:

View File

@@ -18,9 +18,12 @@ dependencies:
get_it: ^9.2.1 get_it: ^9.2.1
go_router: ^17.2.0 go_router: ^17.2.0
google_fonts: ^8.0.2 google_fonts: ^8.0.2
image_picker: ^1.2.1
internet_file: ^1.3.0 internet_file: ^1.3.0
intl: ^0.20.2 intl: ^0.20.2
pdfx: ^2.9.2 pdfx: ^2.9.2
permission_handler: ^12.0.1
qr_flutter: ^4.1.0
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
supabase_flutter: ^2.12.2 supabase_flutter: ^2.12.2

View File

@@ -7,14 +7,20 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h> #include <app_links/app_links_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <pdfx/pdfx_plugin.h> #include <pdfx/pdfx_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar( AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi")); registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
PdfxPluginRegisterWithRegistrar( PdfxPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PdfxPlugin")); registry->GetRegistrarForPlugin("PdfxPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@@ -4,7 +4,9 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
app_links app_links
file_selector_windows
pdfx pdfx
permission_handler_windows
url_launcher_windows url_launcher_windows
) )