feat-add-files-from-qr (#8)
Reviewed-on: http://catelliub.zapto.org:3000/brontomark/flux/pulls/8 Co-authored-by: Mark M2 Macbook <marco@catelli.it> Co-committed-by: Mark M2 Macbook <marco@catelli.it>
This commit is contained in:
@@ -23,6 +23,12 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<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 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>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
create: (context) =>
|
||||||
|
ServiceFilesBloc(serviceId: serviceId ?? existingService?.id),
|
||||||
|
child: ServiceFormScreen(
|
||||||
serviceId: serviceId ?? existingService?.id,
|
serviceId: serviceId ?? existingService?.id,
|
||||||
existingService: existingService,
|
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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
9
lib/core/utils/functions.dart
Normal file
9
lib/core/utils/functions.dart
Normal 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
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
93
lib/core/widgets/qr_upload_dialog.dart
Normal file
93
lib/core/widgets/qr_upload_dialog.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
139
lib/features/customers/blocs/customer_files_bloc.dart
Normal file
139
lib/features/customers/blocs/customer_files_bloc.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
30
lib/features/customers/blocs/customer_files_events.dart
Normal file
30
lib/features/customers/blocs/customer_files_events.dart
Normal 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);
|
||||||
|
}
|
||||||
34
lib/features/customers/blocs/customer_files_state.dart
Normal file
34
lib/features/customers/blocs/customer_files_state.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +143,8 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDocumentSection() {
|
Widget _buildDocumentSection() {
|
||||||
|
return BlocBuilder<CustomerFilesBloc, CustomerFilesState>(
|
||||||
|
builder: (context, state) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -172,17 +159,63 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
color: context.accent,
|
color: context.accent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// ZONA BOTTONI: Li mettiamo in una Row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Bottone classico: c'è sempre (carica da disco locale)
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: _pickAndUpload,
|
onPressed: _pickAndUpload,
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
label: const Text("CARICA FILE"),
|
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),
|
const SizedBox(height: 20),
|
||||||
if (_isLoadingFiles)
|
if (state.status == CustomerFilesStatus.loading)
|
||||||
const Center(child: CircularProgressIndicator())
|
const Center(child: CircularProgressIndicator())
|
||||||
else if (_files.isEmpty)
|
else if (state.customerFiles.isEmpty)
|
||||||
const Center(child: Text("Nessun documento presente"))
|
const Center(child: Text("Nessun documento presente"))
|
||||||
else
|
else
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -193,12 +226,15 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
|||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
childAspectRatio: 1.2,
|
childAspectRatio: 1.2,
|
||||||
),
|
),
|
||||||
itemCount: _files.length,
|
itemCount: state.customerFiles.length,
|
||||||
itemBuilder: (context, index) => _FileCard(file: _files[index]),
|
itemBuilder: (context, index) =>
|
||||||
|
_FileCard(file: state.customerFiles[index], state: state),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _infoTile(IconData icon, String label, String value) {
|
Widget _infoTile(IconData icon, String label, String value) {
|
||||||
@@ -223,15 +259,28 @@ 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(
|
||||||
|
onTap: () => context.read<CustomerFilesBloc>().add(
|
||||||
|
ToggleCustomerFileSelectionEvent(file),
|
||||||
|
),
|
||||||
|
onDoubleTap: () => _handleDoubleClickOnFile(context, file),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.background,
|
color: context.background,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -240,7 +289,11 @@ class _FileCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(_getFileIcon(file.extension), size: 48, color: context.accent),
|
Icon(
|
||||||
|
_getFileIcon(file.extension),
|
||||||
|
size: 48,
|
||||||
|
color: context.accent,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
@@ -248,9 +301,21 @@ class _FileCard extends StatelessWidget {
|
|||||||
file.name,
|
file.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
304
lib/features/customers/ui/customer_mobile_upload_screen.dart
Normal file
304
lib/features/customers/ui/customer_mobile_upload_screen.dart
Normal 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"!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
232
lib/features/services/blocs/service_files_bloc.dart
Normal file
232
lib/features/services/blocs/service_files_bloc.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
56
lib/features/services/blocs/service_files_events.dart
Normal file
56
lib/features/services/blocs/service_files_events.dart
Normal 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);
|
||||||
|
}
|
||||||
52
lib/features/services/blocs/service_files_state.dart
Normal file
52
lib/features/services/blocs/service_files_state.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,19 +22,32 @@ 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 BlocListener<ServicesCubit, ServicesState>(
|
||||||
|
listenWhen: (previous, current) =>
|
||||||
|
previous.currentService?.id == null &&
|
||||||
|
current.currentService?.id != null,
|
||||||
|
listener: (context, state) {
|
||||||
|
// FIGASSA! La pratica è stata salvata e ora ha un ID.
|
||||||
|
// Diciamo al Bloc dei file di agganciarsi al database.
|
||||||
|
final newId = state.currentService!.id!;
|
||||||
|
context.read<ServiceFilesBloc>().add(ServiceSavedEvent(newId));
|
||||||
|
},
|
||||||
|
child: BlocBuilder<ServiceFilesBloc, ServiceFilesState>(
|
||||||
|
builder: (context, state) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// --- HEADER SEZIONE ---
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -43,16 +59,41 @@ class AttachmentsSection extends StatelessWidget {
|
|||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.attach_file),
|
icon: const Icon(Icons.attach_file),
|
||||||
label: const Text("Aggiungi File"),
|
label: const Text("Aggiungi File"),
|
||||||
onPressed: () => _pickFiles(context),
|
onPressed: () => _pickFiles(context),
|
||||||
),
|
),
|
||||||
|
if (!context
|
||||||
|
.read<SessionCubit>()
|
||||||
|
.state
|
||||||
|
.isMobileDevice) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => _handleGenerateQr(context),
|
||||||
|
icon: const Icon(Icons.qr_code),
|
||||||
|
label: const Text("GENERA QR"),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withValues(alpha: 0.1),
|
||||||
|
foregroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
if (files.isEmpty)
|
// --- LISTA VUOTA ---
|
||||||
|
if (state.allFiles.isEmpty) // Furbata: usiamo allFiles.isEmpty!
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -70,34 +111,49 @@ class AttachmentsSection extends StatelessWidget {
|
|||||||
style: TextStyle(color: Colors.grey),
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
// --- LISTA PIENA ---
|
||||||
|
else ...[
|
||||||
ListView.builder(
|
ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: files.length,
|
itemCount: state.allFiles.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final file = files[index];
|
final file = state.allFiles[index];
|
||||||
// Calcoliamo la dimensione in MB
|
|
||||||
final sizeMb = (file.fileSize / (1024 * 1024))
|
final sizeMb = (file.fileSize / (1024 * 1024))
|
||||||
.toStringAsFixed(2);
|
.toStringAsFixed(2);
|
||||||
|
|
||||||
// Scegliamo un'icona in base al tipo di file
|
|
||||||
final isPdf = file.extension.toLowerCase() == 'pdf';
|
final isPdf = file.extension.toLowerCase() == 'pdf';
|
||||||
|
final isSelected = state.selectedFiles.contains(file);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _handleSingleClick(context, file),
|
onTap: () => serviceFilesBloc.add(
|
||||||
|
ToggleServiceFileSelectionEvent(file),
|
||||||
|
),
|
||||||
onDoubleTap: () => _handleDoubleClick(context, file),
|
onDoubleTap: () => _handleDoubleClick(context, file),
|
||||||
child: Card(
|
child: Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
// UX Fina: cambiamo colore del bordo se selezionato
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
side: BorderSide(color: Colors.grey.shade300),
|
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(
|
child: ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
isPdf ? Icons.picture_as_pdf : Icons.image,
|
isSelected
|
||||||
color: isPdf ? Colors.red : Colors.blue,
|
? Icons.check_box
|
||||||
|
: Icons.check_box_outline_blank,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -105,35 +161,176 @@ class AttachmentsSection extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text("$sizeMb MB"),
|
subtitle: Text(
|
||||||
trailing: IconButton(
|
file.isLocal ? "$sizeMb MB • (Nuovo)" : " MB",
|
||||||
icon: const Icon(
|
|
||||||
Icons.delete_outline,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
),
|
||||||
onPressed: () => context
|
trailing: Icon(
|
||||||
.read<ServicesCubit>()
|
isPdf ? Icons.picture_as_pdf : Icons.image,
|
||||||
.removeAttachment(index),
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
172
pubspec.lock
172
pubspec.lock
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user