reworked operation (#12)
Reviewed-on: #12 Co-authored-by: Mark M2 Macbook <marco@catelli.it> Co-committed-by: Mark M2 Macbook <marco@catelli.it>
This commit is contained in:
@@ -1 +1,16 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
# Escludiamo i file generati per le lingue, così il linter non ci entra proprio
|
||||
- "lib/generated/**"
|
||||
- "lib/l10n/*.dart"
|
||||
- "**/*.g.dart" # Già che ci siamo escludiamo tutti i file generati (tipo quelli di JsonSerializable)
|
||||
- "**/*.freezed.dart"
|
||||
|
||||
linter:
|
||||
rules:
|
||||
diagnostic_describe_all_properties: false
|
||||
public_member_api_docs: false
|
||||
# Ti consiglio di aggiungere anche questa se usi molto i file generati
|
||||
avoid_relative_lib_imports: true
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionUrl=https\://operations.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
3
l10n.yaml
Normal file
3
l10n.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
arb-dir: lib/l10n
|
||||
template-arb-file: app_it.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
@@ -24,7 +24,7 @@ class CoreRepository {
|
||||
return CompanyModel.fromMap(response);
|
||||
} catch (e) {
|
||||
debugPrint('Errore recupero azienda: $e');
|
||||
throw Exception('Errore recupero azienda: $e');
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class CoreRepository {
|
||||
if (response == null) return null;
|
||||
return CompanyModel.fromMap(response);
|
||||
} catch (e) {
|
||||
debugPrint('Errore recupero azienda per ID: $e');
|
||||
debugPrint('$e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -50,12 +50,12 @@ class CoreRepository {
|
||||
.select()
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true) // Buona pratica
|
||||
.order('nome'); // O come si chiama il campo nome
|
||||
.order('name'); // O come si chiama il campo nome
|
||||
|
||||
return (response as List).map((s) => StoreModel.fromMap(s)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('Errore recupero negozi: $e');
|
||||
throw Exception('Errore recupero negozi: $e');
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class CoreRepository {
|
||||
return StaffMemberModel.fromMap(response);
|
||||
} catch (e) {
|
||||
debugPrint('Errore recupero profilo staff: $e');
|
||||
throw Exception('Errore recupero profilo staff: $e');
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ class CoreRepository {
|
||||
return CompanyModel.fromMap(response);
|
||||
} catch (e) {
|
||||
debugPrint('Creazione azienda fallita: $e');
|
||||
throw Exception('Creazione azienda fallita: $e');
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ class CoreRepository {
|
||||
return StoreModel.fromMap(response);
|
||||
} catch (e) {
|
||||
debugPrint('Creazione negozio fallita: $e');
|
||||
throw Exception('Creazione negozio fallita: $e');
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class CoreRepository {
|
||||
return StaffMemberModel.fromMap(response);
|
||||
} catch (e) {
|
||||
debugPrint('Creazione profilo staff fallita: $e');
|
||||
throw Exception('Creazione profilo staff fallita: $e');
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class AppShell extends StatelessWidget {
|
||||
@@ -43,21 +44,21 @@ class AppShell extends StatelessWidget {
|
||||
onDestinationSelected: (index) =>
|
||||
_onItemTapped(index, context),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
destinations: [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: Text('Dashboard'),
|
||||
label: Text(context.l10n.commonDashboard),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.folder_special_outlined),
|
||||
selectedIcon: Icon(Icons.folder_special),
|
||||
label: Text('Anagrafiche'),
|
||||
label: Text(context.l10n.commonMasterData),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: Text('Impostazioni'),
|
||||
label: Text(context.l10n.commonSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -73,21 +74,21 @@ class AppShell extends StatelessWidget {
|
||||
: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (index) => _onItemTapped(index, context),
|
||||
destinations: const [
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Dashboard',
|
||||
label: context.l10n.commonDashboard,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.folder_special_outlined),
|
||||
selectedIcon: Icon(Icons.folder_special),
|
||||
label: 'Anagrafiche',
|
||||
label: context.l10n.commonMasterData,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Impostazioni',
|
||||
label: context.l10n.commonSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,22 +4,31 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/data/core_repository.dart';
|
||||
import 'package:flux/core/layout/app_shell.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/core/widgets/set_password_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/blocs/customers_cubit.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_mobile_upload_screen.dart';
|
||||
import 'package:flux/features/customers/ui/customers_content.dart';
|
||||
import 'package:flux/features/home/ui/home_screen.dart';
|
||||
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||
import 'package:flux/features/master_data/products/ui/products_screen.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/ui/providers_master_data_screen.dart';
|
||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
||||
import 'package:flux/features/master_data/store/ui/stores_screen.dart';
|
||||
import 'package:flux/features/onboarding/blocs/onboarding_cubit.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/ui/service_form_screen/service_form_screen.dart';
|
||||
import 'package:flux/features/services/ui/service_form_screen/service_mobile_upload_screen.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:flux/features/operations/ui/operation_form_screen.dart';
|
||||
import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart';
|
||||
import 'package:flux/features/operations/ui/operations_screen.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -92,23 +101,24 @@ class AppRouter {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'products', // Diventa /master-data/products
|
||||
builder: (context, state) => const ProductsScreen(),
|
||||
builder: (context, state) {
|
||||
context.read<ProductsCubit>().refreshCubit();
|
||||
|
||||
return const ProductsScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'staff', // Diventa /master-data/staff
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Center(child: Text("Lista Staff"))),
|
||||
builder: (context, state) => const StaffScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'stores', // Diventa /master-data/stores
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Center(child: Text("Lista Negozi"))),
|
||||
builder: (context, state) => const StoresScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'providers', // Diventa /master-data/providers
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(child: Text("Lista Fornitori")),
|
||||
),
|
||||
builder: (context, state) =>
|
||||
const ProvidersMasterDataScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -117,7 +127,7 @@ class AppRouter {
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: AppBar(title: const Text("Impostazioni")),
|
||||
appBar: AppBar(title: Text(context.l10n.commonSettings)),
|
||||
body: Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => context.read<SessionCubit>().signOut(),
|
||||
@@ -127,15 +137,19 @@ class AppRouter {
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/operations',
|
||||
builder: (context, state) => const OperationsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/customers',
|
||||
builder: (context, state) =>
|
||||
const CustomersContent(), // O come si chiama il tuo widget della lista!
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) ---
|
||||
GoRoute(
|
||||
path: '/customers',
|
||||
builder: (context, state) =>
|
||||
const CustomersContent(), // O come si chiama il tuo widget della lista!
|
||||
),
|
||||
GoRoute(
|
||||
path: '/customer/:id',
|
||||
builder: (context, state) {
|
||||
@@ -161,31 +175,58 @@ class AppRouter {
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/service-form',
|
||||
name: 'service-form',
|
||||
path: '/operation-form',
|
||||
name: 'operation-form',
|
||||
builder: (context, state) {
|
||||
final existingService = state.extra as ServiceModel?;
|
||||
final serviceId = state.uri.queryParameters['serviceId'];
|
||||
final existingOperation = state.extra as OperationModel?;
|
||||
final operationId = state.uri.queryParameters['operationId'];
|
||||
final currentStoreId = GetIt.I
|
||||
.get<SessionCubit>()
|
||||
.state
|
||||
.currentStore!
|
||||
.id!;
|
||||
context.read<CustomersCubit>().loadCustomers();
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
currentStoreId,
|
||||
);
|
||||
context.read<ProductsCubit>().loadModels();
|
||||
context.read<ProductsCubit>().loadBrands();
|
||||
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
ServiceFilesBloc(serviceId: serviceId ?? existingService?.id),
|
||||
child: ServiceFormScreen(
|
||||
serviceId: serviceId ?? existingService?.id,
|
||||
existingService: existingService,
|
||||
create: (context) => OperationFilesBloc(
|
||||
operationId: operationId ?? existingOperation?.id,
|
||||
),
|
||||
child: OperationFormScreen(
|
||||
operationId: operationId ?? existingOperation?.id,
|
||||
existingOperation: existingOperation,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/service/:id/upload',
|
||||
path: '/operation/:id/upload',
|
||||
builder: (context, state) {
|
||||
final serviceId = state.pathParameters['id']!;
|
||||
final serviceName = state.uri.queryParameters['name'] ?? 'Pratica';
|
||||
final operationId = state.pathParameters['id']!;
|
||||
final operationName =
|
||||
state.uri.queryParameters['name'] ?? 'Pratica';
|
||||
final currentStoreId = GetIt.I
|
||||
.get<SessionCubit>()
|
||||
.state
|
||||
.currentStore!
|
||||
.id!;
|
||||
context.read<CustomersCubit>().loadCustomers();
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
currentStoreId,
|
||||
);
|
||||
context.read<ProductsCubit>().loadModels();
|
||||
context.read<ProductsCubit>().loadBrands();
|
||||
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
|
||||
return BlocProvider(
|
||||
create: (context) => ServiceFilesBloc(serviceId: serviceId),
|
||||
child: ServiceMobileUploadScreen(
|
||||
serviceId: serviceId,
|
||||
serviceName: serviceName,
|
||||
create: (context) => OperationFilesBloc(operationId: operationId),
|
||||
child: OperationMobileUploadScreen(
|
||||
operationId: operationId,
|
||||
operationName: operationName,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
20
lib/core/utils/app_message.dart
Normal file
20
lib/core/utils/app_message.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
|
||||
class AppMessage {
|
||||
final String key;
|
||||
final String? argument;
|
||||
|
||||
const AppMessage({required this.key, this.argument});
|
||||
|
||||
String translatedMessage(BuildContext context) {
|
||||
switch (key) {
|
||||
case 'authCubitCheckEmailToConfirmAccount':
|
||||
return context.l10n.authCubitCheckEmailToConfirmAccount;
|
||||
case 'authCubitResetPasswordEmailSentTo':
|
||||
return context.l10n.authCubitResetPasswordEmailSentTo(argument!);
|
||||
default:
|
||||
return 'empty message';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/l10n/app_localizations.dart';
|
||||
|
||||
extension MyStringExtensions on String? {
|
||||
// Gestiamo anche il nullable per sicurezza
|
||||
String myFormat() {
|
||||
@@ -40,3 +43,7 @@ extension MyStringExtensions on String? {
|
||||
.join('.'); // Ritorna tutto tranne l'ultima parte
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizationExtension on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this)!;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/core/utils/functions.dart';
|
||||
|
||||
class ImageViewerWidget extends StatelessWidget {
|
||||
@@ -36,8 +37,8 @@ class ImageViewerWidget extends StatelessWidget {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return const Text(
|
||||
"Errore caricamento immagine (Permessi negati?)",
|
||||
return Text(
|
||||
context.l10n.imageViewerWidgetErrorOpening,
|
||||
style: TextStyle(color: Colors.red),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/core/utils/functions.dart';
|
||||
import 'package:pdfx/pdfx.dart';
|
||||
import 'package:internet_file/internet_file.dart';
|
||||
@@ -74,13 +75,13 @@ class _PdfViewerWidgetState extends State<PdfViewerWidget> {
|
||||
if (_errorMessage != null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(leading: const CloseButton()),
|
||||
body: Center(child: Text("Errore: $_errorMessage")),
|
||||
body: Center(child: Text(context.l10n.commonError(_errorMessage!))),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Anteprima PDF"),
|
||||
title: Text(context.l10n.pdfViewerAnteprimaPdf),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
class QrUploadDialog extends StatelessWidget {
|
||||
@@ -84,7 +85,7 @@ class QrUploadDialog extends StatelessWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text("CHIUDI"),
|
||||
child: Text(context.l10n.commonClose),
|
||||
),
|
||||
],
|
||||
actionsAlignment: MainAxisAlignment.center,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
@@ -25,9 +26,7 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
|
||||
final newPassword = _passwordCtrl.text.trim();
|
||||
if (newPassword.length < 6) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("La password deve avere almeno 6 caratteri"),
|
||||
),
|
||||
SnackBar(content: Text(context.l10n.setPasswordScreenAtLeast6Chars)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -43,23 +42,23 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
|
||||
// 2. Finito! Lo mandiamo alla home o facciamo ricaricare la sessione al SessionCubit
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Password impostata! Benvenuto a bordo 🚀"),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.setPasswordScreenPasswordSetWelcome),
|
||||
),
|
||||
);
|
||||
context.go('/'); // Rimandiamo al router principale
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text("Errore Auth: ${e.message}")));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.authError(e.message))),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text("Errore: $e")));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.commonError(e.toString()))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
@@ -70,7 +69,7 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Benvenuto in FLUX!"),
|
||||
title: Text(context.l10n.setPasswordScreenWelcomeInFlux),
|
||||
automaticallyImplyLeading:
|
||||
false, // Non può tornare indietro, deve mettere la password!
|
||||
),
|
||||
@@ -82,21 +81,21 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
|
||||
children: [
|
||||
const Icon(Icons.lock_reset, size: 80, color: Colors.blueAccent),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"Imposta la tua Password",
|
||||
Text(
|
||||
context.l10n.setPasswordScreenSetPassword,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"Hai accettato l'invito. Scegli una password sicura per accedere in futuro.",
|
||||
Text(
|
||||
context.l10n.setPasswordInviteAcceptedChoosePassword,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FluxTextField(
|
||||
controller: _passwordCtrl,
|
||||
label: "Nuova Password",
|
||||
label: context.l10n.commonNewPassword,
|
||||
icon: Icons.lock,
|
||||
isPassword: true,
|
||||
),
|
||||
@@ -108,8 +107,8 @@ class _SetPasswordScreenState extends State<SetPasswordScreen> {
|
||||
),
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text(
|
||||
"SALVA E INIZIA",
|
||||
: Text(
|
||||
context.l10n.setPasswordScreenSaveAndStart,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
|
||||
23
lib/features/attachments/data/attachments_repository.dart
Normal file
23
lib/features/attachments/data/attachments_repository.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class AttachmentsRepository {
|
||||
final _supabase = Supabase.instance.client;
|
||||
|
||||
/// Scarica i byte di un file direttamente da Supabase Storage
|
||||
Future<Uint8List> downloadAttachmentBytes(String storagePath) async {
|
||||
try {
|
||||
// ATTENZIONE: Sostituisci 'attachments' con il nome VERO del tuo bucket su Supabase!
|
||||
// Se il tuo storagePath contiene già il nome del bucket all'inizio,
|
||||
// assicurati di passargli solo il percorso interno.
|
||||
final Uint8List bytes = await _supabase.storage
|
||||
.from('attachments') // <--- NOME DEL TUO BUCKET
|
||||
.download(storagePath);
|
||||
|
||||
return bytes;
|
||||
} catch (e) {
|
||||
throw Exception("Impossibile scaricare il documento dal cloud: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,30 +2,49 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class ServiceFileModel extends Equatable {
|
||||
class AttachmentModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final String? customerId;
|
||||
final String? operationId;
|
||||
final String name;
|
||||
final String extension;
|
||||
final String storagePath;
|
||||
final String serviceId;
|
||||
final String? storagePath;
|
||||
final int fileSize;
|
||||
final Uint8List? localBytes;
|
||||
final String companyId;
|
||||
|
||||
const ServiceFileModel({
|
||||
const AttachmentModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
this.customerId,
|
||||
this.operationId,
|
||||
required this.name,
|
||||
required this.extension,
|
||||
required this.storagePath,
|
||||
required this.serviceId,
|
||||
this.storagePath,
|
||||
required this.fileSize,
|
||||
this.localBytes,
|
||||
required this.companyId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
customerId,
|
||||
operationId,
|
||||
name,
|
||||
extension,
|
||||
storagePath,
|
||||
fileSize,
|
||||
localBytes,
|
||||
companyId,
|
||||
];
|
||||
|
||||
bool get isLocal => localBytes != null;
|
||||
|
||||
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
|
||||
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
|
||||
|
||||
String get sizeFormatted {
|
||||
if (fileSize <= 0) return "0 B";
|
||||
const suffixes = ["B", "KB", "MB", "GB", "TB"];
|
||||
@@ -35,43 +54,45 @@ class ServiceFileModel extends Equatable {
|
||||
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
|
||||
}
|
||||
|
||||
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
|
||||
|
||||
ServiceFileModel copyWith({
|
||||
AttachmentModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? customerId,
|
||||
String? operationId,
|
||||
String? name,
|
||||
String? extension,
|
||||
String? storagePath,
|
||||
String? serviceId,
|
||||
int? fileSize,
|
||||
Uint8List? localBytes,
|
||||
}) {
|
||||
return ServiceFileModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
name: name ?? this.name,
|
||||
extension: extension ?? this.extension,
|
||||
storagePath: storagePath ?? this.storagePath,
|
||||
serviceId: serviceId ?? this.serviceId,
|
||||
fileSize: fileSize ?? this.fileSize,
|
||||
localBytes: localBytes ?? this.localBytes,
|
||||
);
|
||||
}
|
||||
String? companyId,
|
||||
}) => AttachmentModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
customerId: customerId ?? this.customerId,
|
||||
operationId: operationId ?? this.operationId,
|
||||
name: name ?? this.name,
|
||||
extension: extension ?? this.extension,
|
||||
storagePath: storagePath ?? this.storagePath,
|
||||
fileSize: fileSize ?? this.fileSize,
|
||||
localBytes: localBytes ?? this.localBytes,
|
||||
companyId: companyId ?? this.companyId,
|
||||
);
|
||||
|
||||
factory ServiceFileModel.fromMap(Map<String, dynamic> map) {
|
||||
return ServiceFileModel(
|
||||
factory AttachmentModel.fromMap(Map<String, dynamic> map) {
|
||||
return AttachmentModel(
|
||||
id: map['id'] as String,
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
name: map['name'] ?? '',
|
||||
extension: map['extension'] ?? '',
|
||||
storagePath: map['storage_path'] ?? '',
|
||||
serviceId: map['service_id']?.toString() ?? '',
|
||||
customerId: map['customer_id'] as String?,
|
||||
operationId: map['operation_id'] as String?,
|
||||
name: map['name'] as String,
|
||||
extension: map['extension'] as String,
|
||||
storagePath: map['storage_path'] as String?,
|
||||
fileSize: map['file_size'] is int
|
||||
? map['file_size']
|
||||
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
|
||||
companyId: map['company_id'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,20 +102,10 @@ class ServiceFileModel extends Equatable {
|
||||
'name': name,
|
||||
'extension': extension,
|
||||
'storage_path': storagePath,
|
||||
'service_id': serviceId,
|
||||
'customer_id': customerId,
|
||||
'operation_id': operationId,
|
||||
'file_size': fileSize,
|
||||
'company_id': companyId,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
name,
|
||||
extension,
|
||||
storagePath,
|
||||
serviceId,
|
||||
fileSize,
|
||||
localBytes,
|
||||
];
|
||||
}
|
||||
220
lib/features/attachments/ui/attachment_viewer_screen.dart
Normal file
220
lib/features/attachments/ui/attachment_viewer_screen.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/utils/functions.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
import 'package:pdfx/pdfx.dart';
|
||||
import 'package:internet_file/internet_file.dart';
|
||||
|
||||
class AttachmentViewerScreen extends StatefulWidget {
|
||||
final AttachmentModel attachment;
|
||||
final Function(String newName)? onRename;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const AttachmentViewerScreen({
|
||||
super.key,
|
||||
required this.attachment,
|
||||
this.onRename,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AttachmentViewerScreen> createState() => _AttachmentViewerScreenState();
|
||||
}
|
||||
|
||||
class _AttachmentViewerScreenState extends State<AttachmentViewerScreen> {
|
||||
PdfControllerPinch? _pdfController;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
Uint8List? _fileBytes;
|
||||
late String _fileName;
|
||||
|
||||
bool get isPdf => widget.attachment.extension.toLowerCase() == 'pdf';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fileName = widget.attachment.name;
|
||||
_loadFile();
|
||||
}
|
||||
|
||||
Future<void> _loadFile() async {
|
||||
try {
|
||||
// 1. Capiamo da dove prendere i dati
|
||||
if (widget.attachment.localBytes != null) {
|
||||
_fileBytes = widget.attachment.localBytes;
|
||||
} else if (widget.attachment.storagePath != null &&
|
||||
widget.attachment.storagePath!.isNotEmpty) {
|
||||
final signedUrl = await getSignedUrl(widget.attachment.storagePath!);
|
||||
_fileBytes = await InternetFile.get(signedUrl);
|
||||
} else {
|
||||
throw Exception("Nessun documento trovato o byte mancanti.");
|
||||
}
|
||||
|
||||
// 2. Se è PDF, inizializziamo il controller
|
||||
if (isPdf && _fileBytes != null) {
|
||||
_pdfController = PdfControllerPinch(
|
||||
document: PdfDocument.openData(_fileBytes!),
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pdfController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showRenameDialog() {
|
||||
final ctrl = TextEditingController(text: _fileName);
|
||||
ctrl.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: ctrl.text.length,
|
||||
);
|
||||
final focusNode = FocusNode();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => focusNode.requestFocus(),
|
||||
);
|
||||
return AlertDialog(
|
||||
title: const Text('Rinomina File'),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nuovo nome',
|
||||
suffixText: '.${widget.attachment.extension}',
|
||||
),
|
||||
onSubmitted: (val) {
|
||||
Navigator.pop(context);
|
||||
if (val.trim().isNotEmpty && widget.onRename != null) {
|
||||
setState(() {
|
||||
_fileName = val.trim();
|
||||
});
|
||||
widget.onRename!(val.trim());
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annulla'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
if (ctrl.text.trim().isNotEmpty && widget.onRename != null) {
|
||||
setState(() {
|
||||
_fileName = ctrl.text.trim();
|
||||
});
|
||||
widget.onRename!(ctrl.text.trim());
|
||||
}
|
||||
},
|
||||
child: const Text('Salva'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black87, // Sfondo scuro per i viewer è il top
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
|
||||
actions: [
|
||||
if (widget.onRename != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: 'Rinomina',
|
||||
onPressed: _showRenameDialog,
|
||||
),
|
||||
if (widget.onDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.redAccent),
|
||||
tooltip: 'Elimina',
|
||||
onPressed: () {
|
||||
// Chiediamo conferma
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (c) => AlertDialog(
|
||||
title: const Text('Eliminare file?'),
|
||||
content: const Text(
|
||||
'Sei sicuro di voler eliminare questo allegato?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(c),
|
||||
child: const Text('Annulla'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(c); // Chiude dialog
|
||||
widget.onDelete!(); // Lancia eliminazione
|
||||
Navigator.pop(context); // Chiude il viewer
|
||||
},
|
||||
child: const Text('Elimina'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Errore: $_errorMessage',
|
||||
style: const TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_fileBytes == null) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'File non disponibile',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isPdf && _pdfController != null) {
|
||||
return PdfViewPinch(controller: _pdfController!);
|
||||
} else {
|
||||
return InteractiveViewer(
|
||||
maxScale: 5.0,
|
||||
child: Center(child: Image.memory(_fileBytes!)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
lib/features/attachments/ui/quick_rename_dialog.dart
Normal file
85
lib/features/attachments/ui/quick_rename_dialog.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class QuickRenameDialog extends StatefulWidget {
|
||||
final String suggestedName;
|
||||
final Widget previewWidget; // Può essere Image.memory o un'icona PDF
|
||||
|
||||
const QuickRenameDialog({
|
||||
super.key,
|
||||
required this.suggestedName,
|
||||
required this.previewWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QuickRenameDialog> createState() => _QuickRenameDialogState();
|
||||
}
|
||||
|
||||
class _QuickRenameDialogState extends State<QuickRenameDialog> {
|
||||
late TextEditingController _nameCtrl;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.suggestedName);
|
||||
|
||||
// MAGIA UX: Selezioniamo tutto il testo di default appena si apre!
|
||||
_nameCtrl.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: widget.suggestedName.length,
|
||||
);
|
||||
|
||||
// Richiediamo il focus appena il widget è costruito
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Rinomina per Export'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Anteprima del documento (limitiamo l'altezza)
|
||||
Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.grey)),
|
||||
child: widget.previewWidget,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
focusNode: _focusNode,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nome del file',
|
||||
suffixText: '.pdf', // Facciamo capire che sarà un PDF
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
// MAGIA UX 2: Se preme invio sulla tastiera, salva e chiude!
|
||||
onSubmitted: (value) => Navigator.of(context).pop(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(), // Ritorna null
|
||||
child: const Text('Salta'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(_nameCtrl.text),
|
||||
child: const Text('Esporta (Invio)'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/data/constants.dart';
|
||||
import 'package:flux/core/utils/app_message.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
part 'auth_state.dart';
|
||||
@@ -41,7 +42,9 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AuthStatus.initial,
|
||||
infoMessage: "Controlla la tua email per confermare l'account!",
|
||||
infoMessage: AppMessage(
|
||||
key: 'authCubitCheckEmailToConfirmAccount',
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -82,7 +85,10 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: AuthStatus.pwResetSent,
|
||||
infoMessage: "Email per reset password inviata a $email!",
|
||||
infoMessage: AppMessage(
|
||||
key: 'authCubitResetPasswordEmailSentTo',
|
||||
argument: email,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ class AuthState extends Equatable {
|
||||
final AuthStatus status;
|
||||
final bool isLoginMode;
|
||||
final String? errorMessage;
|
||||
final String? infoMessage;
|
||||
final AppMessage? infoMessage;
|
||||
|
||||
const AuthState({
|
||||
this.status = AuthStatus.initial,
|
||||
@@ -19,7 +19,7 @@ class AuthState extends Equatable {
|
||||
AuthStatus? status,
|
||||
bool? isLoginMode,
|
||||
String? errorMessage,
|
||||
String? infoMessage,
|
||||
AppMessage? infoMessage,
|
||||
}) {
|
||||
return AuthState(
|
||||
status: status ?? this.status,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/core/widgets/flux_logo.dart';
|
||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||
import 'package:flux/features/auth/bloc/auth_cubit.dart';
|
||||
@@ -55,7 +56,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
if (state.infoMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.infoMessage!),
|
||||
content: Text(state.infoMessage!.translatedMessage(context)),
|
||||
backgroundColor: Colors.blueAccent, // O context.accent
|
||||
),
|
||||
);
|
||||
@@ -77,7 +78,9 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
|
||||
// --- TITOLO DINAMICO ---
|
||||
Text(
|
||||
state.isLoginMode ? 'BENTORNATO' : 'CREA ACCOUNT',
|
||||
state.isLoginMode
|
||||
? context.l10n.authScreenWelcomeBack
|
||||
: context.l10n.authScreenCreateAccount,
|
||||
style: TextStyle(
|
||||
color: context.primaryText,
|
||||
fontSize: 24,
|
||||
@@ -88,8 +91,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.isLoginMode
|
||||
? 'Accedi per gestire il tuo business'
|
||||
: 'Inizia oggi a digitalizzare il tuo negozio',
|
||||
? context.l10n.authScreenLoginToManageYourBusiness
|
||||
: context
|
||||
.l10n
|
||||
.authScreenStartTodayToDigitalizeYourStore,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: context.secondaryText),
|
||||
),
|
||||
@@ -97,7 +102,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
|
||||
// --- CAMPI INPUT ---
|
||||
FluxTextField(
|
||||
label: 'Email Aziendale',
|
||||
label: context.l10n.authScreenBusinessEmail,
|
||||
icon: Icons.email_outlined,
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
@@ -130,7 +135,9 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
state.isLoginMode ? 'ACCEDI' : 'REGISTRATI',
|
||||
state.isLoginMode
|
||||
? context.l10n.authScreenLogin
|
||||
: context.l10n.authScreenSignUp,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -147,12 +154,15 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: state.isLoginMode
|
||||
? "Non hai un account? "
|
||||
: "Hai già un account? ",
|
||||
? context.l10n.authScreenDontHaveAccount
|
||||
: context.l10n.authScreenAlreadyHaveAccount,
|
||||
style: TextStyle(color: context.secondaryText),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: state.isLoginMode ? "Registrati" : "Accedi",
|
||||
text: state.isLoginMode
|
||||
? context.l10n.authScreenSignUp
|
||||
: context.l10n.authScreenLogin,
|
||||
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -169,7 +179,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
.read<AuthCubit>()
|
||||
.requestPasswordReset(_emailController.text.trim()),
|
||||
child: Text(
|
||||
'Pw dimenticata/Invito scaduto?',
|
||||
context.l10n.authScreenForgotPassword,
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -22,5 +22,5 @@ class CompanyState extends Equatable {
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, errorMessage];
|
||||
List<Object?> get props => [status, errorMessage, company];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class CompanyRepository {
|
||||
} on PostgrestException catch (e) {
|
||||
throw e.message;
|
||||
} catch (e) {
|
||||
throw 'Errore imprevisto durante la creazione dell\'azienda';
|
||||
throw e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,14 +45,14 @@ class CompanyModel extends Equatable {
|
||||
final String userId; // Nel DB è user_id (chiave esterna su auth.users)
|
||||
|
||||
// Dati Anagrafici e Fatturazione
|
||||
final String ragioneSociale;
|
||||
final String indirizzo;
|
||||
final String cap;
|
||||
final String citta;
|
||||
final String provincia;
|
||||
final String partitaIva;
|
||||
final String codiceFiscale;
|
||||
final String codiceUnivoco;
|
||||
final String name;
|
||||
final String address;
|
||||
final String zipCode;
|
||||
final String city;
|
||||
final String province;
|
||||
final String vatId;
|
||||
final String fiscalCode;
|
||||
final String sdi;
|
||||
final String companyLogo;
|
||||
|
||||
// Stato Pagamenti (Ibride: manuale + Stripe)
|
||||
@@ -70,14 +70,14 @@ class CompanyModel extends Equatable {
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.userId,
|
||||
required this.ragioneSociale,
|
||||
required this.indirizzo,
|
||||
required this.cap,
|
||||
required this.citta,
|
||||
required this.provincia,
|
||||
required this.partitaIva,
|
||||
required this.codiceFiscale,
|
||||
required this.codiceUnivoco,
|
||||
required this.name,
|
||||
required this.address,
|
||||
required this.zipCode,
|
||||
required this.city,
|
||||
required this.province,
|
||||
required this.vatId,
|
||||
required this.fiscalCode,
|
||||
required this.sdi,
|
||||
this.companyLogo = '',
|
||||
this.isPaid = false,
|
||||
this.paymentExpiration,
|
||||
@@ -92,14 +92,14 @@ class CompanyModel extends Equatable {
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? userId,
|
||||
String? ragioneSociale,
|
||||
String? indirizzo,
|
||||
String? cap,
|
||||
String? citta,
|
||||
String? provincia,
|
||||
String? partitaIva,
|
||||
String? codiceFiscale,
|
||||
String? codiceUnivoco,
|
||||
String? name,
|
||||
String? address,
|
||||
String? zipCode,
|
||||
String? city,
|
||||
String? province,
|
||||
String? vatId,
|
||||
String? fiscalCode,
|
||||
String? sdi,
|
||||
String? companyLogo,
|
||||
bool? isPaid,
|
||||
DateTime? paymentExpiration,
|
||||
@@ -113,14 +113,14 @@ class CompanyModel extends Equatable {
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
userId: userId ?? this.userId,
|
||||
ragioneSociale: ragioneSociale ?? this.ragioneSociale,
|
||||
indirizzo: indirizzo ?? this.indirizzo,
|
||||
cap: cap ?? this.cap,
|
||||
citta: citta ?? this.citta,
|
||||
provincia: provincia ?? this.provincia,
|
||||
partitaIva: partitaIva ?? this.partitaIva,
|
||||
codiceFiscale: codiceFiscale ?? this.codiceFiscale,
|
||||
codiceUnivoco: codiceUnivoco ?? this.codiceUnivoco,
|
||||
name: name ?? this.name,
|
||||
address: address ?? this.address,
|
||||
zipCode: zipCode ?? this.zipCode,
|
||||
city: city ?? this.city,
|
||||
province: province ?? this.province,
|
||||
vatId: vatId ?? this.vatId,
|
||||
fiscalCode: fiscalCode ?? this.fiscalCode,
|
||||
sdi: sdi ?? this.sdi,
|
||||
companyLogo: companyLogo ?? this.companyLogo,
|
||||
isPaid: isPaid ?? this.isPaid,
|
||||
paymentExpiration: paymentExpiration ?? this.paymentExpiration,
|
||||
@@ -137,14 +137,14 @@ class CompanyModel extends Equatable {
|
||||
id: null,
|
||||
createdAt: null,
|
||||
userId: '',
|
||||
ragioneSociale: '',
|
||||
indirizzo: '',
|
||||
cap: '',
|
||||
citta: '',
|
||||
provincia: '',
|
||||
partitaIva: '',
|
||||
codiceFiscale: '',
|
||||
codiceUnivoco: '',
|
||||
name: '',
|
||||
address: '',
|
||||
zipCode: '',
|
||||
city: '',
|
||||
province: '',
|
||||
vatId: '',
|
||||
fiscalCode: '',
|
||||
sdi: '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,14 +155,14 @@ class CompanyModel extends Equatable {
|
||||
? DateTime.tryParse(map['created_at'])
|
||||
: null,
|
||||
userId: map['user_id'] ?? '',
|
||||
ragioneSociale: map['ragione_sociale'] ?? '',
|
||||
indirizzo: map['indirizzo'] ?? '',
|
||||
cap: map['cap'] ?? '',
|
||||
citta: map['citta'] ?? '',
|
||||
provincia: map['provincia'] ?? '',
|
||||
partitaIva: map['partita_iva'] ?? '',
|
||||
codiceFiscale: map['codice_fiscale'] ?? '',
|
||||
codiceUnivoco: map['codice_univoco'] ?? '',
|
||||
name: map['name'] ?? '',
|
||||
address: map['address'] ?? '',
|
||||
zipCode: map['zip_code'] ?? '',
|
||||
city: map['city'] ?? '',
|
||||
province: map['province'] ?? '',
|
||||
vatId: map['vat_id'] ?? '',
|
||||
fiscalCode: map['fiscal_code'] ?? '',
|
||||
sdi: map['sdi'] ?? '',
|
||||
companyLogo: map['company_logo'] ?? '',
|
||||
isPaid: map['is_paid'] ?? false,
|
||||
paymentExpiration: map['payment_expiration'] != null
|
||||
@@ -185,14 +185,14 @@ class CompanyModel extends Equatable {
|
||||
if (id != null) 'id': id,
|
||||
// created_at è gestito dal DB di default, di solito non si passa nell'insert
|
||||
'user_id': userId,
|
||||
'ragione_sociale': ragioneSociale,
|
||||
'indirizzo': indirizzo,
|
||||
'cap': cap,
|
||||
'citta': citta,
|
||||
'provincia': provincia,
|
||||
'partita_iva': partitaIva,
|
||||
'codice_fiscale': codiceFiscale,
|
||||
'codice_univoco': codiceUnivoco,
|
||||
'name': name,
|
||||
'address': address,
|
||||
'zip_code': zipCode,
|
||||
'city': city,
|
||||
'province': province,
|
||||
'vat_id': vatId,
|
||||
'fiscal_code': fiscalCode,
|
||||
'sdi': sdi,
|
||||
'company_logo': companyLogo,
|
||||
'is_paid': isPaid,
|
||||
if (paymentExpiration != null)
|
||||
@@ -213,14 +213,14 @@ class CompanyModel extends Equatable {
|
||||
id,
|
||||
createdAt,
|
||||
userId,
|
||||
ragioneSociale,
|
||||
indirizzo,
|
||||
cap,
|
||||
citta,
|
||||
provincia,
|
||||
partitaIva,
|
||||
codiceFiscale,
|
||||
codiceUnivoco,
|
||||
name,
|
||||
address,
|
||||
zipCode,
|
||||
city,
|
||||
province,
|
||||
vatId,
|
||||
fiscalCode,
|
||||
sdi,
|
||||
companyLogo,
|
||||
isPaid,
|
||||
paymentExpiration,
|
||||
@@ -263,7 +263,7 @@ extension CompanyLimits on CompanyModel {
|
||||
}
|
||||
}
|
||||
|
||||
int get maxServicesPerMonth {
|
||||
int get maxOperationsPerMonth {
|
||||
switch (subscriptionTier) {
|
||||
case SubscriptionTier.free:
|
||||
return 50;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/company/bloc/company_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
@@ -49,14 +50,14 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
|
||||
final company = CompanyModel(
|
||||
userId: userId,
|
||||
ragioneSociale: _ragioneSocialeController.text.trim(),
|
||||
indirizzo: _indirizzoController.text.trim(),
|
||||
cap: _capController.text.trim(),
|
||||
citta: _cittaController.text.trim(),
|
||||
provincia: _provinciaController.text.trim(),
|
||||
partitaIva: _pIvaController.text.trim(),
|
||||
codiceFiscale: _cfController.text.trim(),
|
||||
codiceUnivoco: _univocoController.text.trim().toUpperCase(),
|
||||
name: _ragioneSocialeController.text.trim(),
|
||||
address: _indirizzoController.text.trim(),
|
||||
zipCode: _capController.text.trim(),
|
||||
city: _cittaController.text.trim(),
|
||||
province: _provinciaController.text.trim(),
|
||||
vatId: _pIvaController.text.trim(),
|
||||
fiscalCode: _cfController.text.trim(),
|
||||
sdi: _univocoController.text.trim().toUpperCase(),
|
||||
// Gli altri campi hanno i default nel modello
|
||||
);
|
||||
|
||||
@@ -69,7 +70,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Configurazione Azienda'),
|
||||
title: Text(context.l10n.createCompanyScreenCompanyConfiguration),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
@@ -98,7 +99,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
state.errorMessage ?? 'Errore durante il salvataggio',
|
||||
state.errorMessage ?? context.l10n.commonSavingError,
|
||||
),
|
||||
backgroundColor: Colors.redAccent,
|
||||
),
|
||||
@@ -118,10 +119,12 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// --- SEZIONE 1: IDENTITÀ FISCALE ---
|
||||
_SectionTitle(title: 'DATI FISCALI'),
|
||||
_SectionTitle(
|
||||
title: context.l10n.createCompanyScreenFiscalData,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FluxTextField(
|
||||
label: 'Ragione Sociale',
|
||||
label: context.l10n.createCompanyScreenCompanyName,
|
||||
icon: Icons.business,
|
||||
controller: _ragioneSocialeController,
|
||||
),
|
||||
@@ -130,7 +133,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: FluxTextField(
|
||||
label: 'Partita IVA',
|
||||
label: context.l10n.createCompanyScreenVatId,
|
||||
icon: Icons.numbers,
|
||||
controller: _pIvaController,
|
||||
),
|
||||
@@ -138,7 +141,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FluxTextField(
|
||||
label: 'Codice Fiscale',
|
||||
label: context.l10n.createCompanyScreenFiscalCode,
|
||||
icon: Icons.badge_outlined,
|
||||
controller: _cfController,
|
||||
),
|
||||
@@ -147,7 +150,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FluxTextField(
|
||||
label: 'Codice Univoco (SDI) / PEC',
|
||||
label: context.l10n.createCompanyScreenSdiPec,
|
||||
icon: Icons.send_and_archive_outlined,
|
||||
controller: _univocoController,
|
||||
),
|
||||
@@ -155,10 +158,13 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// --- SEZIONE 2: SEDE LEGALE ---
|
||||
_SectionTitle(title: 'SEDE LEGALE'),
|
||||
_SectionTitle(
|
||||
title:
|
||||
context.l10n.createCompanyScreenCompanyLegalAddress,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FluxTextField(
|
||||
label: 'Indirizzo e n. civico',
|
||||
label: context.l10n.commonAddress,
|
||||
icon: Icons.home_work_outlined,
|
||||
controller: _indirizzoController,
|
||||
),
|
||||
@@ -168,7 +174,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FluxTextField(
|
||||
label: 'Città',
|
||||
label: context.l10n.commonCity,
|
||||
icon: Icons.location_city,
|
||||
controller: _cittaController,
|
||||
),
|
||||
@@ -176,7 +182,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FluxTextField(
|
||||
label: 'CAP',
|
||||
label: context.l10n.commonZipCode,
|
||||
icon: Icons.map_outlined,
|
||||
controller: _capController,
|
||||
),
|
||||
@@ -184,7 +190,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FluxTextField(
|
||||
label: 'Prov',
|
||||
label: context.l10n.commonProvince,
|
||||
icon: Icons.explore_outlined,
|
||||
controller: _provinciaController,
|
||||
),
|
||||
@@ -232,7 +238,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
Icon(Icons.cloud_upload_outlined, color: context.accent, size: 32),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Carica Logo Aziendale',
|
||||
context.l10n.createCompanyScreenUploadLogo,
|
||||
style: TextStyle(
|
||||
color: context.primaryText,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -240,7 +246,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Verrà usato per le tue stampe e ricevute',
|
||||
context.l10n.createCompanyScreenWillBeUsedForReceipts,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: context.secondaryText, fontSize: 12),
|
||||
),
|
||||
@@ -259,7 +265,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
: () => _onSave(),
|
||||
child: state.status == CompanyStatus.loading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('SALVA AZIENDA'),
|
||||
: Text(context.l10n.createCompanyScreenSaveCompany),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -282,7 +288,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Configura la tua Azienda',
|
||||
context.l10n.createCompanyScreenSetupYourCompany,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryText,
|
||||
@@ -290,7 +296,7 @@ class _CreateCompanyScreenState extends State<CreateCompanyScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.',
|
||||
context.l10n.createCompanyScreenFluxNeedsYourFiscalData,
|
||||
style: TextStyle(
|
||||
color: context.secondaryText,
|
||||
fontSize: 15,
|
||||
|
||||
@@ -4,8 +4,8 @@ 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/attachments/models/attachment_model.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';
|
||||
@@ -26,7 +26,7 @@ class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
|
||||
LoadCustomerFilesEvent event,
|
||||
Emitter<CustomerFilesState> emit,
|
||||
) async {
|
||||
await emit.forEach<List<CustomerFileModel>>(
|
||||
await emit.forEach<List<AttachmentModel>>(
|
||||
_repository.getCustomerFilesStream(customerId),
|
||||
onData: (customerFiles) => CustomerFilesState(
|
||||
status: CustomerFilesStatus.success,
|
||||
@@ -128,7 +128,7 @@ class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
|
||||
ToggleCustomerFileSelectionEvent event,
|
||||
Emitter<CustomerFilesState> emit,
|
||||
) {
|
||||
List<CustomerFileModel> selectedFiles = List.from(state.selectedFiles);
|
||||
List<AttachmentModel> selectedFiles = List.from(state.selectedFiles);
|
||||
if (selectedFiles.contains(event.file)) {
|
||||
selectedFiles.remove(event.file);
|
||||
} else {
|
||||
|
||||
@@ -25,6 +25,6 @@ class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent {
|
||||
class DeleteCustomerFilesEvent extends CustomerFilesEvent {}
|
||||
|
||||
class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent {
|
||||
final CustomerFileModel file;
|
||||
final AttachmentModel file;
|
||||
const ToggleCustomerFileSelectionEvent(this.file);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ class CustomerFilesState extends Equatable {
|
||||
|
||||
final CustomerFilesStatus status;
|
||||
final String? error;
|
||||
final List<CustomerFileModel> customerFiles;
|
||||
final List<CustomerFileModel> selectedFiles;
|
||||
final List<AttachmentModel> customerFiles;
|
||||
final List<AttachmentModel> selectedFiles;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, error, customerFiles, selectedFiles];
|
||||
@@ -21,8 +21,8 @@ class CustomerFilesState extends Equatable {
|
||||
CustomerFilesState copyWith({
|
||||
CustomerFilesStatus? status,
|
||||
String? error,
|
||||
List<CustomerFileModel>? customerFiles,
|
||||
List<CustomerFileModel>? selectedFiles,
|
||||
List<AttachmentModel>? customerFiles,
|
||||
List<AttachmentModel>? selectedFiles,
|
||||
}) {
|
||||
return CustomerFilesState(
|
||||
status: status ?? this.status,
|
||||
|
||||
@@ -3,35 +3,34 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.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:get_it/get_it.dart';
|
||||
|
||||
part 'customer_state.dart';
|
||||
part 'customers_state.dart';
|
||||
|
||||
class CustomerCubit extends Cubit<CustomerState> {
|
||||
class CustomersCubit extends Cubit<CustomersState> {
|
||||
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
|
||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||
|
||||
// Variabile per gestire il debounce della ricerca
|
||||
Timer? _searchDebounce;
|
||||
|
||||
CustomerCubit() : super(const CustomerState());
|
||||
CustomersCubit() : super(const CustomersState());
|
||||
|
||||
// --- LETTURA ---
|
||||
Future<void> loadCustomers() async {
|
||||
emit(state.copyWith(status: CustomerStatus.loading));
|
||||
emit(state.copyWith(status: CustomersStatus.loading));
|
||||
try {
|
||||
final customers = await _repository.getCustomers(
|
||||
_sessionCubit.state.company!.id!,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(status: CustomerStatus.success, customers: customers),
|
||||
state.copyWith(status: CustomersStatus.success, customers: customers),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.failure,
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
@@ -40,7 +39,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
|
||||
// --- CREAZIONE ---
|
||||
Future<void> createCustomer(CustomerModel customer) async {
|
||||
emit(state.copyWith(status: CustomerStatus.loading));
|
||||
emit(state.copyWith(status: CustomersStatus.loading));
|
||||
try {
|
||||
final newCustomer = await _repository.saveCustomer(customer);
|
||||
|
||||
@@ -50,7 +49,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.success,
|
||||
status: CustomersStatus.success,
|
||||
customers: updatedList,
|
||||
lastCreatedCustomer: newCustomer,
|
||||
),
|
||||
@@ -58,7 +57,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.failure,
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
@@ -67,7 +66,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
|
||||
// --- AGGIORNAMENTO ---
|
||||
Future<void> updateCustomer(CustomerModel customer) async {
|
||||
emit(state.copyWith(status: CustomerStatus.loading));
|
||||
emit(state.copyWith(status: CustomersStatus.loading));
|
||||
try {
|
||||
final updatedCustomer = await _repository.updateCustomer(customer);
|
||||
|
||||
@@ -80,7 +79,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.success,
|
||||
status: CustomersStatus.success,
|
||||
customers: updatedList,
|
||||
lastCreatedCustomer:
|
||||
updatedCustomer, // Utile se modifichi un cliente appena creato
|
||||
@@ -89,7 +88,7 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.failure,
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
@@ -116,12 +115,12 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
query,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(status: CustomerStatus.success, customers: results),
|
||||
state.copyWith(status: CustomersStatus.success, customers: results),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CustomerStatus.failure,
|
||||
status: CustomersStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
@@ -135,8 +134,8 @@ class CustomerCubit extends Cubit<CustomerState> {
|
||||
String? email,
|
||||
}) async {
|
||||
final newCustomer = CustomerModel(
|
||||
nome: name,
|
||||
telefono: phone ?? '',
|
||||
name: name,
|
||||
phoneNumber: phone ?? '',
|
||||
email: email ?? '',
|
||||
companyId: _sessionCubit.state.company!.id!,
|
||||
note: '',
|
||||
@@ -1,6 +1,6 @@
|
||||
part of 'customer_cubit.dart';
|
||||
part of 'customers_cubit.dart';
|
||||
|
||||
enum CustomerStatus {
|
||||
enum CustomersStatus {
|
||||
initial,
|
||||
loading,
|
||||
filesLoading,
|
||||
@@ -9,34 +9,30 @@ enum CustomerStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
class CustomerState extends Equatable {
|
||||
final CustomerStatus status;
|
||||
class CustomersState extends Equatable {
|
||||
final CustomersStatus status;
|
||||
final List<CustomerModel> customers;
|
||||
final CustomerModel? lastCreatedCustomer;
|
||||
final String? errorMessage;
|
||||
final List<CustomerFileModel> customerFiles;
|
||||
|
||||
const CustomerState({
|
||||
this.status = CustomerStatus.initial,
|
||||
const CustomersState({
|
||||
this.status = CustomersStatus.initial,
|
||||
this.customers = const [],
|
||||
this.lastCreatedCustomer,
|
||||
this.errorMessage,
|
||||
this.customerFiles = const [],
|
||||
});
|
||||
|
||||
CustomerState copyWith({
|
||||
CustomerStatus? status,
|
||||
CustomersState copyWith({
|
||||
CustomersStatus? status,
|
||||
List<CustomerModel>? customers,
|
||||
CustomerModel? lastCreatedCustomer,
|
||||
String? errorMessage,
|
||||
List<CustomerFileModel>? customerFiles,
|
||||
}) {
|
||||
return CustomerState(
|
||||
return CustomersState(
|
||||
status: status ?? this.status,
|
||||
customers: customers ?? this.customers,
|
||||
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
customerFiles: customerFiles ?? this.customerFiles,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +42,5 @@ class CustomerState extends Equatable {
|
||||
customers,
|
||||
lastCreatedCustomer,
|
||||
errorMessage,
|
||||
customerFiles,
|
||||
];
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
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/utils/string_extensions.dart';
|
||||
import 'package:flux/features/customers/models/customer_file_model.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/customer_model.dart';
|
||||
@@ -21,7 +20,7 @@ class CustomerRepository {
|
||||
.single();
|
||||
return CustomerModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante il salvataggio del cliente: $e';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +34,7 @@ class CustomerRepository {
|
||||
.single();
|
||||
return CustomerModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante la modifica del cliente: $e';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +45,15 @@ class CustomerRepository {
|
||||
.from('customer')
|
||||
.select('''
|
||||
*,
|
||||
customer_file(*)
|
||||
attachment(*)
|
||||
''')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('nome');
|
||||
.order('name');
|
||||
|
||||
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
|
||||
} catch (e) {
|
||||
throw 'Errore nel recupero clienti';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +67,7 @@ class CustomerRepository {
|
||||
.from('customer')
|
||||
.select()
|
||||
.eq('company_id', companyId)
|
||||
.or('nome.ilike.%$query%,telefono.ilike.%$query%')
|
||||
.or('name.ilike.%$query%,phone_number.ilike.%$query%')
|
||||
.limit(10);
|
||||
|
||||
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
|
||||
@@ -78,36 +77,34 @@ class CustomerRepository {
|
||||
}
|
||||
|
||||
/// Ascolta in tempo reale i file caricati per un cliente
|
||||
Stream<List<CustomerFileModel>> getCustomerFilesStream(String customerId) {
|
||||
Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
|
||||
return _supabase
|
||||
.from('customer_file')
|
||||
.from('attachment')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('customer_id', customerId)
|
||||
.order('created_at', ascending: false)
|
||||
.map(
|
||||
(listOfMaps) =>
|
||||
listOfMaps.map((map) => CustomerFileModel.fromMap(map)).toList(),
|
||||
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Recupera i file di un cliente specifico
|
||||
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async {
|
||||
Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('customer_file')
|
||||
.from('attachment')
|
||||
.select()
|
||||
.eq('customer_id', customerId);
|
||||
|
||||
return (response as List)
|
||||
.map((f) => CustomerFileModel.fromMap(f))
|
||||
.toList();
|
||||
return (response as List).map((f) => AttachmentModel.fromMap(f)).toList();
|
||||
} catch (e) {
|
||||
throw 'Errore recupero file: $e';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
/// Carica un file e salva il riferimento nel database
|
||||
Future<CustomerFileModel> uploadAndRegisterFile({
|
||||
Future<AttachmentModel> uploadAndRegisterFile({
|
||||
required String customerId,
|
||||
required PlatformFile pickedFile,
|
||||
}) async {
|
||||
@@ -118,7 +115,8 @@ class CustomerRepository {
|
||||
final storagePath =
|
||||
'$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
|
||||
final int fileSize = pickedFile.size;
|
||||
final fileToSave = CustomerFileModel(
|
||||
final fileToSave = AttachmentModel(
|
||||
companyId: companyId,
|
||||
customerId: customerId,
|
||||
name: cleanFileName.fileNameWithoutExtension(),
|
||||
extension: cleanFileName.fileExtension(),
|
||||
@@ -131,7 +129,7 @@ class CustomerRepository {
|
||||
try {
|
||||
// Usiamo bytes invece del path per massima compatibilità
|
||||
if (pickedFile.bytes == null && pickedFile.path == null) {
|
||||
throw 'Impossibile leggere il contenuto del file';
|
||||
throw 'File read error';
|
||||
}
|
||||
|
||||
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
|
||||
@@ -146,54 +144,51 @@ class CustomerRepository {
|
||||
}
|
||||
|
||||
final response = await _supabase
|
||||
.from('customer_file')
|
||||
.from('attachment')
|
||||
.insert(fileToSave.toMap())
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return CustomerFileModel.fromMap(response);
|
||||
return AttachmentModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante l\'upload: $e';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveFileReference(CustomerFileModel file) async {
|
||||
await _supabase.from('customer_file').upsert(file.toMap());
|
||||
Future<void> saveFileReference(AttachmentModel file) async {
|
||||
await _supabase.from('attachment').upsert(file.toMap());
|
||||
}
|
||||
|
||||
/// Aggiorna la lista degli URL nel database
|
||||
Future<void> updateCustomerDocuments(int id, List<String> urls) async {
|
||||
await _supabase
|
||||
.from('customer')
|
||||
.update({'document_urls': urls})
|
||||
.eq('id', id);
|
||||
}
|
||||
|
||||
Future<void> deleteDocuments(List<CustomerFileModel> files) async {
|
||||
Future<void> deleteDocuments(List<AttachmentModel> 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();
|
||||
|
||||
final List<String> idsToDelete = [];
|
||||
final List<String> storagePathsToDelete = [];
|
||||
final List<String> idsToEdit = [];
|
||||
for (var file in files) {
|
||||
if (file.operationId == null) {
|
||||
idsToDelete.add(file.id!);
|
||||
storagePathsToDelete.add(file.storagePath!);
|
||||
} else {
|
||||
idsToEdit.add(file.id!);
|
||||
}
|
||||
}
|
||||
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.");
|
||||
if (idsToDelete.isNotEmpty) {
|
||||
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
|
||||
// 3. Cancellazione MASSIVA dallo Storage
|
||||
await _supabase.storage.from('documents').remove(storagePathsToDelete);
|
||||
}
|
||||
if (idsToEdit.isNotEmpty) {
|
||||
await _supabase
|
||||
.from('attachment')
|
||||
.update({'customer_id': null})
|
||||
.inFilter('id', idsToEdit);
|
||||
}
|
||||
} on PostgrestException catch (e) {
|
||||
debugPrint("Errore DB: ${e.message}");
|
||||
throw 'Errore database: ${e.message}';
|
||||
throw e.message;
|
||||
} catch (e) {
|
||||
debugPrint("Errore generico: $e");
|
||||
throw 'Errore durante l\'eliminazione dei file: $e';
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class CustomerFileModel extends Equatable {
|
||||
final String? id;
|
||||
final String customerId; // Riferimento UUID
|
||||
final String name;
|
||||
final String storagePath;
|
||||
final String extension;
|
||||
final DateTime? createdAt;
|
||||
final int fileSize;
|
||||
|
||||
const CustomerFileModel({
|
||||
this.id,
|
||||
required this.customerId,
|
||||
required this.name,
|
||||
required this.storagePath,
|
||||
required this.extension,
|
||||
this.createdAt,
|
||||
required this.fileSize,
|
||||
});
|
||||
|
||||
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
|
||||
String get sizeFormatted {
|
||||
if (fileSize <= 0) return "0 B";
|
||||
const suffixes = ["B", "KB", "MB", "GB", "TB"];
|
||||
var i = (fileSize.toString().length - 1) ~/ 3;
|
||||
if (i >= suffixes.length) i = suffixes.length - 1;
|
||||
double num = fileSize / (1 << (i * 10));
|
||||
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
|
||||
}
|
||||
|
||||
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
|
||||
|
||||
CustomerFileModel copyWith({
|
||||
String? id,
|
||||
String? customerId,
|
||||
String? name,
|
||||
String? storagePath,
|
||||
String? extension,
|
||||
DateTime? createdAt,
|
||||
int? fileSize,
|
||||
}) {
|
||||
return CustomerFileModel(
|
||||
id: id ?? this.id,
|
||||
customerId: customerId ?? this.customerId,
|
||||
name: name ?? this.name,
|
||||
storagePath: storagePath ?? this.storagePath,
|
||||
extension: extension ?? this.extension,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
fileSize: fileSize ?? this.fileSize,
|
||||
);
|
||||
}
|
||||
|
||||
factory CustomerFileModel.fromMap(Map<String, dynamic> map) {
|
||||
return CustomerFileModel(
|
||||
id: map['id'] as String,
|
||||
customerId: map['customer_id'],
|
||||
name: map['name'],
|
||||
storagePath: map['storage_path'],
|
||||
extension: map['extension'] ?? '',
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
fileSize: map['file_size'] is int
|
||||
? map['file_size']
|
||||
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'customer_id': customerId,
|
||||
'name': name,
|
||||
'storage_path': storagePath,
|
||||
'extension': extension,
|
||||
'file_size': fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
customerId,
|
||||
name,
|
||||
storagePath,
|
||||
extension,
|
||||
createdAt,
|
||||
fileSize,
|
||||
];
|
||||
}
|
||||
@@ -1,74 +1,74 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/utils/string_extensions.dart';
|
||||
import 'package:flux/features/customers/models/customer_file_model.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
|
||||
class CustomerModel extends Equatable {
|
||||
final String? id; // Bigint in SQL
|
||||
final DateTime? createdAt;
|
||||
final String nome;
|
||||
final String telefono;
|
||||
final String name;
|
||||
final String phoneNumber;
|
||||
final String email;
|
||||
final String note;
|
||||
final DateTime? dataUltimoContatto;
|
||||
final bool nonDisturbare;
|
||||
final DateTime? lastContactDate;
|
||||
final bool doNotDisturb;
|
||||
final String companyId; // UUID
|
||||
final bool isActive;
|
||||
final List<CustomerFileModel> files;
|
||||
final List<AttachmentModel> attachments;
|
||||
|
||||
const CustomerModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.nome,
|
||||
required this.telefono,
|
||||
required this.name,
|
||||
required this.phoneNumber,
|
||||
required this.email,
|
||||
required this.note,
|
||||
this.dataUltimoContatto,
|
||||
this.nonDisturbare = false,
|
||||
this.lastContactDate,
|
||||
this.doNotDisturb = false,
|
||||
required this.companyId,
|
||||
this.isActive = true,
|
||||
this.files = const [],
|
||||
this.attachments = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
nome,
|
||||
telefono,
|
||||
name,
|
||||
phoneNumber,
|
||||
email,
|
||||
note,
|
||||
dataUltimoContatto,
|
||||
nonDisturbare,
|
||||
lastContactDate,
|
||||
doNotDisturb,
|
||||
companyId,
|
||||
isActive,
|
||||
files,
|
||||
attachments,
|
||||
];
|
||||
|
||||
CustomerModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? nome,
|
||||
String? telefono,
|
||||
String? name,
|
||||
String? phoneNumber,
|
||||
String? email,
|
||||
String? note,
|
||||
DateTime? dataUltimoContatto,
|
||||
bool? nonDisturbare,
|
||||
DateTime? lastContactDate,
|
||||
bool? doNotDisturb,
|
||||
String? companyId,
|
||||
bool? isActive,
|
||||
List<CustomerFileModel>? files,
|
||||
List<AttachmentModel>? attachments,
|
||||
}) {
|
||||
return CustomerModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
nome: nome ?? this.nome,
|
||||
telefono: telefono ?? this.telefono,
|
||||
name: name ?? this.name,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
email: email ?? this.email,
|
||||
note: note ?? this.note,
|
||||
dataUltimoContatto: dataUltimoContatto ?? this.dataUltimoContatto,
|
||||
nonDisturbare: nonDisturbare ?? this.nonDisturbare,
|
||||
lastContactDate: lastContactDate ?? this.lastContactDate,
|
||||
doNotDisturb: doNotDisturb ?? this.doNotDisturb,
|
||||
companyId: companyId ?? this.companyId,
|
||||
isActive: isActive ?? this.isActive,
|
||||
files: files ?? this.files,
|
||||
attachments: attachments ?? this.attachments,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,19 +78,19 @@ class CustomerModel extends Equatable {
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
nome: (map['nome'] as String).myFormat(),
|
||||
telefono: map['telefono'],
|
||||
name: (map['name'] as String).myFormat(),
|
||||
phoneNumber: map['phone_number'],
|
||||
email: map['email'],
|
||||
note: map['note'] ?? '',
|
||||
dataUltimoContatto: map['data_ultimo_contatto'] != null
|
||||
? DateTime.parse(map['data_ultimo_contatto'])
|
||||
lastContactDate: map['last_contact_date'] != null
|
||||
? DateTime.parse(map['last_contact_date'])
|
||||
: null,
|
||||
nonDisturbare: map['non_disturbare'] ?? false,
|
||||
doNotDisturb: map['do_not_disturb'] ?? false,
|
||||
companyId: map['company_id'] as String,
|
||||
isActive: map['is_active'] ?? true,
|
||||
files:
|
||||
(map['customer_file'] as List?)
|
||||
?.map((x) => CustomerFileModel.fromMap(x))
|
||||
attachments:
|
||||
(map['attachment'] as List?)
|
||||
?.map((x) => AttachmentModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
@@ -99,13 +99,13 @@ class CustomerModel extends Equatable {
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'nome': nome.toLowerCase().trim(),
|
||||
'telefono': telefono,
|
||||
'name': name.toLowerCase().trim(),
|
||||
'phone_number': phoneNumber,
|
||||
'email': email.toLowerCase().trim(),
|
||||
'note': note,
|
||||
if (dataUltimoContatto != null)
|
||||
'data_ultimo_contatto': dataUltimoContatto!.toIso8601String(),
|
||||
'non_disturbare': nonDisturbare,
|
||||
if (lastContactDate != null)
|
||||
'last_contact_date': lastContactDate!.toIso8601String(),
|
||||
'do_not_disturb': doNotDisturb,
|
||||
'company_id': companyId,
|
||||
'is_active': isActive,
|
||||
};
|
||||
|
||||
@@ -6,9 +6,9 @@ import 'package:flux/core/theme/theme.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/attachments/models/attachment_model.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_file_model.dart';
|
||||
|
||||
class CustomerDetailScreen extends StatefulWidget {
|
||||
final CustomerModel customer;
|
||||
@@ -62,7 +62,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
backgroundColor: context.background,
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.customer.nome,
|
||||
widget.customer.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundColor: context.background,
|
||||
@@ -103,7 +103,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_infoTile(Icons.phone_android, "Telefono", widget.customer.telefono),
|
||||
_infoTile(Icons.phone_android, "Telefono", widget.customer.phoneNumber),
|
||||
_infoTile(
|
||||
Icons.email_outlined,
|
||||
"Email",
|
||||
@@ -117,7 +117,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
: widget.customer.note,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (widget.customer.nonDisturbare)
|
||||
if (widget.customer.doNotDisturb)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -191,8 +191,8 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
context: context,
|
||||
builder: (context) => QrUploadDialog(
|
||||
deepLinkUrl:
|
||||
'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.nome)}',
|
||||
title: 'Scatta per ${widget.customer.nome}',
|
||||
'fluxapp://customer/${widget.customer.id}/upload?name=${Uri.encodeComponent(widget.customer.name)}',
|
||||
title: 'Scatta per ${widget.customer.name}',
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -262,12 +262,12 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
|
||||
|
||||
void _showDeleteConfirmationDialog({
|
||||
required BuildContext context,
|
||||
required List<CustomerFileModel> files,
|
||||
required List<AttachmentModel> files,
|
||||
}) {}
|
||||
}
|
||||
|
||||
class _FileCard extends StatelessWidget {
|
||||
final CustomerFileModel file;
|
||||
final AttachmentModel file;
|
||||
final CustomerFilesState state;
|
||||
const _FileCard({required this.file, required this.state});
|
||||
|
||||
@@ -334,7 +334,7 @@ class _FileCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDoubleClickOnFile(BuildContext context, CustomerFileModel file) {
|
||||
void _handleDoubleClickOnFile(BuildContext context, AttachmentModel file) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
|
||||
@@ -30,15 +30,15 @@ class _CustomerFormState extends State<CustomerForm> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Se widget.customer è null, i campi saranno vuoti
|
||||
_nomeController = TextEditingController(text: widget.customer?.nome ?? '');
|
||||
_nomeController = TextEditingController(text: widget.customer?.name ?? '');
|
||||
_telefonoController = TextEditingController(
|
||||
text: widget.customer?.telefono ?? '',
|
||||
text: widget.customer?.phoneNumber ?? '',
|
||||
);
|
||||
_emailController = TextEditingController(
|
||||
text: widget.customer?.email ?? '',
|
||||
);
|
||||
_noteController = TextEditingController(text: widget.customer?.note ?? '');
|
||||
_nonDisturbare = widget.customer?.nonDisturbare ?? false;
|
||||
_nonDisturbare = widget.customer?.doNotDisturb ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -56,19 +56,19 @@ class _CustomerFormState extends State<CustomerForm> {
|
||||
// o creandone uno da zero, preservando l'ID in caso di modifica.
|
||||
final updatedCustomer =
|
||||
widget.customer?.copyWith(
|
||||
nome: _nomeController.text.trim(),
|
||||
telefono: _telefonoController.text.trim(),
|
||||
name: _nomeController.text.trim(),
|
||||
phoneNumber: _telefonoController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
note: _noteController.text.trim(),
|
||||
nonDisturbare: _nonDisturbare,
|
||||
doNotDisturb: _nonDisturbare,
|
||||
) ??
|
||||
CustomerModel(
|
||||
// Caso nuovo cliente
|
||||
nome: _nomeController.text.trim(),
|
||||
telefono: _telefonoController.text.trim(),
|
||||
name: _nomeController.text.trim(),
|
||||
phoneNumber: _telefonoController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
note: _noteController.text.trim(),
|
||||
nonDisturbare: _nonDisturbare,
|
||||
doNotDisturb: _nonDisturbare,
|
||||
companyId: '', // Verrà iniettato dal Bloc o dal chiamante
|
||||
);
|
||||
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_cubit.dart';
|
||||
import 'package:flux/features/customers/models/customer_model.dart';
|
||||
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
|
||||
import 'package:flux/features/services/blocs/services_cubit.dart';
|
||||
|
||||
class CustomerSearchSheet extends StatefulWidget {
|
||||
const CustomerSearchSheet({super.key});
|
||||
|
||||
@override
|
||||
State<CustomerSearchSheet> createState() => _CustomerSearchSheetState();
|
||||
}
|
||||
|
||||
class _CustomerSearchSheetState extends State<CustomerSearchSheet> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<CustomerCubit>().loadCustomers();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
context.read<CustomerCubit>().searchCustomers(query);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER ---
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Trova Cliente",
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: "Chiudi",
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- BARRA DI RICERCA ---
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Cerca per nome, cognome o CF...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged("");
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- TASTO NUOVO CLIENTE ---
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.person_add),
|
||||
onPressed: () async {
|
||||
final servicesCubit = context.read<ServicesCubit>();
|
||||
// Apriamo la dialog passando la query attuale
|
||||
final CustomerModel? nuovoCliente = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => QuickCustomerDialog(
|
||||
initialQuery: _searchController.text,
|
||||
),
|
||||
);
|
||||
|
||||
if (nuovoCliente != null) {
|
||||
servicesCubit.updateField(
|
||||
customerId: nuovoCliente.id,
|
||||
customerDisplayName: nuovoCliente.nome,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_searchController.clear();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// --- LISTA RISULTATI CON BLOC BUILDER ---
|
||||
const Text(
|
||||
"Risultati",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Expanded(
|
||||
// AGGANCIO AL CUBIT REALE
|
||||
child: BlocBuilder<CustomerCubit, CustomerState>(
|
||||
builder: (context, state) {
|
||||
// 1. Stato di caricamento
|
||||
if (state.status == CustomerStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// 2. Nessun risultato trovato
|
||||
if (state.customers.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
"Nessun cliente trovato.\nProva a cambiare i termini di ricerca.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Mostriamo la lista vera
|
||||
return ListView.separated(
|
||||
itemCount: state.customers.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final customer = state.customers[index];
|
||||
// Assumo che il tuo CustomerModel abbia le proprietà name e surname.
|
||||
// Adatta queste variabili al tuo modello reale!
|
||||
final displayName = customer.nome.trim();
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
// Mostra l'iniziale
|
||||
child: Text(
|
||||
displayName.isNotEmpty
|
||||
? displayName[0].toUpperCase()
|
||||
: "?",
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
displayName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(customer.email),
|
||||
trailing: const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: Colors.grey,
|
||||
),
|
||||
onTap: () {
|
||||
// Salviamo l'ID e il nome formattato nel form dei servizi
|
||||
context.read<ServicesCubit>().updateField(
|
||||
customerId: customer.id,
|
||||
customerDisplayName: displayName,
|
||||
);
|
||||
|
||||
// Chiudiamo la modale
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.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/features/customers/blocs/customer_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||
import 'package:flux/features/customers/models/customer_model.dart';
|
||||
import 'package:flux/features/customers/ui/customer_form.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -26,14 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
void _loadInitialCustomers() {
|
||||
final companyId = context.read<SessionCubit>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomerCubit>().loadCustomers();
|
||||
context.read<CustomersCubit>().loadCustomers();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearch(String query) {
|
||||
final companyId = context.read<SessionCubit>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomerCubit>().searchCustomers(query);
|
||||
context.read<CustomersCubit>().searchCustomers(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,9 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
|
||||
// LISTA CLIENTI
|
||||
Expanded(
|
||||
child: BlocBuilder<CustomerCubit, CustomerState>(
|
||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == CustomerStatus.loading &&
|
||||
if (state.status == CustomersStatus.loading &&
|
||||
state.customers.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -166,7 +166,7 @@ class _CustomerTile extends StatelessWidget {
|
||||
radius: 24,
|
||||
backgroundColor: context.accent.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
customer.nome.isNotEmpty ? customer.nome[0].toUpperCase() : '?',
|
||||
customer.name.isNotEmpty ? customer.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -174,7 +174,7 @@ class _CustomerTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
customer.nome,
|
||||
customer.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
subtitle: Padding(
|
||||
@@ -184,7 +184,7 @@ class _CustomerTile extends StatelessWidget {
|
||||
Icon(Icons.phone_android, size: 14, color: context.secondaryText),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
customer.telefono,
|
||||
customer.phoneNumber,
|
||||
style: TextStyle(color: context.secondaryText),
|
||||
),
|
||||
if (customer.email.isNotEmpty) ...[
|
||||
@@ -196,11 +196,11 @@ class _CustomerTile extends StatelessWidget {
|
||||
style: TextStyle(color: context.secondaryText),
|
||||
),
|
||||
],
|
||||
if (customer.files.isNotEmpty) ...[
|
||||
if (customer.attachments.isNotEmpty) ...[
|
||||
Text(' - ', style: TextStyle(color: context.secondaryText)),
|
||||
Icon(Icons.attach_file, size: 14, color: context.accent),
|
||||
Text(
|
||||
'${customer.files.length} doc',
|
||||
'${customer.attachments.length} doc',
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -242,12 +242,12 @@ void openCustomerForm({
|
||||
|
||||
if (customer == null) {
|
||||
// CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create
|
||||
context.read<CustomerCubit>().createCustomer(
|
||||
context.read<CustomersCubit>().createCustomer(
|
||||
customerFromForm.copyWith(companyId: companyId),
|
||||
);
|
||||
} else {
|
||||
// CASO MODIFICA: L'ID e il companyId sono già nel modello
|
||||
context.read<CustomerCubit>().updateCustomer(customerFromForm);
|
||||
context.read<CustomersCubit>().updateCustomer(customerFromForm);
|
||||
}
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_cubit.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||
|
||||
class QuickCustomerDialog extends StatefulWidget {
|
||||
final String initialQuery;
|
||||
@@ -42,13 +42,15 @@ class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
// Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti)
|
||||
final newCustomer = await context.read<CustomerCubit>().quickCreateCustomer(
|
||||
name: _nameCtrl.text.trim(),
|
||||
phone: _phoneCtrl.text.trim(),
|
||||
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit:
|
||||
// email: _emailCtrl.text.trim(),
|
||||
// note: _noteCtrl.text.trim(),
|
||||
);
|
||||
final newCustomer = await context
|
||||
.read<CustomersCubit>()
|
||||
.quickCreateCustomer(
|
||||
name: _nameCtrl.text.trim(),
|
||||
phone: _phoneCtrl.text.trim(),
|
||||
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit:
|
||||
// email: _emailCtrl.text.trim(),
|
||||
// note: _noteCtrl.text.trim(),
|
||||
);
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
part '../../latest_store_operations/bloc/latest_store_operations_events.dart';
|
||||
part '../../latest_store_operations/bloc/latest_store_operations_state.dart';
|
||||
|
||||
class LatestStoreOperationsBloc
|
||||
extends Bloc<LatestStoreOperationsEvent, LatestStoreOperationsState> {
|
||||
final _repository = GetIt.I.get<OperationsRepository>();
|
||||
|
||||
LatestStoreOperationsBloc()
|
||||
: super(
|
||||
const LatestStoreOperationsState(
|
||||
status: LatestStoreOperationsStatus.initial,
|
||||
),
|
||||
) {
|
||||
on<InitLastStoreOperationsEvent>((event, emit) async {
|
||||
emit(state.copyWith(status: LatestStoreOperationsStatus.loading));
|
||||
try {
|
||||
// 1. Creiamo uno stream "intermedio" che idrata i dati
|
||||
final hydratedStream = _repository
|
||||
.getLastStoreOperationsStream(storeId: event.storeId, limit: 5)
|
||||
.asyncMap((List<OperationModel> rawOperations) async {
|
||||
// Questo gira ad ogni "scatto" dello stream di Supabase
|
||||
List<OperationModel> fullyHydratedOperations = [];
|
||||
|
||||
for (OperationModel operation in rawOperations) {
|
||||
// Peschiamo i dati completi (incluso il cliente)
|
||||
OperationModel fullOperation = await _repository
|
||||
.fetchOperationById(operation.id!);
|
||||
fullyHydratedOperations.add(fullOperation);
|
||||
}
|
||||
|
||||
// Passiamo la lista completa allo step successivo
|
||||
return fullyHydratedOperations;
|
||||
});
|
||||
|
||||
// 2. Ora passiamo lo stream idratato all'emit.forEach
|
||||
await emit.forEach(
|
||||
hydratedStream, // Usiamo lo stream modificato!
|
||||
onData: (List<OperationModel> fullyHydratedOperations) {
|
||||
// Qui ora è tutto sincrono e bellissimo
|
||||
return state.copyWith(
|
||||
operations: fullyHydratedOperations,
|
||||
status: LatestStoreOperationsStatus.success,
|
||||
);
|
||||
},
|
||||
onError: (error, stackTrace) => state.copyWith(
|
||||
status: LatestStoreOperationsStatus.failure,
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: LatestStoreOperationsStatus.failure,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
part of 'latest_store_operations_bloc.dart';
|
||||
|
||||
sealed class LatestStoreOperationsEvent extends Equatable {
|
||||
const LatestStoreOperationsEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class InitLastStoreOperationsEvent extends LatestStoreOperationsEvent {
|
||||
final String storeId;
|
||||
|
||||
const InitLastStoreOperationsEvent(this.storeId);
|
||||
|
||||
@override
|
||||
List<Object> get props => [storeId];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
part of 'latest_store_operations_bloc.dart';
|
||||
|
||||
enum LatestStoreOperationsStatus { initial, loading, success, failure }
|
||||
|
||||
class LatestStoreOperationsState extends Equatable {
|
||||
final LatestStoreOperationsStatus status;
|
||||
final String? error;
|
||||
final List<OperationModel> operations;
|
||||
|
||||
const LatestStoreOperationsState({
|
||||
required this.status,
|
||||
this.error,
|
||||
this.operations = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, error, operations];
|
||||
|
||||
LatestStoreOperationsState copyWith({
|
||||
LatestStoreOperationsStatus? status,
|
||||
String? error,
|
||||
List<OperationModel>? operations,
|
||||
}) {
|
||||
return LatestStoreOperationsState(
|
||||
status: status ?? this.status,
|
||||
error: error,
|
||||
operations: operations ?? this.operations,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.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/utils/extensions.dart';
|
||||
import 'package:flux/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class LatestStoreOperationsCard extends StatelessWidget {
|
||||
const LatestStoreOperationsCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentStoreId = context.read<SessionCubit>().state.currentStore?.id;
|
||||
|
||||
return BlocProvider(
|
||||
// 1. Creiamo il Bloc e facciamo partire subito la query
|
||||
create: (context) =>
|
||||
LatestStoreOperationsBloc()
|
||||
..add(InitLastStoreOperationsEvent(currentStoreId ?? '')),
|
||||
child: BlocListener<SessionCubit, SessionState>(
|
||||
// 2. MAGIA: Se l'utente cambia negozio dalla barra in alto, riavviamo lo stream!
|
||||
listenWhen: (previous, current) =>
|
||||
previous.currentStore?.id != current.currentStore?.id,
|
||||
listener: (context, state) {
|
||||
if (state.currentStore?.id != null) {
|
||||
context.read<LatestStoreOperationsBloc>().add(
|
||||
InitLastStoreOperationsEvent(state.currentStore!.id!),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: _LatestOperationsCardContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LatestOperationsCardContent extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
const color = Colors.blue;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER DELLA CARD ---
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.design_services_outlined,
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => context.push('/operations'),
|
||||
child: Text(
|
||||
context.l10n.homeLatestOperations,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: context.primaryText,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
|
||||
Expanded(
|
||||
child:
|
||||
BlocBuilder<
|
||||
LatestStoreOperationsBloc,
|
||||
LatestStoreOperationsState
|
||||
>(
|
||||
builder: (context, state) {
|
||||
if (state.status == LatestStoreOperationsStatus.loading ||
|
||||
state.status == LatestStoreOperationsStatus.initial) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == LatestStoreOperationsStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Errore di caricamento",
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.operations.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Nessun servizio recente.",
|
||||
style: TextStyle(
|
||||
color: context.secondaryText,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: state.operations.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final operation = state.operations[index];
|
||||
return InkWell(
|
||||
onTap: () => context.push(
|
||||
'/operation-form',
|
||||
extra: operation,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(
|
||||
operation.customerDisplayName ??
|
||||
'Cliente sconosciuto',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: context.primaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(
|
||||
operation.reference,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryText,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${operation.createdAt?.day}/${operation.createdAt?.month}",
|
||||
style: TextStyle(
|
||||
color: context.secondaryText,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
|
||||
class DashboardActionCard extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const DashboardActionCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
// CAMBIA QUI: da Border.all a BorderSide
|
||||
side: BorderSide(
|
||||
color: context.accent.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/home/ui/dashboard_action_card.dart';
|
||||
import 'package:flux/features/services/utils/service_actions.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class DashboardAdaptiveGrid extends StatelessWidget {
|
||||
final bool isLargeScreen;
|
||||
final Function(int)? onTabRequested;
|
||||
const DashboardAdaptiveGrid({
|
||||
super.key,
|
||||
this.isLargeScreen = false,
|
||||
this.onTabRequested,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Logica Colonne: Mobile 2, Tablet 3, Desktop 4+
|
||||
int crossAxisCount = 2;
|
||||
if (constraints.maxWidth > 1000) {
|
||||
crossAxisCount = 5;
|
||||
} else if (constraints.maxWidth > 700) {
|
||||
crossAxisCount = 3;
|
||||
}
|
||||
|
||||
return GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: isLargeScreen ? 1.3 : 1.5,
|
||||
children: [
|
||||
DashboardActionCard(
|
||||
label: 'Nuova Op',
|
||||
icon: Icons.add_task,
|
||||
color: context.accent,
|
||||
onTap: () => startNewService(context),
|
||||
),
|
||||
DashboardActionCard(
|
||||
label: 'Clienti',
|
||||
icon: Icons.people,
|
||||
color: Colors.orange,
|
||||
onTap: () => onTabRequested?.call(1),
|
||||
),
|
||||
DashboardActionCard(
|
||||
label: 'Prodotti',
|
||||
icon: Icons
|
||||
.phone_android_outlined, // Icona "comoda" e professionale
|
||||
color: context
|
||||
.accent, // O un colore a tua scelta, magari Indigo o Blue
|
||||
onTap: () => context.push(
|
||||
'/products',
|
||||
), // Apre la schermata sopra la Dashboard
|
||||
),
|
||||
DashboardActionCard(
|
||||
label: 'Campagne',
|
||||
icon: Icons.campaign,
|
||||
color: Colors.purple,
|
||||
onTap: () {},
|
||||
),
|
||||
DashboardActionCard(
|
||||
label: 'Report',
|
||||
icon: Icons.analytics,
|
||||
color: Colors.teal,
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import 'package:flutter/material.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/features/home/ui/dashboard_adaptive_grid.dart';
|
||||
|
||||
class DashboardContent extends StatelessWidget {
|
||||
final bool isLargeScreen;
|
||||
final Function(int)? onTabRequested;
|
||||
|
||||
const DashboardContent({
|
||||
super.key,
|
||||
this.isLargeScreen = false,
|
||||
this.onTabRequested,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SessionCubit, SessionState>(
|
||||
builder: (context, state) {
|
||||
final store = state.currentStore;
|
||||
final company = state.company;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: context.background,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 100.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
elevation: 0,
|
||||
backgroundColor: context.background,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
store?.nome ?? 'Dashboard',
|
||||
style: TextStyle(
|
||||
color: context.primaryText,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildWelcome(context, company?.ragioneSociale),
|
||||
const SizedBox(height: 32),
|
||||
const _SectionTitle(title: 'AZIONI RAPIDE'),
|
||||
const SizedBox(height: 16),
|
||||
DashboardAdaptiveGrid(
|
||||
isLargeScreen: isLargeScreen,
|
||||
onTabRequested: onTabRequested,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const _SectionTitle(title: 'INFO PUNTO VENDITA'),
|
||||
const SizedBox(height: 16),
|
||||
_buildStoreCard(context, store),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWelcome(BuildContext context, String? name) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Benvenuto in',
|
||||
style: TextStyle(color: context.secondaryText, fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
name ?? 'Azienda',
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStoreCard(BuildContext context, dynamic store) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: context.accent.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: context.accent.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.location_on, color: context.accent),
|
||||
const SizedBox(width: 16),
|
||||
Text('${store?.indirizzo}, ${store?.comune} (${store?.provincia})'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionTitle({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.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/utils/extensions.dart';
|
||||
import 'package:flux/features/home/latest_store_operations/ui/latest_store_operations_card.dart';
|
||||
import 'package:flux/features/home/ui/quick_actions_widget.dart';
|
||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -57,31 +59,27 @@ class HomeScreen extends StatelessWidget {
|
||||
),
|
||||
delegate: SliverChildListDelegate([
|
||||
_buildDashboardWidget(
|
||||
title: 'Contratti in Scadenza',
|
||||
title: context.l10n.homeExpiringContracts,
|
||||
icon: Icons.assignment_late_outlined,
|
||||
color: Colors.orange,
|
||||
context: context,
|
||||
),
|
||||
_buildDashboardWidget(
|
||||
title: 'Sticky Notes',
|
||||
title: context.l10n.commonStickyNotes,
|
||||
icon: Icons.sticky_note_2_outlined,
|
||||
color: Colors.yellow.shade700,
|
||||
context: context,
|
||||
),
|
||||
_buildDashboardWidget(
|
||||
title: 'I miei Task',
|
||||
title: context.l10n.homeMyTasks,
|
||||
icon: Icons.check_box_outlined,
|
||||
color: Colors.green,
|
||||
context: context,
|
||||
),
|
||||
LatestStoreOperationsCard(),
|
||||
|
||||
_buildDashboardWidget(
|
||||
title: 'Ultimi Servizi',
|
||||
icon: Icons.design_services_outlined,
|
||||
color: Colors.blue,
|
||||
context: context,
|
||||
),
|
||||
_buildDashboardWidget(
|
||||
title: 'Ultime Assistenze',
|
||||
title: context.l10n.homeLatestOperationTickets,
|
||||
icon: Icons.support_agent_outlined,
|
||||
color: Colors.purple,
|
||||
context: context,
|
||||
@@ -117,7 +115,7 @@ class HomeScreen extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Bentornato, ${user!.name}! 👋",
|
||||
context.l10n.homeWelcomeBack(user?.name ?? "Utente"),
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
@@ -151,7 +149,7 @@ class HomeScreen extends StatelessWidget {
|
||||
Icon(Icons.storefront, size: 16, color: context.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
currentStore?.nome ?? "Nessun negozio",
|
||||
currentStore?.name ?? context.l10n.homeNoStoreFound,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primary,
|
||||
@@ -183,17 +181,17 @@ class HomeScreen extends StatelessWidget {
|
||||
children: [
|
||||
QuickActionButton(
|
||||
icon: Icons.add,
|
||||
label: "Servizio",
|
||||
label: context.l10n.commonOperation,
|
||||
color: Colors.blue,
|
||||
onTap: () {
|
||||
// Entriamo nel form! Nessun parametro extra = Nuovo Servizio
|
||||
context.push('/service-form');
|
||||
context.push('/operation-form');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
QuickActionButton(
|
||||
icon: Icons.handyman,
|
||||
label: "Assistenza",
|
||||
label: context.l10n.homeNewOperationTicket,
|
||||
color: Colors.redAccent,
|
||||
onTap: () {
|
||||
// TODO: Quando avrai la rotta per la nuova assistenza
|
||||
@@ -203,7 +201,7 @@ class HomeScreen extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
QuickActionButton(
|
||||
icon: Icons.note_add,
|
||||
label: "Nota",
|
||||
label: context.l10n.commonNote,
|
||||
color: Colors.amber,
|
||||
onTap: () {
|
||||
// TODO: Quando faremo il modale/pagina delle note
|
||||
@@ -212,7 +210,7 @@ class HomeScreen extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
QuickActionButton(
|
||||
icon: Icons.task_alt,
|
||||
label: "Task",
|
||||
label: context.l10n.commonTask,
|
||||
color: Colors.teal,
|
||||
onTap: () {
|
||||
// TODO: Quando faremo i task
|
||||
@@ -280,7 +278,7 @@ class HomeScreen extends StatelessWidget {
|
||||
const Spacer(),
|
||||
Center(
|
||||
child: Text(
|
||||
"(Coming Soon)",
|
||||
context.l10n.commonComingSoon,
|
||||
style: TextStyle(
|
||||
color: context.secondaryText.withValues(alpha: 0.7),
|
||||
fontStyle: FontStyle.italic,
|
||||
@@ -354,7 +352,7 @@ class HomeScreen extends StatelessWidget {
|
||||
: theme.iconTheme.color,
|
||||
),
|
||||
title: Text(
|
||||
store.nome,
|
||||
store.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
|
||||
@@ -9,19 +9,17 @@ import 'package:get_it/get_it.dart';
|
||||
|
||||
part 'product_state.dart';
|
||||
|
||||
class ProductCubit extends Cubit<ProductState> {
|
||||
class ProductsCubit extends Cubit<ProductState> {
|
||||
final ProductRepository _repository = GetIt.I<ProductRepository>();
|
||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||
|
||||
ProductCubit() : super(const ProductState());
|
||||
ProductsCubit() : super(const ProductState());
|
||||
|
||||
// Caricamento iniziale dei Brand
|
||||
Future<void> loadBrands() async {
|
||||
emit(state.copyWith(status: ProductStatus.loading));
|
||||
try {
|
||||
final brands = await _repository.getBrands(
|
||||
_sessionCubit.state.company!.id!,
|
||||
);
|
||||
final brands = await _repository.getBrands();
|
||||
emit(state.copyWith(status: ProductStatus.success, brands: brands));
|
||||
} catch (e) {
|
||||
emit(
|
||||
@@ -30,6 +28,27 @@ class ProductCubit extends Cubit<ProductState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadModels() async {
|
||||
emit(state.copyWith(status: ProductStatus.loading));
|
||||
try {
|
||||
final models = await _repository.getModels();
|
||||
emit(state.copyWith(status: ProductStatus.success, models: models));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(status: ProductStatus.error, errorMessage: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshCubit() async {
|
||||
if (state.selectedBrand != null) {
|
||||
await selectBrand(state.selectedBrand);
|
||||
} else {
|
||||
emit(state.copyWith(status: ProductStatus.initial));
|
||||
await loadBrands();
|
||||
}
|
||||
}
|
||||
|
||||
// Selezione Brand e caricamento Modelli
|
||||
Future<void> selectBrand(BrandModel? brand) async {
|
||||
if (brand == null) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/brand_model.dart';
|
||||
@@ -5,16 +6,17 @@ import '../models/model_model.dart';
|
||||
|
||||
class ProductRepository {
|
||||
final SupabaseClient _supabase = GetIt.I<SupabaseClient>();
|
||||
final String _companyId = GetIt.I<SessionCubit>().state.company!.id!;
|
||||
|
||||
// --- BRAND ---
|
||||
|
||||
/// Recupera tutti i brand dell'azienda
|
||||
Future<List<BrandModel>> getBrands(String companyId) async {
|
||||
Future<List<BrandModel>> getBrands() async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('brand')
|
||||
.select()
|
||||
.eq('company_id', companyId)
|
||||
.eq('company_id', _companyId)
|
||||
.eq('is_active', true)
|
||||
.order('name');
|
||||
|
||||
@@ -57,6 +59,19 @@ class ProductRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ModelModel>> getModels() async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('model')
|
||||
.select()
|
||||
.eq('is_active', true)
|
||||
.order('name');
|
||||
return (response as List).map((m) => ModelModel.fromJson(m)).toList();
|
||||
} catch (e) {
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea o aggiorna un modello
|
||||
/// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato!
|
||||
Future<ModelModel> upsertModel(ModelModel model) async {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/utils/string_extensions.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
|
||||
class BrandModel extends Equatable {
|
||||
final String? id;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/utils/string_extensions.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
|
||||
class ModelModel extends Equatable {
|
||||
final String? id;
|
||||
|
||||
@@ -33,7 +33,7 @@ class BrandSelector extends StatelessWidget {
|
||||
return DropdownMenuItem(value: brand, child: Text(brand.name));
|
||||
}).toList(),
|
||||
onChanged: (brand) =>
|
||||
context.read<ProductCubit>().selectBrand(brand),
|
||||
context.read<ProductsCubit>().selectBrand(brand),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
@@ -64,7 +64,7 @@ class ModelsList extends StatelessWidget {
|
||||
color: model.isActive ? context.accent : Colors.grey,
|
||||
),
|
||||
onPressed: () => context
|
||||
.read<ProductCubit>()
|
||||
.read<ProductsCubit>()
|
||||
.toggleStatus('model', model.id!, model.isActive),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -40,7 +40,7 @@ void _submitBrand(
|
||||
BrandModel? brand,
|
||||
) {
|
||||
if (controller.text.trim().isNotEmpty) {
|
||||
context.read<ProductCubit>().saveBrand(controller.text, id: brand?.id);
|
||||
context.read<ProductsCubit>().saveBrand(controller.text, id: brand?.id);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ void _submitModel(
|
||||
ModelModel? model,
|
||||
) {
|
||||
if (controller.text.isNotEmpty) {
|
||||
context.read<ProductCubit>().saveModel(controller.text, id: model?.id);
|
||||
context.read<ProductsCubit>().saveModel(controller.text, id: model?.id);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class ProductsScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Carichiamo i brand appena la pagina viene creata
|
||||
context.read<ProductCubit>().loadBrands();
|
||||
context.read<ProductsCubit>().loadBrands();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: context.background,
|
||||
@@ -33,7 +33,7 @@ class ProductsScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
body: BlocConsumer<ProductCubit, ProductState>(
|
||||
body: BlocConsumer<ProductsCubit, ProductState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == ProductStatus.error) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -23,7 +23,7 @@ class _QuickProductDialogState extends State<QuickProductDialog> {
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final newModel = await context.read<ProductCubit>().quickCreateProduct(
|
||||
final newModel = await context.read<ProductsCubit>().quickCreateProduct(
|
||||
brandName: _selectedBrandName.trim(),
|
||||
modelName: _modelCtrl.text.trim(),
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ class ProviderRepository {
|
||||
)
|
||||
''')
|
||||
.eq('company_id', companyId)
|
||||
.order('nome');
|
||||
.order('name');
|
||||
|
||||
return (response as List).map((m) => ProviderModel.fromMap(m)).toList();
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,28 +3,30 @@ import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||
|
||||
class ProviderModel extends Equatable {
|
||||
final String? id;
|
||||
final String nome;
|
||||
final bool telefoniaFissa;
|
||||
final bool telefoniaMobile;
|
||||
final bool energia;
|
||||
final bool assicurazioni;
|
||||
final bool intrattenimento;
|
||||
final bool finanziamenti;
|
||||
final bool altro;
|
||||
final String name;
|
||||
final bool landline;
|
||||
final bool mobile;
|
||||
final bool energy;
|
||||
final bool insurance;
|
||||
final bool entertainment;
|
||||
final bool financing;
|
||||
final bool telepass;
|
||||
final bool other;
|
||||
final bool isActive;
|
||||
final String companyId;
|
||||
final List<StoreModel> associatedStores;
|
||||
|
||||
const ProviderModel({
|
||||
this.id,
|
||||
required this.nome,
|
||||
required this.telefoniaFissa,
|
||||
required this.telefoniaMobile,
|
||||
required this.energia,
|
||||
required this.assicurazioni,
|
||||
required this.intrattenimento,
|
||||
required this.finanziamenti,
|
||||
required this.altro,
|
||||
required this.name,
|
||||
required this.landline,
|
||||
required this.mobile,
|
||||
required this.energy,
|
||||
required this.insurance,
|
||||
required this.entertainment,
|
||||
required this.financing,
|
||||
required this.telepass,
|
||||
required this.other,
|
||||
required this.isActive,
|
||||
required this.companyId,
|
||||
this.associatedStores = const [],
|
||||
@@ -44,14 +46,15 @@ class ProviderModel extends Equatable {
|
||||
}
|
||||
return ProviderModel(
|
||||
id: map['id'],
|
||||
nome: map['nome'],
|
||||
telefoniaFissa: map['telefonia_fissa'] ?? false,
|
||||
telefoniaMobile: map['telefonia_mobile'] ?? false,
|
||||
energia: map['energia'] ?? false,
|
||||
assicurazioni: map['assicurazioni'] ?? false,
|
||||
intrattenimento: map['intrattenimento'] ?? false,
|
||||
finanziamenti: map['finanziamenti'] ?? false,
|
||||
altro: map['altro'] ?? false,
|
||||
name: map['name'],
|
||||
landline: map['landline'] ?? false,
|
||||
mobile: map['mobile'] ?? false,
|
||||
energy: map['energy'] ?? false,
|
||||
insurance: map['insurance'] ?? false,
|
||||
entertainment: map['entertainment'] ?? false,
|
||||
financing: map['financing'] ?? false,
|
||||
telepass: map['telepass'] ?? false,
|
||||
other: map['other'] ?? false,
|
||||
isActive: map['is_active'] ?? true,
|
||||
companyId: map['company_id'],
|
||||
associatedStores: stores,
|
||||
@@ -60,14 +63,15 @@ class ProviderModel extends Equatable {
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final map = {
|
||||
'nome': nome,
|
||||
'telefonia_fissa': telefoniaFissa,
|
||||
'telefonia_mobile': telefoniaMobile,
|
||||
'energia': energia,
|
||||
'assicurazioni': assicurazioni,
|
||||
'intrattenimento': intrattenimento,
|
||||
'finanziamenti': finanziamenti,
|
||||
'altro': altro,
|
||||
'name': name,
|
||||
'landline': landline,
|
||||
'mobile': mobile,
|
||||
'energy': energy,
|
||||
'insurance': insurance,
|
||||
'entertainment': entertainment,
|
||||
'financing': financing,
|
||||
'telepass': telepass,
|
||||
'other': other,
|
||||
'is_active': isActive,
|
||||
'company_id': companyId,
|
||||
};
|
||||
@@ -82,14 +86,15 @@ class ProviderModel extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
nome,
|
||||
telefoniaFissa,
|
||||
telefoniaMobile,
|
||||
energia,
|
||||
assicurazioni,
|
||||
intrattenimento,
|
||||
finanziamenti,
|
||||
altro,
|
||||
name,
|
||||
landline,
|
||||
mobile,
|
||||
energy,
|
||||
insurance,
|
||||
entertainment,
|
||||
financing,
|
||||
telepass,
|
||||
other,
|
||||
isActive,
|
||||
companyId,
|
||||
associatedStores,
|
||||
@@ -97,28 +102,30 @@ class ProviderModel extends Equatable {
|
||||
|
||||
ProviderModel copyWith({
|
||||
String? id,
|
||||
String? nome,
|
||||
bool? telefoniaFissa,
|
||||
bool? telefoniaMobile,
|
||||
bool? energia,
|
||||
bool? assicurazioni,
|
||||
bool? intrattenimento,
|
||||
bool? finanziamenti,
|
||||
bool? altro,
|
||||
String? name,
|
||||
bool? landline,
|
||||
bool? mobile,
|
||||
bool? energy,
|
||||
bool? insurance,
|
||||
bool? entertainment,
|
||||
bool? financing,
|
||||
bool? telepass,
|
||||
bool? other,
|
||||
bool? isActive,
|
||||
String? companyId,
|
||||
List<StoreModel>? associatedStores,
|
||||
}) {
|
||||
return ProviderModel(
|
||||
id: id ?? this.id,
|
||||
nome: nome ?? this.nome,
|
||||
telefoniaFissa: telefoniaFissa ?? this.telefoniaFissa,
|
||||
telefoniaMobile: telefoniaMobile ?? this.telefoniaMobile,
|
||||
energia: energia ?? this.energia,
|
||||
assicurazioni: assicurazioni ?? this.assicurazioni,
|
||||
intrattenimento: intrattenimento ?? this.intrattenimento,
|
||||
finanziamenti: finanziamenti ?? this.finanziamenti,
|
||||
altro: altro ?? this.altro,
|
||||
name: name ?? this.name,
|
||||
landline: landline ?? this.landline,
|
||||
mobile: mobile ?? this.mobile,
|
||||
energy: energy ?? this.energy,
|
||||
insurance: insurance ?? this.insurance,
|
||||
entertainment: entertainment ?? this.entertainment,
|
||||
financing: financing ?? this.financing,
|
||||
telepass: telepass ?? this.telepass,
|
||||
other: other ?? this.other,
|
||||
isActive: isActive ?? this.isActive,
|
||||
companyId: companyId ?? this.companyId,
|
||||
associatedStores: associatedStores ?? this.associatedStores,
|
||||
|
||||
@@ -15,13 +15,14 @@ class ProviderFormSheet extends StatefulWidget {
|
||||
|
||||
class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
||||
late TextEditingController _nameController;
|
||||
late bool _telefoniaFissa;
|
||||
late bool _telefoniaMobile;
|
||||
late bool _energia;
|
||||
late bool _assicurazioni;
|
||||
late bool _intrattenimento;
|
||||
late bool _finanziamenti;
|
||||
late bool _altro;
|
||||
late bool _landline;
|
||||
late bool _mobile;
|
||||
late bool _energy;
|
||||
late bool _insurance;
|
||||
late bool _entertainment;
|
||||
late bool _financing;
|
||||
late bool _telepass;
|
||||
late bool _other;
|
||||
late bool _isActive;
|
||||
final List<String> _tempSelectedStoreIds =
|
||||
[]; // Per gestire la selezione temporanea dei negozi
|
||||
@@ -33,14 +34,15 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
||||
for (final store in p?.associatedStores ?? []) {
|
||||
_tempSelectedStoreIds.add(store.id!);
|
||||
}
|
||||
_nameController = TextEditingController(text: p?.nome ?? '');
|
||||
_telefoniaFissa = p?.telefoniaFissa ?? false;
|
||||
_telefoniaMobile = p?.telefoniaMobile ?? false;
|
||||
_energia = p?.energia ?? false;
|
||||
_assicurazioni = p?.assicurazioni ?? false;
|
||||
_intrattenimento = p?.intrattenimento ?? false;
|
||||
_finanziamenti = p?.finanziamenti ?? false;
|
||||
_altro = p?.altro ?? false;
|
||||
_nameController = TextEditingController(text: p?.name ?? '');
|
||||
_landline = p?.landline ?? false;
|
||||
_mobile = p?.mobile ?? false;
|
||||
_energy = p?.energy ?? false;
|
||||
_insurance = p?.insurance ?? false;
|
||||
_entertainment = p?.entertainment ?? false;
|
||||
_financing = p?.financing ?? false;
|
||||
_telepass = p?.telepass ?? false;
|
||||
_other = p?.other ?? false;
|
||||
_isActive = p?.isActive ?? true;
|
||||
}
|
||||
|
||||
@@ -57,14 +59,15 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
||||
final cubit = context.read<ProvidersCubit>();
|
||||
final provider = ProviderModel(
|
||||
id: widget.initialProvider?.id, // Se nullo, Supabase farà insert
|
||||
nome: _nameController.text.trim(),
|
||||
telefoniaFissa: _telefoniaFissa,
|
||||
telefoniaMobile: _telefoniaMobile,
|
||||
energia: _energia,
|
||||
assicurazioni: _assicurazioni,
|
||||
intrattenimento: _intrattenimento,
|
||||
finanziamenti: _finanziamenti,
|
||||
altro: _altro,
|
||||
name: _nameController.text.trim(),
|
||||
landline: _landline,
|
||||
mobile: _mobile,
|
||||
energy: _energy,
|
||||
insurance: _insurance,
|
||||
entertainment: _entertainment,
|
||||
financing: _financing,
|
||||
telepass: _telepass,
|
||||
other: _other,
|
||||
isActive: _isActive,
|
||||
companyId:
|
||||
'', // Verrà ignorato dal toMap e gestito dal Cubit/SessionBloc se hai messo la logica lì
|
||||
@@ -110,38 +113,43 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
||||
),
|
||||
_buildSwitch(
|
||||
"Energia (Luce/Gas)",
|
||||
_energia,
|
||||
(v) => setState(() => _energia = v),
|
||||
_energy,
|
||||
(v) => setState(() => _energy = v),
|
||||
),
|
||||
_buildSwitch(
|
||||
"Telefonia Fissa",
|
||||
_telefoniaFissa,
|
||||
(v) => setState(() => _telefoniaFissa = v),
|
||||
_landline,
|
||||
(v) => setState(() => _landline = v),
|
||||
),
|
||||
_buildSwitch(
|
||||
"Telefonia Mobile",
|
||||
_telefoniaMobile,
|
||||
(v) => setState(() => _telefoniaMobile = v),
|
||||
_mobile,
|
||||
(v) => setState(() => _mobile = v),
|
||||
),
|
||||
_buildSwitch(
|
||||
"Assicurazioni",
|
||||
_assicurazioni,
|
||||
(v) => setState(() => _assicurazioni = v),
|
||||
_insurance,
|
||||
(v) => setState(() => _insurance = v),
|
||||
),
|
||||
_buildSwitch(
|
||||
"Intrattenimento",
|
||||
_intrattenimento,
|
||||
(v) => setState(() => _intrattenimento = v),
|
||||
_entertainment,
|
||||
(v) => setState(() => _entertainment = v),
|
||||
),
|
||||
_buildSwitch(
|
||||
"Finanziamenti",
|
||||
_finanziamenti,
|
||||
(v) => setState(() => _finanziamenti = v),
|
||||
_financing,
|
||||
(v) => setState(() => _financing = v),
|
||||
),
|
||||
_buildSwitch(
|
||||
"Telepass",
|
||||
_telepass,
|
||||
(v) => setState(() => _telepass = v),
|
||||
),
|
||||
_buildSwitch(
|
||||
"Altro/Accessori",
|
||||
_altro,
|
||||
(v) => setState(() => _altro = v),
|
||||
_other,
|
||||
(v) => setState(() => _other = v),
|
||||
),
|
||||
const Divider(),
|
||||
_buildSwitch(
|
||||
@@ -164,7 +172,7 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
||||
store.id,
|
||||
);
|
||||
return CheckboxListTile(
|
||||
title: Text(store.nome),
|
||||
title: Text(store.name),
|
||||
value: isAssociated,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
|
||||
@@ -93,7 +93,7 @@ class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
provider.nome,
|
||||
provider.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: _buildCardSubtitle(
|
||||
@@ -141,12 +141,13 @@ class _ProvidersMasterDataScreenState extends State<ProvidersMasterDataScreen> {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (p.telefoniaFissa || p.telefoniaMobile)
|
||||
_smallTag("📞 Tel", Colors.blue),
|
||||
if (p.energia) _smallTag("⚡ Energy", Colors.orange),
|
||||
if (p.assicurazioni) _smallTag("🛡️ Assic", Colors.teal),
|
||||
if (p.intrattenimento) _smallTag("📺 Ent", Colors.red),
|
||||
if (p.altro) _smallTag("📦 Altro", Colors.grey),
|
||||
if (p.landline || p.mobile) _smallTag("📞 Tel", Colors.blue),
|
||||
if (p.energy) _smallTag("⚡ Energy", Colors.orange),
|
||||
if (p.insurance) _smallTag("🛡️ Assic", Colors.teal),
|
||||
if (p.entertainment) _smallTag("📺 Ent", Colors.red),
|
||||
if (p.financing) _smallTag("💰 Fin", Colors.purple),
|
||||
if (p.telepass) _smallTag("🏎️ Telepass", Colors.yellow),
|
||||
if (p.other) _smallTag("📦 Altro", Colors.grey),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class StaffCubit extends Cubit<StaffState> {
|
||||
state.staffByStore,
|
||||
);
|
||||
newMap[storeId] = staffInStore;
|
||||
emit(state.copyWith(staffByStore: newMap));
|
||||
emit(state.copyWith(staffByStore: newMap, storeStaff: staffInStore));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(status: StaffStatus.error, error: e.toString()));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class StaffState extends Equatable {
|
||||
final List<StaffMemberModel> allStaff;
|
||||
final Map<String, List<StoreModel>> storesByStaff;
|
||||
final Map<String, List<StaffMemberModel>> staffByStore;
|
||||
final List<StaffMemberModel> storeStaff;
|
||||
final String? error;
|
||||
|
||||
const StaffState({
|
||||
@@ -14,6 +15,7 @@ class StaffState extends Equatable {
|
||||
this.allStaff = const [],
|
||||
this.storesByStaff = const {},
|
||||
this.staffByStore = const {},
|
||||
this.storeStaff = const [],
|
||||
this.error,
|
||||
});
|
||||
|
||||
@@ -22,6 +24,7 @@ class StaffState extends Equatable {
|
||||
List<StaffMemberModel>? allStaff,
|
||||
Map<String, List<StoreModel>>? storesByStaff,
|
||||
Map<String, List<StaffMemberModel>>? staffByStore,
|
||||
List<StaffMemberModel>? storeStaff,
|
||||
String? error,
|
||||
}) {
|
||||
return StaffState(
|
||||
@@ -29,6 +32,7 @@ class StaffState extends Equatable {
|
||||
allStaff: allStaff ?? this.allStaff,
|
||||
storesByStaff: storesByStaff ?? this.storesByStaff,
|
||||
staffByStore: staffByStore ?? this.staffByStore,
|
||||
storeStaff: storeStaff ?? this.storeStaff,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
@@ -39,6 +43,7 @@ class StaffState extends Equatable {
|
||||
allStaff,
|
||||
storesByStaff,
|
||||
staffByStore,
|
||||
storeStaff,
|
||||
error,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ class _StaffScreenState extends State<StaffScreen> {
|
||||
initialValue: _selectedStoreId,
|
||||
decoration: const InputDecoration(labelText: "Filtra per Negozio"),
|
||||
items: state.stores
|
||||
.map((s) => DropdownMenuItem(value: s.id, child: Text(s.nome)))
|
||||
.map((s) => DropdownMenuItem(value: s.id, child: Text(s.name)))
|
||||
.toList(),
|
||||
onChanged: (id) {
|
||||
setState(() => _selectedStoreId = id);
|
||||
@@ -355,7 +355,7 @@ class _StaffScreenState extends State<StaffScreen> {
|
||||
store.id,
|
||||
);
|
||||
return FilterChip(
|
||||
label: Text(store.nome),
|
||||
label: Text(store.name),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setModalState(() {
|
||||
|
||||
@@ -98,7 +98,7 @@ class StoreRepository {
|
||||
)
|
||||
''')
|
||||
.eq('company_id', companyId)
|
||||
.order('nome');
|
||||
.order('name');
|
||||
|
||||
return (response as List).map((m) => StoreModel.fromMap(m)).toList();
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,30 +4,30 @@ import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||
|
||||
class StoreModel extends Equatable {
|
||||
final String? id;
|
||||
final String nome;
|
||||
final String name;
|
||||
final String companyId;
|
||||
final bool isActive;
|
||||
final bool isPaid;
|
||||
final DateTime? paymentExpiration;
|
||||
final String indirizzo;
|
||||
final String cap;
|
||||
final String comune;
|
||||
final String provincia;
|
||||
final String address;
|
||||
final String zipCode;
|
||||
final String city;
|
||||
final String province;
|
||||
final List<ProviderModel> associatedProviders; // Provider associati
|
||||
final List<StaffMemberModel>
|
||||
associatedStaffMembers; // Membri dello staff associati
|
||||
|
||||
const StoreModel({
|
||||
this.id,
|
||||
required this.nome,
|
||||
required this.name,
|
||||
required this.companyId,
|
||||
this.isActive = true,
|
||||
this.isPaid = false,
|
||||
this.paymentExpiration,
|
||||
required this.indirizzo,
|
||||
required this.cap,
|
||||
required this.comune,
|
||||
required this.provincia,
|
||||
required this.address,
|
||||
required this.zipCode,
|
||||
required this.city,
|
||||
required this.province,
|
||||
this.associatedProviders = const [],
|
||||
this.associatedStaffMembers = const [],
|
||||
});
|
||||
@@ -36,15 +36,15 @@ class StoreModel extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
nome,
|
||||
name,
|
||||
companyId,
|
||||
isActive,
|
||||
isPaid,
|
||||
paymentExpiration,
|
||||
indirizzo,
|
||||
cap,
|
||||
comune,
|
||||
provincia,
|
||||
address,
|
||||
zipCode,
|
||||
city,
|
||||
province,
|
||||
associatedProviders,
|
||||
associatedStaffMembers,
|
||||
];
|
||||
@@ -52,29 +52,29 @@ class StoreModel extends Equatable {
|
||||
// Il mitico copyWith per creare nuove istanze modificando solo ciò che serve
|
||||
StoreModel copyWith({
|
||||
String? id,
|
||||
String? nome,
|
||||
String? name,
|
||||
String? companyId,
|
||||
bool? isActive,
|
||||
bool? isPaid,
|
||||
DateTime? paymentExpiration,
|
||||
String? indirizzo,
|
||||
String? cap,
|
||||
String? comune,
|
||||
String? provincia,
|
||||
String? address,
|
||||
String? zipCode,
|
||||
String? city,
|
||||
String? province,
|
||||
List<ProviderModel>? associatedProviders,
|
||||
List<StaffMemberModel>? associatedStaffMembers,
|
||||
}) {
|
||||
return StoreModel(
|
||||
id: id ?? this.id,
|
||||
nome: nome ?? this.nome,
|
||||
name: name ?? this.name,
|
||||
companyId: companyId ?? this.companyId,
|
||||
isActive: isActive ?? this.isActive,
|
||||
isPaid: isPaid ?? this.isPaid,
|
||||
paymentExpiration: paymentExpiration ?? this.paymentExpiration,
|
||||
indirizzo: indirizzo ?? this.indirizzo,
|
||||
cap: cap ?? this.cap,
|
||||
comune: comune ?? this.comune,
|
||||
provincia: provincia ?? this.provincia,
|
||||
address: address ?? this.address,
|
||||
zipCode: zipCode ?? this.zipCode,
|
||||
city: city ?? this.city,
|
||||
province: province ?? this.province,
|
||||
associatedProviders: associatedProviders ?? this.associatedProviders,
|
||||
associatedStaffMembers:
|
||||
associatedStaffMembers ?? this.associatedStaffMembers,
|
||||
@@ -83,12 +83,12 @@ class StoreModel extends Equatable {
|
||||
|
||||
factory StoreModel.empty() {
|
||||
return const StoreModel(
|
||||
nome: '',
|
||||
name: '',
|
||||
companyId: '',
|
||||
indirizzo: '',
|
||||
cap: '',
|
||||
comune: '',
|
||||
provincia: '',
|
||||
address: '',
|
||||
zipCode: '',
|
||||
city: '',
|
||||
province: '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,17 +118,17 @@ class StoreModel extends Equatable {
|
||||
}
|
||||
return StoreModel(
|
||||
id: map['id'] as String,
|
||||
nome: map['nome'],
|
||||
name: map['name'],
|
||||
companyId: map['company_id'] as String,
|
||||
isActive: map['is_active'] ?? true,
|
||||
isPaid: map['is_paid'] ?? false,
|
||||
paymentExpiration: map['payment_expiration'] != null
|
||||
? DateTime.parse(map['payment_expiration'])
|
||||
: null,
|
||||
indirizzo: map['indirizzo'],
|
||||
cap: map['cap'],
|
||||
comune: map['comune'],
|
||||
provincia: map['provincia'],
|
||||
address: map['address'],
|
||||
zipCode: map['zip_code'],
|
||||
city: map['city'],
|
||||
province: map['province'],
|
||||
associatedProviders: providers,
|
||||
associatedStaffMembers: staffMembers,
|
||||
);
|
||||
@@ -137,16 +137,16 @@ class StoreModel extends Equatable {
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'nome': nome,
|
||||
'name': name,
|
||||
'company_id': companyId,
|
||||
'is_active': isActive,
|
||||
'is_paid': isPaid,
|
||||
if (paymentExpiration != null)
|
||||
'payment_expiration': paymentExpiration!.toIso8601String(),
|
||||
'indirizzo': indirizzo,
|
||||
'cap': cap,
|
||||
'comune': comune,
|
||||
'provincia': provincia,
|
||||
'address': address,
|
||||
'zip_code': zipCode,
|
||||
'city': city,
|
||||
'province': province,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +37,14 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
|
||||
final company = context.read<SessionCubit>().state.company;
|
||||
if (company != null) {
|
||||
setState(() {
|
||||
_indirizzoController.text = company.indirizzo;
|
||||
_capController.text = company.cap;
|
||||
_indirizzoController.text = company.address;
|
||||
_capController.text = company.zipCode;
|
||||
_comuneController.text =
|
||||
company.citta; // Nel DB company è 'citta', store è 'comune'
|
||||
_provinciaController.text = company.provincia;
|
||||
company.city; // Nel DB company è 'citta', store è 'comune'
|
||||
_provinciaController.text = company.province;
|
||||
// Suggeriamo anche un nome se vuoto
|
||||
if (_nomeController.text.isEmpty) {
|
||||
_nomeController.text = '${company.ragioneSociale} - Sede';
|
||||
_nomeController.text = '${company.name} - Sede';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,12 +68,12 @@ class _CreateStoreScreenState extends State<CreateStoreScreen> {
|
||||
}
|
||||
|
||||
final store = StoreModel(
|
||||
nome: _nomeController.text.trim(),
|
||||
name: _nomeController.text.trim(),
|
||||
companyId: company.id!,
|
||||
indirizzo: _indirizzoController.text.trim(),
|
||||
cap: _capController.text.trim(),
|
||||
comune: _comuneController.text.trim(),
|
||||
provincia: _provinciaController.text.trim().toUpperCase(),
|
||||
address: _indirizzoController.text.trim(),
|
||||
zipCode: _capController.text.trim(),
|
||||
city: _comuneController.text.trim(),
|
||||
province: _provinciaController.text.trim().toUpperCase(),
|
||||
);
|
||||
|
||||
context.read<StoreCubit>().createStore(store);
|
||||
|
||||
@@ -53,11 +53,11 @@ class _StoreCardState extends State<StoreCard> {
|
||||
color: widget.store.isActive ? context.accent : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
widget.store.nome,
|
||||
widget.store.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
"${widget.store.comune} (${widget.store.provincia}) - ${widget.store.indirizzo}",
|
||||
"${widget.store.city} (${widget.store.province}) - ${widget.store.address}",
|
||||
),
|
||||
trailing: Switch(
|
||||
value: widget.store.isActive,
|
||||
@@ -129,7 +129,7 @@ class _StoreCardState extends State<StoreCard> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"Personale di ${store.nome}",
|
||||
"Personale di ${store.name}",
|
||||
style: context.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -184,14 +184,14 @@ class _StoreCardState extends State<StoreCard> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Providers di ${store.nome}", style: context.titleLarge),
|
||||
Text("Providers di ${store.name}", style: context.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
...state.allProviders.map((provider) {
|
||||
final isAssociated = _tempAssociatedProviders.any(
|
||||
(p) => p.id == provider.id,
|
||||
);
|
||||
return CheckboxListTile(
|
||||
title: Text(provider.nome),
|
||||
title: Text(provider.name),
|
||||
value: isAssociated,
|
||||
onChanged: (selected) {
|
||||
if (selected == true) {
|
||||
|
||||
@@ -24,11 +24,11 @@ class _StoreFormState extends State<StoreForm> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.store != null) {
|
||||
nomeController.text = widget.store!.nome;
|
||||
indirizzoController.text = widget.store!.indirizzo;
|
||||
capController.text = widget.store!.cap;
|
||||
comuneController.text = widget.store!.comune;
|
||||
provinciaController.text = widget.store!.provincia;
|
||||
nomeController.text = widget.store!.name;
|
||||
indirizzoController.text = widget.store!.address;
|
||||
capController.text = widget.store!.zipCode;
|
||||
comuneController.text = widget.store!.city;
|
||||
provinciaController.text = widget.store!.province;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,11 +124,11 @@ class _StoreFormState extends State<StoreForm> {
|
||||
id: widget
|
||||
.store
|
||||
?.id, // Se nullo, Supabase ne crea uno nuovo
|
||||
nome: nomeController.text,
|
||||
indirizzo: indirizzoController.text,
|
||||
cap: capController.text,
|
||||
comune: comuneController.text,
|
||||
provincia: provinciaController.text,
|
||||
name: nomeController.text,
|
||||
address: indirizzoController.text,
|
||||
zipCode: capController.text,
|
||||
city: comuneController.text,
|
||||
province: provinciaController.text,
|
||||
companyId: context
|
||||
.read<SessionCubit>()
|
||||
.state
|
||||
|
||||
@@ -25,7 +25,7 @@ class OnboardingCubit extends Cubit<OnboardingState> {
|
||||
Future<void> saveCompany(String companyName) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
final company = CompanyModel.empty().copyWith(
|
||||
ragioneSociale: companyName,
|
||||
name: companyName,
|
||||
userId: GetIt.I<SupabaseClient>().auth.currentUser!.id,
|
||||
subscriptionTier: SubscriptionTier.pro,
|
||||
subscriptionStatus: SubscriptionStatus.trialing,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart'; // <-- IMPORTANTE per i formatter
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/widgets/flux_text_field.dart';
|
||||
import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||
@@ -134,12 +135,12 @@ class _StoreOnboardingFormState extends State<StoreOnboardingForm> {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// MIRACOLO DELLA FACTORY EMPTY!
|
||||
final newStore = StoreModel.empty().copyWith(
|
||||
nome: _nameCtrl.text.trim(),
|
||||
indirizzo: _addressCtrl.text.trim(),
|
||||
comune: _cityCtrl.text.trim(),
|
||||
cap: _zipCodeCtrl.text.trim(),
|
||||
name: _nameCtrl.text.trim(),
|
||||
address: _addressCtrl.text.trim(),
|
||||
city: _cityCtrl.text.trim(),
|
||||
zipCode: _zipCodeCtrl.text.trim(),
|
||||
// Formattiamo in maiuscolo qui, al momento del salvataggio!
|
||||
provincia: _provinceCtrl.text.trim().toUpperCase(),
|
||||
province: _provinceCtrl.text.trim().toUpperCase(),
|
||||
);
|
||||
context.read<OnboardingCubit>().saveStore(newStore);
|
||||
}
|
||||
|
||||
389
lib/features/operations/blocs/operation_files_bloc.dart
Normal file
389
lib/features/operations/blocs/operation_files_bloc.dart
Normal file
@@ -0,0 +1,389 @@
|
||||
import 'dart:async';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
part 'operation_files_events.dart';
|
||||
part 'operation_files_state.dart';
|
||||
|
||||
class OperationFilesBloc
|
||||
extends Bloc<OperationFilesEvent, OperationFilesState> {
|
||||
final _repository = GetIt.I.get<OperationsRepository>();
|
||||
final String? operationId;
|
||||
|
||||
OperationFilesBloc({this.operationId})
|
||||
: super(
|
||||
OperationFilesState(
|
||||
status: OperationFilesStatus.initial,
|
||||
operationId: operationId,
|
||||
),
|
||||
) {
|
||||
on<OperationsavedEvent>(_onOperationsaved);
|
||||
on<LoadOperationFilesEvent>(_onLoadOperationFiles);
|
||||
on<AddOperationFilesEvent>(_onAddOperationFiles);
|
||||
on<UploadOperationFilesEvent>(_onUploadOperationFiles);
|
||||
on<DeleteOperationFilesEvent>(_onDeleteOperationFiles);
|
||||
on<ToggleOperationFileSelectionEvent>(_onToggleOperationFileSelection);
|
||||
on<LinkFilesToCustomerEvent>(_onLinkFilesToCustomer);
|
||||
on<RenameOperationFileEvent>(_onRenameOperationFile);
|
||||
on<DeleteSpecificOperationFileEvent>(_onDeleteSpecificOperationFiles);
|
||||
on<SelectAllOperationFilesEvent>(_onSelectAllOperationFiles);
|
||||
on<ClearOperationFileSelectionEvent>(_onClearOperationFileSelection);
|
||||
|
||||
// Se il BLoC nasce con un ID, accendiamo subito lo stream!
|
||||
if (operationId != null) {
|
||||
add(LoadOperationFilesEvent(operationId: operationId));
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onOperationsaved(
|
||||
OperationsavedEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) async {
|
||||
// 1. Aggiorniamo l'ID e mettiamo in loading
|
||||
emit(
|
||||
state.copyWith(
|
||||
operationId: event.operationId,
|
||||
status: OperationFilesStatus.uploading,
|
||||
),
|
||||
);
|
||||
|
||||
// 2. RECUPERO E UPLOAD DEI FILE "PARCHEGGIATI" (Pratica Nuova)
|
||||
if (state.localFiles.isNotEmpty) {
|
||||
try {
|
||||
final List<Future<void>> uploadTasks = [];
|
||||
|
||||
for (var file in state.localFiles) {
|
||||
// Ricreiamo il PlatformFile dal nostro AttachmentModel
|
||||
// così il repository lo accetta senza fare storie!
|
||||
final fakePlatformFile = PlatformFile(
|
||||
name: '${file.name}.${file.extension}',
|
||||
size: file.fileSize,
|
||||
bytes: file.localBytes,
|
||||
);
|
||||
|
||||
uploadTasks.add(
|
||||
_repository.uploadAndRegisterOperationFile(
|
||||
operationId: event.operationId, // L'ID APPENA NATO!
|
||||
pickedFile: fakePlatformFile,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Lanciamo tutti gli upload in parallelo
|
||||
await Future.wait(uploadTasks);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationFilesStatus.failure,
|
||||
error: "Errore upload post-salvataggio: $e",
|
||||
),
|
||||
);
|
||||
return; // Ci fermiamo qui se esplode qualcosa
|
||||
}
|
||||
}
|
||||
|
||||
// 3. FINE DEI GIOCHI! Svuotiamo i locali, passiamo a success e accendiamo lo Stream
|
||||
emit(state.copyWith(localFiles: [], status: OperationFilesStatus.success));
|
||||
|
||||
add(LoadOperationFilesEvent(operationId: event.operationId));
|
||||
}
|
||||
|
||||
FutureOr<void> _onLoadOperationFiles(
|
||||
LoadOperationFilesEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) async {
|
||||
final currentId = event.operationId ?? state.operationId;
|
||||
|
||||
if (currentId != null) {
|
||||
emit(state.copyWith(status: OperationFilesStatus.loading));
|
||||
|
||||
await emit.forEach(
|
||||
_repository.getOperationFilesStream(currentId),
|
||||
onData: (List<AttachmentModel> data) => state.copyWith(
|
||||
status: OperationFilesStatus.success,
|
||||
remoteFiles: data,
|
||||
),
|
||||
onError: (error, stackTrace) => state.copyWith(
|
||||
status: OperationFilesStatus.failure,
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onAddOperationFiles(
|
||||
AddOperationFilesEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) async {
|
||||
final currentId = state.operationId;
|
||||
|
||||
// BIVIO 1: PRATICA NUOVA (Nessun ID - salvataggio locale)
|
||||
if (currentId == null) {
|
||||
final companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
|
||||
final newLocalFiles = event.files.map((file) {
|
||||
return AttachmentModel(
|
||||
id: null,
|
||||
companyId: companyId,
|
||||
operationId: '', // Sarà riempito al salvataggio
|
||||
name: file.name.fileNameWithoutExtension(),
|
||||
extension: file.name.fileExtension(),
|
||||
storagePath: '',
|
||||
fileSize: file.size,
|
||||
localBytes: file.bytes,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
localFiles: [...state.localFiles, ...newLocalFiles],
|
||||
status: OperationFilesStatus.success,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID - Upload immediato)
|
||||
emit(state.copyWith(status: OperationFilesStatus.uploading));
|
||||
try {
|
||||
final List<Future<void>> uploadTasks = [];
|
||||
for (var file in event.files) {
|
||||
uploadTasks.add(
|
||||
_repository.uploadAndRegisterOperationFile(
|
||||
operationId: currentId,
|
||||
pickedFile: file,
|
||||
),
|
||||
);
|
||||
}
|
||||
await Future.wait(uploadTasks);
|
||||
emit(state.copyWith(status: OperationFilesStatus.success));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationFilesStatus.failure,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onUploadOperationFiles(
|
||||
UploadOperationFilesEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) async {
|
||||
if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) &&
|
||||
(event.photos == null || event.photos!.isEmpty)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.operationId == null) return;
|
||||
|
||||
emit(state.copyWith(status: OperationFilesStatus.uploading));
|
||||
try {
|
||||
final List<Future<void>> uploadTasks = [];
|
||||
|
||||
// 1. Gestione Documenti normali (PlatformFile)
|
||||
if (event.pickedFiles != null) {
|
||||
for (var file in event.pickedFiles!) {
|
||||
uploadTasks.add(
|
||||
_repository.uploadAndRegisterOperationFile(
|
||||
operationId: state.operationId!,
|
||||
pickedFile: file,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Gestione Foto Fotocamera (XFile)
|
||||
if (event.photos != null) {
|
||||
for (var photo in event.photos!) {
|
||||
// Leggiamo i byte asincronamente
|
||||
final bytes = await photo.readAsBytes();
|
||||
final fileSize = await photo.length();
|
||||
|
||||
// Lo travestiamo da PlatformFile per passarlo al Repository!
|
||||
final fakePlatformFile = PlatformFile(
|
||||
name: photo.name,
|
||||
size: fileSize,
|
||||
bytes: bytes,
|
||||
path: photo.path,
|
||||
);
|
||||
|
||||
uploadTasks.add(
|
||||
_repository.uploadAndRegisterOperationFile(
|
||||
operationId: state.operationId!,
|
||||
pickedFile: fakePlatformFile,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Esecuzione parallela di tutti i documenti e foto
|
||||
await Future.wait(uploadTasks);
|
||||
emit(state.copyWith(status: OperationFilesStatus.success));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationFilesStatus.failure,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onDeleteOperationFiles(
|
||||
DeleteOperationFilesEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: OperationFilesStatus.loading));
|
||||
try {
|
||||
await _repository.deleteOperationFiles(state.selectedFiles);
|
||||
emit(
|
||||
state.copyWith(status: OperationFilesStatus.success, selectedFiles: []),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationFilesStatus.failure,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onToggleOperationFileSelection(
|
||||
ToggleOperationFileSelectionEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) {
|
||||
final selectedFiles = List<AttachmentModel>.from(state.selectedFiles);
|
||||
if (selectedFiles.contains(event.file)) {
|
||||
selectedFiles.remove(event.file);
|
||||
} else {
|
||||
selectedFiles.add(event.file);
|
||||
}
|
||||
emit(state.copyWith(selectedFiles: selectedFiles));
|
||||
}
|
||||
|
||||
void _onSelectAllOperationFiles(
|
||||
SelectAllOperationFilesEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) {
|
||||
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
|
||||
emit(state.copyWith(selectedFiles: state.allFiles));
|
||||
}
|
||||
|
||||
void _onClearOperationFileSelection(
|
||||
ClearOperationFileSelectionEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) {
|
||||
// Svuotiamo brutalmente la lista
|
||||
emit(state.copyWith(selectedFiles: []));
|
||||
}
|
||||
|
||||
FutureOr<void> _onLinkFilesToCustomer(
|
||||
LinkFilesToCustomerEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) async {
|
||||
if (state.selectedFiles.isEmpty) return;
|
||||
|
||||
// BIVIO 1: PRATICA NUOVA (Modalità Locale)
|
||||
if (state.operationId == null) {
|
||||
// Mappiamo i file locali: se sono tra quelli selezionati, iniettiamo il customerId
|
||||
final updatedLocalFiles = state.localFiles.map((file) {
|
||||
if (state.selectedFiles.contains(file)) {
|
||||
return file.copyWith(customerId: event.customerId);
|
||||
}
|
||||
return file;
|
||||
}).toList();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
localFiles: updatedLocalFiles,
|
||||
selectedFiles: [], // Svuotiamo la selezione dopo averli associati
|
||||
status: OperationFilesStatus.success, // o un toast di feedback
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// BIVIO 2: PRATICA ESISTENTE (Modalità Remota su DB)
|
||||
emit(state.copyWith(status: OperationFilesStatus.loading));
|
||||
try {
|
||||
final List<Future<void>> linkTasks = [];
|
||||
|
||||
for (var file in state.selectedFiles) {
|
||||
linkTasks.add(
|
||||
_repository.copyFileToCustomer(
|
||||
file: file,
|
||||
customerId: event.customerId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Future.wait(linkTasks);
|
||||
|
||||
// Svuotiamo la selezione.
|
||||
// NON serve aggiornare la lista a mano, perché il DB si aggiorna
|
||||
// e lo Stream di Supabase spingerà automaticamente in UI i file aggiornati!
|
||||
emit(
|
||||
state.copyWith(status: OperationFilesStatus.success, selectedFiles: []),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationFilesStatus.failure,
|
||||
error: "Errore associazione: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onRenameOperationFile(
|
||||
RenameOperationFileEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) async {
|
||||
// BIVIO 1: File Locale (Bozza)
|
||||
if (event.file.localBytes != null) {
|
||||
final updatedLocalFiles = state.localFiles.map((f) {
|
||||
if (f == event.file) {
|
||||
return f.copyWith(name: event.newName);
|
||||
}
|
||||
return f;
|
||||
}).toList();
|
||||
emit(state.copyWith(localFiles: updatedLocalFiles));
|
||||
return;
|
||||
}
|
||||
|
||||
// BIVIO 2: File Remoto (Salvato su DB)
|
||||
emit(state.copyWith(status: OperationFilesStatus.loading));
|
||||
try {
|
||||
await _repository.renameAttachment(event.file.id!, event.newName);
|
||||
emit(state.copyWith(status: OperationFilesStatus.success));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationFilesStatus.failure,
|
||||
error: "Errore rinomina: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<void> _onDeleteSpecificOperationFiles(
|
||||
DeleteSpecificOperationFileEvent event,
|
||||
Emitter<OperationFilesState> emit,
|
||||
) {
|
||||
if (event.file.localBytes != null) {
|
||||
final updatedLocalFiles = state.localFiles
|
||||
.where((f) => f != event.file)
|
||||
.toList();
|
||||
emit(state.copyWith(localFiles: updatedLocalFiles));
|
||||
}
|
||||
}
|
||||
}
|
||||
81
lib/features/operations/blocs/operation_files_events.dart
Normal file
81
lib/features/operations/blocs/operation_files_events.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
part of 'operation_files_bloc.dart';
|
||||
|
||||
abstract class OperationFilesEvent extends Equatable {
|
||||
const OperationFilesEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class OperationsavedEvent extends OperationFilesEvent {
|
||||
final String operationId;
|
||||
const OperationsavedEvent(this.operationId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [operationId];
|
||||
}
|
||||
|
||||
class LoadOperationFilesEvent extends OperationFilesEvent {
|
||||
final String? operationId;
|
||||
final AttachmentModel? operation;
|
||||
const LoadOperationFilesEvent({this.operationId, this.operation});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [operationId, operation];
|
||||
}
|
||||
|
||||
class AddOperationFilesEvent extends OperationFilesEvent {
|
||||
final List<PlatformFile> files;
|
||||
const AddOperationFilesEvent(this.files);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [files];
|
||||
}
|
||||
|
||||
class UploadOperationFilesEvent extends OperationFilesEvent {
|
||||
final List<PlatformFile>? pickedFiles;
|
||||
final List<XFile>? photos;
|
||||
const UploadOperationFilesEvent({this.pickedFiles, this.photos});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [pickedFiles, photos];
|
||||
}
|
||||
|
||||
class LinkFilesToCustomerEvent extends OperationFilesEvent {
|
||||
final String customerId;
|
||||
|
||||
const LinkFilesToCustomerEvent({required this.customerId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [customerId];
|
||||
}
|
||||
|
||||
class DeleteOperationFilesEvent extends OperationFilesEvent {}
|
||||
|
||||
class ToggleOperationFileSelectionEvent extends OperationFilesEvent {
|
||||
final AttachmentModel file;
|
||||
const ToggleOperationFileSelectionEvent(this.file);
|
||||
}
|
||||
|
||||
class RenameOperationFileEvent extends OperationFilesEvent {
|
||||
final AttachmentModel file;
|
||||
final String newName;
|
||||
|
||||
const RenameOperationFileEvent(this.file, this.newName);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [file, newName];
|
||||
}
|
||||
|
||||
class DeleteSpecificOperationFileEvent extends OperationFilesEvent {
|
||||
final AttachmentModel file;
|
||||
|
||||
const DeleteSpecificOperationFileEvent(this.file);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [file];
|
||||
}
|
||||
|
||||
class SelectAllOperationFilesEvent extends OperationFilesEvent {}
|
||||
|
||||
class ClearOperationFileSelectionEvent extends OperationFilesEvent {}
|
||||
52
lib/features/operations/blocs/operation_files_state.dart
Normal file
52
lib/features/operations/blocs/operation_files_state.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
part of 'operation_files_bloc.dart';
|
||||
|
||||
enum OperationFilesStatus { initial, loading, uploading, success, failure }
|
||||
|
||||
class OperationFilesState extends Equatable {
|
||||
const OperationFilesState({
|
||||
this.operationId,
|
||||
required this.status,
|
||||
this.error,
|
||||
this.localFiles = const [],
|
||||
this.remoteFiles = const [],
|
||||
this.selectedFiles = const [],
|
||||
});
|
||||
|
||||
final String? operationId;
|
||||
final OperationFilesStatus status;
|
||||
final String? error;
|
||||
final List<AttachmentModel> localFiles;
|
||||
final List<AttachmentModel> remoteFiles;
|
||||
|
||||
final List<AttachmentModel> selectedFiles;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
operationId,
|
||||
status,
|
||||
error,
|
||||
localFiles,
|
||||
remoteFiles,
|
||||
selectedFiles,
|
||||
];
|
||||
|
||||
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
|
||||
|
||||
OperationFilesState copyWith({
|
||||
String? operationId,
|
||||
OperationFilesStatus? status,
|
||||
String? error,
|
||||
List<AttachmentModel>? localFiles,
|
||||
List<AttachmentModel>? remoteFiles,
|
||||
List<AttachmentModel>? selectedFiles,
|
||||
}) {
|
||||
return OperationFilesState(
|
||||
operationId: operationId ?? this.operationId,
|
||||
status: status ?? this.status,
|
||||
error: error,
|
||||
localFiles: localFiles ?? this.localFiles,
|
||||
remoteFiles: remoteFiles ?? this.remoteFiles,
|
||||
selectedFiles: selectedFiles ?? this.selectedFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
304
lib/features/operations/blocs/operations_cubit.dart
Normal file
304
lib/features/operations/blocs/operations_cubit.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
part 'operations_state.dart';
|
||||
|
||||
class OperationsCubit extends Cubit<OperationsState> {
|
||||
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
|
||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||
final Uuid _uuid = const Uuid(); // Generatore di UUID per il batch
|
||||
|
||||
OperationsCubit()
|
||||
: super(const OperationsState(status: OperationsStatus.initial));
|
||||
|
||||
// --- CARICAMENTO E PAGINAZIONE ---
|
||||
|
||||
Future<void> loadOperations({bool refresh = false}) async {
|
||||
if (state.status == OperationsStatus.loading) return;
|
||||
if (!refresh && state.hasReachedMax) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.loading,
|
||||
errorMessage: null,
|
||||
allOperations: refresh ? [] : state.allOperations,
|
||||
hasReachedMax: refresh ? false : state.hasReachedMax,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final currentOffset = refresh ? 0 : state.allOperations.length;
|
||||
final companyId = _sessionCubit.state.company?.id;
|
||||
|
||||
if (companyId == null) {
|
||||
throw Exception("Company ID non trovato nella sessione");
|
||||
}
|
||||
|
||||
final newOperations = await _repository.fetchOperations(
|
||||
companyId: companyId,
|
||||
offset: currentOffset,
|
||||
limit: 50,
|
||||
searchTerm: state.query,
|
||||
dateRange: state.dateRange,
|
||||
);
|
||||
|
||||
final bool reachedMax = newOperations.length < 50;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.ready,
|
||||
allOperations: refresh
|
||||
? newOperations
|
||||
: [...state.allOperations, ...newOperations],
|
||||
hasReachedMax: reachedMax,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.failure,
|
||||
errorMessage: "Errore nel caricamento operazioni: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GESTIONE FILTRI ---
|
||||
|
||||
void updateFilters({String? query, DateTimeRange? range}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
query: query ?? state.query,
|
||||
dateRange: range ?? state.dateRange,
|
||||
),
|
||||
);
|
||||
loadOperations(refresh: true);
|
||||
}
|
||||
|
||||
void clearFilters() {
|
||||
emit(state.copyWith(query: '', dateRange: null));
|
||||
loadOperations(refresh: true);
|
||||
}
|
||||
|
||||
void initOperationForm({
|
||||
OperationModel? existingOperation,
|
||||
String? operationId,
|
||||
String? staffId,
|
||||
String? staffDisplayName,
|
||||
}) async {
|
||||
if (existingOperation != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentOperation: existingOperation,
|
||||
status: OperationsStatus.ready,
|
||||
),
|
||||
);
|
||||
} else if (operationId != null) {
|
||||
OperationModel? operationModel = state.allOperations.firstWhereOrNull(
|
||||
(s) => s.id == operationId,
|
||||
);
|
||||
operationModel ??= await _repository.fetchOperationById(operationId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentOperation: operationModel,
|
||||
status: OperationsStatus.ready,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// NUOVA PRATICA: Creiamo un nuovo Batch UUID
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentOperation: OperationModel(
|
||||
storeId: _sessionCubit.state.currentStore?.id ?? '',
|
||||
reference: '',
|
||||
createdAt: DateTime.now(),
|
||||
companyId: _sessionCubit.state.company!.id!,
|
||||
status: OperationStatus.draft,
|
||||
batchUuid: _uuid.v4(), // <-- GENERIAMO IL BATCH UNIVOCO
|
||||
),
|
||||
status: OperationsStatus.ready,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// MAGIA PURA: Prepara il form per inserire un altro servizio nella stessa pratica.
|
||||
/// Mantiene il Cliente, il Batch e lo Store, ma svuota il resto.
|
||||
void prepareNextOperationInBatch() {
|
||||
if (state.currentOperation == null) return;
|
||||
|
||||
final current = state.currentOperation!;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.ready,
|
||||
currentOperation: OperationModel(
|
||||
companyId: current.companyId,
|
||||
storeId: current.storeId,
|
||||
storeDisplayName: current.storeDisplayName,
|
||||
batchUuid: current.batchUuid, // <-- MANTIENE IL COLLEGAMENTO
|
||||
customerId: current.customerId, // <-- MANTIENE IL CLIENTE
|
||||
customerDisplayName: current.customerDisplayName,
|
||||
status: OperationStatus.draft,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- PERSISTENZA ---
|
||||
|
||||
Future<void> saveCurrentOperation({
|
||||
required OperationStatus targetStatus,
|
||||
bool shouldPop = true,
|
||||
}) async {
|
||||
if (state.currentOperation == null) return;
|
||||
|
||||
emit(state.copyWith(status: OperationsStatus.saving, errorMessage: null));
|
||||
try {
|
||||
final operationToSave = state.currentOperation!.copyWith(
|
||||
status: targetStatus,
|
||||
);
|
||||
|
||||
final updatedOperation = await _repository.saveFullOperation(
|
||||
operation: operationToSave,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
// Se non facciamo Pop (es. l'utente vuole aggiungere un altro servizio), non killiamo l'operazione corrente
|
||||
status: shouldPop
|
||||
? OperationsStatus.saved
|
||||
: OperationsStatus.savedNoPop,
|
||||
currentOperation: shouldPop ? null : updatedOperation,
|
||||
),
|
||||
);
|
||||
|
||||
// Ricarica in background per la dashboard
|
||||
loadOperations(refresh: true);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECUPERO OPERAZIONI DELLO STESSO BATCH (Per UI di riepilogo) ---
|
||||
|
||||
/// Puoi usare questa funzione se nella UI vuoi mostrare "Hai inserito 3 servizi in questa pratica"
|
||||
List<OperationModel> getOperationsInCurrentBatch() {
|
||||
if (state.currentOperation == null) return [];
|
||||
final currentBatch = state.currentOperation!.batchUuid;
|
||||
|
||||
// Filtriamo dalla lista caricata (o potresti fare una query diretta a Supabase se preferisci)
|
||||
return state.allOperations
|
||||
.where(
|
||||
(op) =>
|
||||
op.batchUuid == currentBatch &&
|
||||
op.id != state.currentOperation!.id,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- GESTIONE DELLO STATO DEL FORM IN TEMPO REALE ---
|
||||
void updateOperationFields({
|
||||
String? customerId,
|
||||
String? customerDisplayName,
|
||||
String? type,
|
||||
String? providerId,
|
||||
String? providerDisplayName,
|
||||
String? subtype,
|
||||
String? description,
|
||||
DateTime? expirationDate,
|
||||
int? quantity,
|
||||
String? modelId,
|
||||
String? modelDisplayName,
|
||||
String? staffId,
|
||||
String? staffDisplayName,
|
||||
// Aggiungiamo questi flag per forzare la pulizia dei campi quando cambi tipo
|
||||
bool clearProvider = false,
|
||||
bool clearType = false,
|
||||
bool clearSubtype = false,
|
||||
bool clearDescription = false,
|
||||
bool clearExpiration = false,
|
||||
bool clearQuantity = false,
|
||||
bool clearModel = false,
|
||||
}) {
|
||||
if (state.currentOperation == null) return;
|
||||
|
||||
final current = state.currentOperation!;
|
||||
|
||||
// Creiamo il modello aggiornato
|
||||
// ATTENZIONE: adatta questa logica in base a come è scritto il tuo copyWith!
|
||||
int? newQuantity;
|
||||
if (clearQuantity) {
|
||||
newQuantity = 1;
|
||||
}
|
||||
if (quantity != null && quantity <= 0) {
|
||||
newQuantity = 0;
|
||||
}
|
||||
if (quantity != null && quantity > 0) {
|
||||
newQuantity = quantity;
|
||||
}
|
||||
final updated = current.copyWith(
|
||||
customerId: customerId,
|
||||
customerDisplayName: customerDisplayName,
|
||||
|
||||
// Se clearProvider è true, forziamo una stringa vuota (o null se il tuo modello lo supporta)
|
||||
providerId: clearProvider ? null : (providerId ?? current.providerId),
|
||||
providerDisplayName: clearProvider
|
||||
? null
|
||||
: (providerDisplayName ?? current.providerDisplayName),
|
||||
quantity: newQuantity,
|
||||
type: clearType ? null : (type ?? current.type),
|
||||
description: clearDescription
|
||||
? null
|
||||
: (description ?? current.description),
|
||||
subtype: clearSubtype ? null : (subtype ?? current.subtype),
|
||||
expirationDate: clearExpiration
|
||||
? null
|
||||
: (expirationDate ?? current.expirationDate),
|
||||
modelId: clearModel ? null : (modelId ?? current.modelId),
|
||||
modelDisplayName: clearModel
|
||||
? null
|
||||
: (modelDisplayName ?? current.modelDisplayName),
|
||||
staffId: staffId ?? current.staffId,
|
||||
staffDisplayName: staffDisplayName ?? current.staffDisplayName,
|
||||
);
|
||||
|
||||
emit(state.copyWith(currentOperation: updated));
|
||||
}
|
||||
|
||||
// Metodo di utilità per calcolare la data X mesi da oggi
|
||||
DateTime _calculateMonths(int months) {
|
||||
final now = DateTime.now();
|
||||
return DateTime(now.year, now.month + months, now.day);
|
||||
}
|
||||
|
||||
// Quando l'utente seleziona un tipo, impostiamo il default
|
||||
void setTypeWithSmartDefault(String type) {
|
||||
DateTime? defaultDate;
|
||||
|
||||
if (type == 'Energy') defaultDate = _calculateMonths(24);
|
||||
if (type == 'Fin') defaultDate = _calculateMonths(30);
|
||||
if (type == 'Entertainment') defaultDate = _calculateMonths(12);
|
||||
|
||||
updateOperationFields(
|
||||
type: type,
|
||||
expirationDate: defaultDate,
|
||||
clearProvider: true,
|
||||
clearSubtype: true,
|
||||
clearModel: true,
|
||||
clearQuantity: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
part of 'services_cubit.dart';
|
||||
part of 'operations_cubit.dart';
|
||||
|
||||
enum ServicesStatus {
|
||||
enum OperationsStatus {
|
||||
initial,
|
||||
loading,
|
||||
ready,
|
||||
@@ -11,20 +11,20 @@ enum ServicesStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
class ServicesState extends Equatable {
|
||||
final ServicesStatus status;
|
||||
final List<ServiceModel> allServices;
|
||||
final ServiceModel? currentService; // La bozza che stiamo editando
|
||||
class OperationsState extends Equatable {
|
||||
final OperationsStatus status;
|
||||
final List<OperationModel> allOperations;
|
||||
final OperationModel? currentOperation; // La bozza che stiamo editando
|
||||
final String? errorMessage;
|
||||
final String query;
|
||||
final DateTimeRange? dateRange;
|
||||
final bool hasReachedMax;
|
||||
final bool isSavingDraft;
|
||||
|
||||
const ServicesState({
|
||||
const OperationsState({
|
||||
required this.status,
|
||||
this.allServices = const [],
|
||||
this.currentService,
|
||||
this.allOperations = const [],
|
||||
this.currentOperation,
|
||||
this.errorMessage,
|
||||
this.query = '',
|
||||
this.dateRange,
|
||||
@@ -32,20 +32,20 @@ class ServicesState extends Equatable {
|
||||
this.isSavingDraft = false,
|
||||
});
|
||||
|
||||
ServicesState copyWith({
|
||||
ServicesStatus? status,
|
||||
List<ServiceModel>? allServices,
|
||||
ServiceModel? currentService,
|
||||
OperationsState copyWith({
|
||||
OperationsStatus? status,
|
||||
List<OperationModel>? allOperations,
|
||||
OperationModel? currentOperation,
|
||||
String? errorMessage,
|
||||
String? query,
|
||||
DateTimeRange? dateRange,
|
||||
bool? hasReachedMax,
|
||||
bool? isSavingDraft,
|
||||
}) {
|
||||
return ServicesState(
|
||||
return OperationsState(
|
||||
status: status ?? this.status,
|
||||
allServices: allServices ?? this.allServices,
|
||||
currentService: currentService ?? this.currentService,
|
||||
allOperations: allOperations ?? this.allOperations,
|
||||
currentOperation: currentOperation ?? this.currentOperation,
|
||||
errorMessage: errorMessage,
|
||||
query: query ?? this.query,
|
||||
dateRange: dateRange ?? this.dateRange,
|
||||
@@ -57,8 +57,8 @@ class ServicesState extends Equatable {
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
allServices,
|
||||
currentService,
|
||||
allOperations,
|
||||
currentOperation,
|
||||
errorMessage,
|
||||
query,
|
||||
dateRange,
|
||||
305
lib/features/operations/data/operations_repository.dart
Normal file
305
lib/features/operations/data/operations_repository.dart
Normal file
@@ -0,0 +1,305 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/operation_model.dart';
|
||||
|
||||
class OperationsRepository {
|
||||
final _supabase = Supabase.instance.client;
|
||||
final companyId = GetIt.I.get<SessionCubit>().state.company!.id;
|
||||
|
||||
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
|
||||
Future<OperationModel> fetchOperationById(String id) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('operation')
|
||||
.select('''
|
||||
*,
|
||||
customer(name),
|
||||
store(name),
|
||||
staff_member(name),
|
||||
provider(name),
|
||||
model(name_with_brand),
|
||||
attachment(*)
|
||||
''')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
return OperationModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw Exception('Errore nel caricamento del servizio: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
|
||||
Future<List<OperationModel>> fetchOperations({
|
||||
required String companyId,
|
||||
required int offset,
|
||||
int limit = 50,
|
||||
String? searchTerm,
|
||||
DateTimeRange? dateRange,
|
||||
}) async {
|
||||
try {
|
||||
var query = _supabase
|
||||
.from('operation')
|
||||
.select('''
|
||||
*,
|
||||
customer(name),
|
||||
store(name),
|
||||
provider(name),
|
||||
model(name_with_brand),
|
||||
staff_member(name),
|
||||
attachment(*)
|
||||
''')
|
||||
.eq('company_id', companyId);
|
||||
|
||||
// Filtro Range Date
|
||||
if (dateRange != null) {
|
||||
query = query
|
||||
.gte('created_at', dateRange.start.toIso8601String())
|
||||
.lte('created_at', dateRange.end.toIso8601String());
|
||||
}
|
||||
|
||||
if (searchTerm != null && searchTerm.isNotEmpty) {
|
||||
// Filtra sui campi della tabella principale O su quelli della tabella joinata
|
||||
query = query.or(
|
||||
'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%',
|
||||
);
|
||||
}
|
||||
|
||||
final response = await query
|
||||
.order('created_at', ascending: false)
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
return (response as List)
|
||||
.map((map) => OperationModel.fromMap(map))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<OperationModel>> getLastStoreOperationsStream({
|
||||
required String storeId,
|
||||
required int limit,
|
||||
}) {
|
||||
return _supabase
|
||||
.from('operation')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('store_id', storeId)
|
||||
.order('created_at', ascending: false)
|
||||
.limit(limit)
|
||||
.map(
|
||||
(listOfMaps) =>
|
||||
listOfMaps.map((map) => OperationModel.fromMap(map)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
||||
Future<OperationModel> saveFullOperation({
|
||||
required OperationModel operation,
|
||||
}) async {
|
||||
try {
|
||||
// 1. Salvataggio classico dell'operazione corrente
|
||||
final response = await _supabase
|
||||
.from('operation')
|
||||
.upsert(operation.toMap())
|
||||
.select(
|
||||
'*, provider(*), model(*), store(*), staff_member(*), customer(*), attachment(*)',
|
||||
)
|
||||
.single();
|
||||
|
||||
final savedOperation = OperationModel.fromMap(response);
|
||||
|
||||
// 2. ALLINEAMENTO BATCH SEMPRE ATTIVO!
|
||||
if (operation.batchUuid.isNotEmpty) {
|
||||
await _supabase
|
||||
.from('operation')
|
||||
.update({'note': operation.note}) // Spalmiamo la nota attuale
|
||||
.eq(
|
||||
'batch_uuid',
|
||||
operation.batchUuid,
|
||||
); // Su tutte le pratiche di questo scontrino
|
||||
}
|
||||
|
||||
return savedOperation;
|
||||
} catch (e) {
|
||||
throw Exception("Errore nel salvataggio dell'operazione: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// --- ELIMINAZIONE ---
|
||||
Future<void> deleteOperation(String id) async {
|
||||
try {
|
||||
await _supabase.from('operation').delete().eq('id', id);
|
||||
} catch (e) {
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
|
||||
Future<List<String>> fetchTopEntertainmentTypes(String companyId) async {
|
||||
try {
|
||||
// Cerchiamo i tipi più frequenti associati ai servizi di questa company
|
||||
// Nota: dobbiamo passare attraverso la tabella 'operation' per filtrare per company_id
|
||||
final response = await _supabase
|
||||
.from('operation')
|
||||
.select('description')
|
||||
.eq('company_id', companyId)
|
||||
.eq('type', 'Entertainment')
|
||||
.limit(50); // Prendiamo un campione
|
||||
|
||||
// Logica rapida per contare le occorrenze e prendere i primi 5
|
||||
final Map<String, int> counts = {};
|
||||
for (var item in (response as List)) {
|
||||
final description = item['description'] as String;
|
||||
counts[description] = (counts[description] ?? 0) + 1;
|
||||
}
|
||||
|
||||
var sortedKeys = counts.keys.toList()
|
||||
..sort((a, b) => counts[b]!.compareTo(counts[a]!));
|
||||
|
||||
return sortedKeys.take(5).toList();
|
||||
} catch (e) {
|
||||
return [
|
||||
"Netflix",
|
||||
"DAZN",
|
||||
"Disney+",
|
||||
"Sky",
|
||||
]; // Fallback se non c'è ancora storia
|
||||
}
|
||||
}
|
||||
|
||||
/// Ascolta in tempo reale i file caricati per una pratica
|
||||
Stream<List<AttachmentModel>> getOperationFilesStream(String operationId) {
|
||||
return _supabase
|
||||
.from('attachment')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('operation_id', operationId)
|
||||
.order('created_at', ascending: false)
|
||||
.map(
|
||||
(listOfMaps) =>
|
||||
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AttachmentModel> uploadAndRegisterOperationFile({
|
||||
required String operationId,
|
||||
required PlatformFile pickedFile,
|
||||
}) async {
|
||||
final cleanFileName = pickedFile.name.replaceAll(
|
||||
RegExp(r'[^a-zA-Z0-9\.\-]'),
|
||||
'_',
|
||||
);
|
||||
final storagePath =
|
||||
'$companyId/operations/$operationId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
|
||||
final int fileSize = pickedFile.size;
|
||||
final fileToSave = AttachmentModel(
|
||||
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
|
||||
operationId: operationId,
|
||||
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('attachment')
|
||||
.insert(fileToSave.toMap())
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return AttachmentModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante l\'upload: $e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> copyFileToCustomer({
|
||||
required AttachmentModel file,
|
||||
required String customerId,
|
||||
}) async {
|
||||
await _supabase
|
||||
.from('attachment')
|
||||
.update({'customer_id': customerId})
|
||||
.eq('id', file.id!);
|
||||
}
|
||||
|
||||
Future<void> renameAttachment(String id, String newName) async {
|
||||
try {
|
||||
await _supabase.from('attachment').update({'name': newName}).eq('id', id);
|
||||
} catch (e) {
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSpecificOperationFile(AttachmentModel file) async {
|
||||
try {
|
||||
if (file.customerId == null) {
|
||||
await _supabase.from('attachment').delete().eq('id', file.id!);
|
||||
await _supabase.storage.from('documents').remove([file.storagePath!]);
|
||||
} else {
|
||||
await _supabase
|
||||
.from('attachment')
|
||||
.update({'operation_id': null})
|
||||
.eq('id', file.id!);
|
||||
}
|
||||
} catch (e) {
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteOperationFiles(List<AttachmentModel> files) async {
|
||||
if (files.isEmpty) return;
|
||||
// 1. Prepariamo le liste di ID e di Percorsi
|
||||
final List<String> idsToDelete = [];
|
||||
final List<String> idsToEdit = [];
|
||||
final List<String> storagePathsToDelete = [];
|
||||
for (var file in files) {
|
||||
if (file.customerId == null) {
|
||||
idsToDelete.add(file.id!);
|
||||
storagePathsToDelete.add(file.storagePath!);
|
||||
} else {
|
||||
idsToEdit.add(file.id!);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (idsToDelete.isNotEmpty) {
|
||||
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
|
||||
await _supabase.storage.from('documents').remove(storagePathsToDelete);
|
||||
}
|
||||
if (idsToEdit.isNotEmpty) {
|
||||
await _supabase
|
||||
.from('attachment')
|
||||
.update({'operation_id': null})
|
||||
.inFilter('id', idsToEdit);
|
||||
}
|
||||
} on PostgrestException catch (e) {
|
||||
throw 'Errore database: ${e.message}';
|
||||
} catch (e) {
|
||||
throw 'Errore durante l\'eliminazione dei file: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
248
lib/features/operations/models/operation_model.dart
Normal file
248
lib/features/operations/models/operation_model.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
|
||||
enum OperationStatus {
|
||||
ok('ok'),
|
||||
waitingforaction('waiting_for_action'),
|
||||
waitingforsupport('waiting_for_support'),
|
||||
waitingfordeployment('waiting_for_deployment'),
|
||||
ko('ko'),
|
||||
draft('draft'),
|
||||
canceled('canceled');
|
||||
|
||||
static OperationStatus fromString(String value) {
|
||||
final normalizedValue = value.replaceAll('_', '').toLowerCase();
|
||||
return OperationStatus.values.firstWhere(
|
||||
(e) => e.name.toLowerCase() == normalizedValue,
|
||||
);
|
||||
}
|
||||
|
||||
final String supabaseName;
|
||||
|
||||
const OperationStatus(this.supabaseName);
|
||||
}
|
||||
|
||||
class OperationModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final String type;
|
||||
final String? subtype;
|
||||
final String? providerId;
|
||||
final String? providerDisplayName;
|
||||
final String? modelId;
|
||||
final String? modelDisplayName;
|
||||
final String? description;
|
||||
final DateTime? expirationDate;
|
||||
final String note;
|
||||
final bool showInDashboard;
|
||||
final String batchUuid;
|
||||
final String companyId;
|
||||
final String storeId;
|
||||
final String? storeDisplayName;
|
||||
final int quantity;
|
||||
final String? staffId;
|
||||
final String? staffDisplayName;
|
||||
final String? lastCampaignId;
|
||||
final OperationStatus status;
|
||||
final String? customerId;
|
||||
final String? customerDisplayName;
|
||||
final String reference;
|
||||
|
||||
// ALLEGATI (Aggiunto)
|
||||
final List<AttachmentModel> attachments;
|
||||
|
||||
const OperationModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
this.type = '',
|
||||
this.subtype,
|
||||
this.providerId,
|
||||
this.providerDisplayName,
|
||||
this.modelId,
|
||||
this.modelDisplayName,
|
||||
this.description,
|
||||
this.expirationDate,
|
||||
this.note = '',
|
||||
this.showInDashboard = true,
|
||||
this.batchUuid = '',
|
||||
required this.companyId,
|
||||
this.storeId = '',
|
||||
this.storeDisplayName,
|
||||
this.quantity = 1,
|
||||
this.staffId,
|
||||
this.staffDisplayName,
|
||||
this.lastCampaignId,
|
||||
this.status = OperationStatus.draft,
|
||||
this.customerId,
|
||||
this.customerDisplayName,
|
||||
this.reference = '',
|
||||
this.attachments = const [],
|
||||
});
|
||||
|
||||
OperationModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? type,
|
||||
String? subtype,
|
||||
String? providerId,
|
||||
String? providerDisplayName,
|
||||
String? modelId,
|
||||
String? modelDisplayName,
|
||||
String? description,
|
||||
DateTime? expirationDate,
|
||||
String? note,
|
||||
bool? showInDashboard,
|
||||
String? batchUuid,
|
||||
String? companyId,
|
||||
String? storeId,
|
||||
String? storeDisplayName,
|
||||
int? quantity,
|
||||
String? staffId,
|
||||
String? staffDisplayName,
|
||||
String? lastCampaignId,
|
||||
OperationStatus? status,
|
||||
String? customerId,
|
||||
String? customerDisplayName,
|
||||
String? reference,
|
||||
List<AttachmentModel>? attachments,
|
||||
}) => OperationModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
subtype: subtype ?? this.subtype,
|
||||
providerId: providerId ?? this.providerId,
|
||||
providerDisplayName: providerDisplayName ?? this.providerDisplayName,
|
||||
modelId: modelId ?? this.modelId,
|
||||
modelDisplayName: modelDisplayName ?? this.modelDisplayName,
|
||||
description: description ?? this.description,
|
||||
expirationDate: expirationDate ?? this.expirationDate,
|
||||
note: note ?? this.note,
|
||||
showInDashboard: showInDashboard ?? this.showInDashboard,
|
||||
batchUuid: batchUuid ?? this.batchUuid,
|
||||
companyId: companyId ?? this.companyId,
|
||||
storeId: storeId ?? this.storeId,
|
||||
storeDisplayName: storeDisplayName ?? this.storeDisplayName,
|
||||
quantity: quantity ?? this.quantity,
|
||||
staffId: staffId ?? this.staffId,
|
||||
staffDisplayName: staffDisplayName ?? this.staffDisplayName,
|
||||
lastCampaignId: lastCampaignId ?? this.lastCampaignId,
|
||||
status: status ?? this.status,
|
||||
customerId: customerId ?? this.customerId,
|
||||
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
|
||||
reference: reference ?? this.reference,
|
||||
attachments: attachments ?? this.attachments,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
type,
|
||||
subtype,
|
||||
providerId,
|
||||
providerDisplayName,
|
||||
modelId,
|
||||
modelDisplayName,
|
||||
description,
|
||||
expirationDate,
|
||||
note,
|
||||
showInDashboard,
|
||||
batchUuid,
|
||||
companyId,
|
||||
storeId,
|
||||
storeDisplayName,
|
||||
quantity,
|
||||
staffId,
|
||||
staffDisplayName,
|
||||
lastCampaignId,
|
||||
status,
|
||||
customerId,
|
||||
customerDisplayName,
|
||||
reference,
|
||||
attachments,
|
||||
];
|
||||
|
||||
factory OperationModel.empty({required String companyId}) {
|
||||
return OperationModel(id: null, createdAt: null, companyId: companyId);
|
||||
}
|
||||
|
||||
factory OperationModel.fromMap(Map<String, dynamic> map) {
|
||||
return OperationModel(
|
||||
id: map['id'] as String?,
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
type: map['type'] as String? ?? '',
|
||||
subtype: map['sub_type'] as String?,
|
||||
|
||||
// I campi relazionali nullabili restano rigorosamente null!
|
||||
providerId: map['provider_id'] as String?,
|
||||
// MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
|
||||
providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(),
|
||||
|
||||
modelId: map['model_id'] as String?,
|
||||
modelDisplayName: (map['model']?['name_with_brand'] as String?)
|
||||
?.myFormat(),
|
||||
|
||||
description: map['description'] as String?,
|
||||
expirationDate: map['expiration_date'] != null
|
||||
? DateTime.parse(map['expiration_date'])
|
||||
: null,
|
||||
note: map['note'] as String? ?? '',
|
||||
showInDashboard: map['show_in_dashboard'] as bool? ?? true,
|
||||
batchUuid: map['batch_uuid'] as String? ?? '',
|
||||
companyId: map['company_id'] as String,
|
||||
|
||||
storeId:
|
||||
map['store_id'] as String? ??
|
||||
'', // Questo è non-nullable nella tua classe
|
||||
storeDisplayName: (map['store']?['name'] as String?)?.myFormat(),
|
||||
|
||||
quantity: map['quantity'] is int
|
||||
? map['quantity']
|
||||
: int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
|
||||
|
||||
staffId: map['staff_id'] as String?,
|
||||
staffDisplayName: (map['staff_member']?['name'] as String?)?.myFormat(),
|
||||
|
||||
lastCampaignId: map['last_campaign_id'] as String?,
|
||||
status: OperationStatus.fromString(map['status'] ?? 'draft'),
|
||||
|
||||
customerId: map['customer_id'] as String?,
|
||||
customerDisplayName: (map['customer']?['name'] as String?)?.myFormat(),
|
||||
|
||||
attachments:
|
||||
(map['attachment'] as List?)
|
||||
?.map((x) => AttachmentModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
|
||||
reference: map['reference'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'type': type,
|
||||
'sub_type': subtype,
|
||||
'provider_id': providerId,
|
||||
'model_id': modelId,
|
||||
'description': description,
|
||||
if (expirationDate != null)
|
||||
'expiration_date': expirationDate!.toIso8601String(),
|
||||
'note': note,
|
||||
'show_in_dashboard': showInDashboard,
|
||||
'batch_uuid': batchUuid,
|
||||
'company_id': companyId,
|
||||
'store_id': storeId,
|
||||
'quantity': quantity,
|
||||
if (staffId != null) 'staff_id': staffId,
|
||||
if (lastCampaignId != null) 'last_campaign_id': lastCampaignId,
|
||||
'status': status.supabaseName,
|
||||
if (customerId != null) 'customer_id': customerId,
|
||||
'reference': reference,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ServiceActionCard extends StatelessWidget {
|
||||
class OperationActionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final Color color;
|
||||
final int count;
|
||||
const ServiceActionCard({
|
||||
const OperationActionCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
481
lib/features/operations/ui/operation_form_screen.dart
Normal file
481
lib/features/operations/ui/operation_form_screen.dart
Normal file
@@ -0,0 +1,481 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:flux/features/operations/ui/widgets/customer_section.dart';
|
||||
import 'package:flux/features/operations/ui/widgets/details_section.dart';
|
||||
import 'package:flux/features/operations/ui/widgets/operation_files_section.dart';
|
||||
import 'package:flux/features/operations/ui/widgets/staff_section.dart';
|
||||
import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH
|
||||
// import 'package:flux/features/attachments/ui/operation_files_section.dart';
|
||||
|
||||
class OperationFormScreen extends StatefulWidget {
|
||||
final String? operationId;
|
||||
final OperationModel? existingOperation;
|
||||
|
||||
const OperationFormScreen({
|
||||
super.key,
|
||||
this.operationId,
|
||||
this.existingOperation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OperationFormScreen> createState() => _OperationFormScreenState();
|
||||
}
|
||||
|
||||
class _OperationFormScreenState extends State<OperationFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
final _referenceController = TextEditingController();
|
||||
final _noteController = TextEditingController();
|
||||
final _freeTextSubtypeController = TextEditingController();
|
||||
final _freeTextDescriptionController = TextEditingController();
|
||||
|
||||
final List<String> _availableTypes = [
|
||||
'AL',
|
||||
'MNP',
|
||||
'NIP',
|
||||
'UNICA',
|
||||
'TELEPASS',
|
||||
'Energy',
|
||||
'Fin',
|
||||
'Entertainment',
|
||||
'Custom',
|
||||
];
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final cubit = context.read<OperationsCubit>();
|
||||
final currentLoggedStaff = GetIt.I
|
||||
.get<SessionCubit>()
|
||||
.state
|
||||
.currentStaffMember!;
|
||||
|
||||
// 1. Diciamo al Cubit di prepararsi
|
||||
cubit.initOperationForm(
|
||||
existingOperation: widget.existingOperation,
|
||||
operationId: widget.operationId,
|
||||
staffId: currentLoggedStaff.id,
|
||||
staffDisplayName: currentLoggedStaff.name,
|
||||
);
|
||||
|
||||
// 2. IL TRUCCO MAGICO:
|
||||
// Se abbiamo passato existingOperation, il Cubit si è appena aggiornato.
|
||||
// Lo stato è già pronto, quindi sincronizziamo i controller SUBITO!
|
||||
if (cubit.state.currentOperation != null) {
|
||||
_syncTextControllers(cubit.state.currentOperation!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_referenceController.dispose();
|
||||
_noteController.dispose();
|
||||
_freeTextSubtypeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncTextControllers(OperationModel model) {
|
||||
if (_referenceController.text.isEmpty && model.reference.isNotEmpty) {
|
||||
_referenceController.text = model.reference;
|
||||
}
|
||||
if (_noteController.text.isEmpty && model.note.isNotEmpty) {
|
||||
_noteController.text = model.note;
|
||||
}
|
||||
if (_freeTextSubtypeController.text.isEmpty &&
|
||||
model.subtype != null &&
|
||||
model.subtype!.isNotEmpty) {
|
||||
_freeTextSubtypeController.text = model.subtype!;
|
||||
}
|
||||
if (_freeTextDescriptionController.text.isEmpty &&
|
||||
model.description != null &&
|
||||
model.description!.isNotEmpty) {
|
||||
_freeTextDescriptionController.text = model.description!;
|
||||
}
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
void _saveOperation({required bool keepAdding}) {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final cubit = context.read<OperationsCubit>();
|
||||
final currentOperation = cubit.state.currentOperation!;
|
||||
|
||||
final operationToSave = currentOperation.copyWith(
|
||||
reference: _referenceController.text,
|
||||
note: _noteController.text,
|
||||
subtype: ['Entertainment', 'Custom'].contains(currentOperation.type)
|
||||
? _freeTextSubtypeController.text
|
||||
: currentOperation.subtype,
|
||||
description: ['Energy', 'Custom'].contains(currentOperation.type)
|
||||
? _freeTextDescriptionController.text
|
||||
: currentOperation.description,
|
||||
);
|
||||
|
||||
cubit.initOperationForm(existingOperation: operationToSave);
|
||||
cubit.saveCurrentOperation(
|
||||
targetStatus: OperationStatus.ok,
|
||||
shouldPop: !keepAdding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return BlocConsumer<OperationsCubit, OperationsState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.status != current.status ||
|
||||
previous.currentOperation?.id != current.currentOperation?.id,
|
||||
listener: (context, state) {
|
||||
if (state.status == OperationsStatus.ready &&
|
||||
state.currentOperation != null &&
|
||||
!_isInitialized) {
|
||||
_syncTextControllers(state.currentOperation!);
|
||||
}
|
||||
|
||||
if (state.status == OperationsStatus.saved) {
|
||||
Navigator.of(context).pop();
|
||||
} else if (state.status == OperationsStatus.savedNoPop) {
|
||||
context.read<OperationsCubit>().prepareNextOperationInBatch();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Servizio aggiunto! Inserisci il prossimo.'),
|
||||
),
|
||||
);
|
||||
_freeTextSubtypeController.clear();
|
||||
_freeTextDescriptionController.clear();
|
||||
} else if (state.status == OperationsStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage ?? 'Errore'),
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (!_isInitialized &&
|
||||
(widget.operationId != null || widget.existingOperation != null) &&
|
||||
state.status == OperationsStatus.loading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
state.currentOperation?.id == null
|
||||
? 'Nuova Pratica'
|
||||
: 'Modifica Pratica',
|
||||
),
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isUltraWide = constraints.maxWidth > 1400;
|
||||
final isDesktop = constraints.maxWidth > 900;
|
||||
if (isUltraWide) {
|
||||
// --- LAYOUT 3 COLONNE (Schermi giganti) ---
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 1. FORM PRINCIPALE (40%)
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
// Attenzione: devi togliere la sezione file dal _buildMainFormContent!
|
||||
child: _buildMainFormContent(
|
||||
theme,
|
||||
state,
|
||||
showFiles: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||
|
||||
// 2. NOTE (30%)
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildNotesSection(isDesktop: true),
|
||||
),
|
||||
),
|
||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||
|
||||
// 3. FILE (30%)
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: OperationFilesSection(
|
||||
currentOp: state.currentOperation!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (isDesktop) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildMainFormContent(theme, state),
|
||||
),
|
||||
),
|
||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildNotesSection(isDesktop: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMainFormContent(theme, state),
|
||||
const Divider(height: 32),
|
||||
_buildNotesSection(isDesktop: false),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: OutlinedButton(
|
||||
onPressed: state.status == OperationsStatus.saving
|
||||
? null
|
||||
: () => _saveOperation(keepAdding: true),
|
||||
child: const Text(
|
||||
'Salva e Aggiungi Altro',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: ElevatedButton(
|
||||
onPressed: state.status == OperationsStatus.saving
|
||||
? null
|
||||
: () => _saveOperation(keepAdding: false),
|
||||
child: state.status == OperationsStatus.saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('Salva ed Esci'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainFormContent(
|
||||
ThemeData theme,
|
||||
OperationsState state, {
|
||||
bool showFiles = true,
|
||||
}) {
|
||||
final currentOp = state.currentOperation;
|
||||
final currentType = currentOp?.type ?? 'AL';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StaffSection(currentOp: currentOp),
|
||||
const Divider(height: 50),
|
||||
_buildSectionTitle('Cliente & Riferimento'),
|
||||
CustomerSection(currentOp: currentOp),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _referenceController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Riferimento (es. numero di telefono, targa...)',
|
||||
prefixIcon: Icon(Icons.tag),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
_buildSectionTitle('Cosa stiamo facendo?'),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: _availableTypes.map((type) {
|
||||
return ChoiceChip(
|
||||
label: Text(type),
|
||||
selected: currentType == type,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
context.read<OperationsCubit>().setTypeWithSmartDefault(type);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
_buildSectionTitle('Dettagli Servizio'),
|
||||
DetailsSection(
|
||||
currentOp: currentOp,
|
||||
currentType: currentType,
|
||||
freeTextSubtypeController: _freeTextSubtypeController,
|
||||
freeTextDescriptionController: _freeTextDescriptionController,
|
||||
durationQuickPicks: _buildDurationQuickPicks(currentOp),
|
||||
),
|
||||
|
||||
// QUANTITÀ
|
||||
Row(
|
||||
children: [
|
||||
const Text('Quantità: '),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: () {
|
||||
final q = currentOp?.quantity ?? 1;
|
||||
if (q > 1) {
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
quantity: q - 1,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
Text(
|
||||
'${currentOp?.quantity ?? 1}',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
final q = currentOp?.quantity ?? 1;
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
quantity: q + 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDurationQuickPicks(OperationModel? currentOp) {
|
||||
final durations = [3, 6, 12, 24, 30, 36, 48];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Imposta durata rapida (mesi):",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: durations.map((months) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: ActionChip(
|
||||
label: Text("$months m"),
|
||||
backgroundColor: Colors.blue.withValues(alpha: 0.05),
|
||||
onPressed: () {
|
||||
final now = DateTime.now();
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
expirationDate: DateTime(
|
||||
now.year,
|
||||
now.month + months,
|
||||
now.day,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotesSection({required bool isDesktop}) {
|
||||
final title = _buildSectionTitle('Note Interne');
|
||||
final noteField = TextFormField(
|
||||
controller: _noteController,
|
||||
keyboardType: TextInputType.multiline,
|
||||
minLines: isDesktop ? null : 5,
|
||||
maxLines: null,
|
||||
expands: isDesktop,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Incolla qui seriali, ICCID, IBAN, indirizzi...',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
return isDesktop
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
title,
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: noteField),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [title, const SizedBox(height: 8), noteField],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_files_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;
|
||||
class OperationMobileUploadScreen extends StatefulWidget {
|
||||
final String operationId;
|
||||
final String operationName;
|
||||
|
||||
const ServiceMobileUploadScreen({
|
||||
const OperationMobileUploadScreen({
|
||||
super.key,
|
||||
required this.serviceId,
|
||||
required this.serviceName,
|
||||
required this.operationId,
|
||||
required this.operationName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ServiceMobileUploadScreen> createState() =>
|
||||
_ServiceMobileUploadScreenState();
|
||||
State<OperationMobileUploadScreen> createState() =>
|
||||
_OperationMobileUploadScreenState();
|
||||
}
|
||||
|
||||
class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
|
||||
class _OperationMobileUploadScreenState
|
||||
extends State<OperationMobileUploadScreen> {
|
||||
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
|
||||
final List<PlatformFile> _stagedFiles = [];
|
||||
|
||||
@@ -35,10 +36,10 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ServiceFilesBloc, ServiceFilesState>(
|
||||
return BlocListener<OperationFilesBloc, OperationFilesState>(
|
||||
listener: (context, state) {
|
||||
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
|
||||
if (state.status == ServiceFilesStatus.success && _isUploading) {
|
||||
if (state.status == OperationFilesStatus.success && _isUploading) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Tutti i file caricati con successo! ✅"),
|
||||
@@ -46,7 +47,7 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
if (state.status == ServiceFilesStatus.failure) {
|
||||
if (state.status == OperationFilesStatus.failure) {
|
||||
setState(() => _isUploading = false);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -55,7 +56,7 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Upload Pratica:\n${widget.serviceName}"),
|
||||
title: Text("Upload Pratica:\n${widget.operationName}"),
|
||||
automaticallyImplyLeading: !_isUploading,
|
||||
),
|
||||
body: Stack(
|
||||
@@ -294,8 +295,8 @@ class _ServiceMobileUploadScreenState extends State<ServiceMobileUploadScreen> {
|
||||
|
||||
// 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));
|
||||
final bloc = context.read<OperationFilesBloc>();
|
||||
bloc.add(UploadOperationFilesEvent(pickedFiles: _stagedFiles));
|
||||
|
||||
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/services/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/services/models/service_model.dart';
|
||||
import 'package:flux/features/services/utils/service_actions.dart';
|
||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
// Importa i tuoi modelli e cubit
|
||||
|
||||
class ServicesScreen extends StatefulWidget {
|
||||
const ServicesScreen({super.key});
|
||||
class OperationsScreen extends StatefulWidget {
|
||||
const OperationsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ServicesScreen> createState() => _ServicesScreenState();
|
||||
State<OperationsScreen> createState() => _OperationsScreenState();
|
||||
}
|
||||
|
||||
class _ServicesScreenState extends State<ServicesScreen> {
|
||||
class _OperationsScreenState extends State<OperationsScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
@@ -22,12 +21,12 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
// Agganciamo il listener per la paginazione (Scroll Infinito)
|
||||
_scrollController.addListener(_onScroll);
|
||||
// Carichiamo i servizi iniziali
|
||||
context.read<ServicesCubit>().loadServices();
|
||||
context.read<OperationsCubit>().loadOperations();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_isBottom) {
|
||||
context.read<ServicesCubit>().loadServices();
|
||||
context.read<OperationsCubit>().loadOperations();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,16 +59,16 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocBuilder<ServicesCubit, ServicesState>(
|
||||
body: BlocBuilder<OperationsCubit, OperationsState>(
|
||||
builder: (context, state) {
|
||||
// 1. Stato di caricamento iniziale
|
||||
if (state.status == ServicesStatus.loading &&
|
||||
state.allServices.isEmpty) {
|
||||
if (state.status == OperationsStatus.loading &&
|
||||
state.allOperations.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// 2. Lista vuota
|
||||
if (state.allServices.isEmpty) {
|
||||
if (state.allOperations.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -77,9 +76,9 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
const Text("Nessuna pratica trovata."),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.read<ServicesCubit>().loadServices(
|
||||
refresh: true,
|
||||
),
|
||||
onPressed: () => context
|
||||
.read<OperationsCubit>()
|
||||
.loadOperations(refresh: true),
|
||||
child: const Text("Riprova"),
|
||||
),
|
||||
],
|
||||
@@ -90,15 +89,15 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
// 3. La Lista (con Pull-to-refresh)
|
||||
return RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
context.read<ServicesCubit>().loadServices(refresh: true),
|
||||
context.read<OperationsCubit>().loadOperations(refresh: true),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB
|
||||
itemCount: state.hasReachedMax
|
||||
? state.allServices.length
|
||||
: state.allServices.length + 1,
|
||||
? state.allOperations.length
|
||||
: state.allOperations.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= state.allServices.length) {
|
||||
if (index >= state.allOperations.length) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
@@ -107,21 +106,21 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final service = state.allServices[index];
|
||||
return _buildServiceCard(context, service);
|
||||
final operation = state.allOperations[index];
|
||||
return _buildOperationCard(context, operation);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => startNewService(context),
|
||||
onPressed: () => startNewOperation(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildServiceCard(BuildContext context, ServiceModel service) {
|
||||
Widget _buildOperationCard(BuildContext context, OperationModel operation) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
elevation: 2,
|
||||
@@ -132,22 +131,13 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
service.customerDisplayName ?? "Cliente sconosciuto",
|
||||
operation.customerDisplayName ?? "Cliente sconosciuto",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (service.isBozza)
|
||||
const Chip(
|
||||
label: Text(
|
||||
"BOZZA",
|
||||
style: TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
@@ -155,52 +145,56 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"Pratica: ${service.number} • ${service.createdAt?.day}/${service.createdAt?.month}/${service.createdAt?.year}",
|
||||
"Pratica: ${operation.reference} • ${operation.createdAt?.day}/${operation.createdAt?.month}/${operation.createdAt?.year}",
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// I nostri mini-chip per i servizi attivati
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
Row(
|
||||
children: [
|
||||
if (service.al > 0 || service.mnp > 0)
|
||||
_miniBadge("📞 Tel", Colors.blue),
|
||||
if (service.energyServices.isNotEmpty)
|
||||
_miniBadge("⚡ Energy", Colors.green),
|
||||
if (service.finServices.isNotEmpty)
|
||||
_miniBadge("💰 Fin", Colors.purple),
|
||||
if (service.entertainmentServices.isNotEmpty)
|
||||
_miniBadge("📺 Ent", Colors.red),
|
||||
Text(operation.type),
|
||||
const SizedBox(width: 8),
|
||||
_buildOperationStatus(operation.status),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.pushNamed(
|
||||
'service-form',
|
||||
extra: service, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
|
||||
'operation-form',
|
||||
extra: operation, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
|
||||
// Teniamo anche il parametro URL per coerenza di routing
|
||||
queryParameters: service.id != null ? {'serviceId': service.id!} : {},
|
||||
queryParameters: operation.id != null
|
||||
? {'operationId': operation.id!}
|
||||
: {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _miniBadge(String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Widget _buildOperationStatus(OperationStatus status) {
|
||||
Color color;
|
||||
switch (status) {
|
||||
case OperationStatus.canceled || OperationStatus.ko:
|
||||
color = Colors.grey.shade800;
|
||||
break;
|
||||
case OperationStatus.waitingforaction || OperationStatus.draft:
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case OperationStatus.ok:
|
||||
color = Colors.green;
|
||||
break;
|
||||
case OperationStatus.waitingfordeployment ||
|
||||
OperationStatus.waitingforsupport:
|
||||
color = Colors.blue;
|
||||
break;
|
||||
}
|
||||
return Chip(
|
||||
label: Text("BOZZA", style: TextStyle(fontSize: 10, color: Colors.white)),
|
||||
backgroundColor: color,
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}
|
||||
|
||||
void startNewOperation(BuildContext context) {
|
||||
context.pushNamed('operation-form');
|
||||
}
|
||||
}
|
||||
222
lib/features/operations/ui/widgets/customer_section.dart
Normal file
222
lib/features/operations/ui/widgets/customer_section.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/customers/blocs/customers_cubit.dart';
|
||||
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
|
||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
|
||||
class CustomerSection extends StatelessWidget {
|
||||
final OperationModel? currentOp;
|
||||
const CustomerSection({super.key, required this.currentOp});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCustomer =
|
||||
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Text(
|
||||
'Cliente',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => _showCustomerModal(context), // Passiamo il context!
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.2),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasCustomer
|
||||
? currentOp!.customerDisplayName!
|
||||
: 'Seleziona Cliente *',
|
||||
style: TextStyle(
|
||||
fontWeight: hasCustomer
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: hasCustomer ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.search),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// --- MODALE SELEZIONE CLIENTE ---
|
||||
void _showCustomerModal(BuildContext context) {
|
||||
String currentSearchQuery = '';
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.8,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Seleziona Cliente',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(modalContext),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Barra di Ricerca
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cerca per nome, telefono o email...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: (query) {
|
||||
currentSearchQuery = query;
|
||||
context.read<CustomersCubit>().searchCustomers(query);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Pulsante Nuovo Cliente
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('Crea Nuovo Cliente'),
|
||||
onPressed: () async {
|
||||
final OperationsCubit operationsCubit = context
|
||||
.read<OperationsCubit>();
|
||||
|
||||
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
|
||||
final newCustomer = await showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<CustomersCubit>(),
|
||||
child: QuickCustomerDialog(
|
||||
initialQuery:
|
||||
currentSearchQuery, // <-- Passiamo quello che ha digitato!
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Se l'ha creato davvero (e non ha premuto annulla)...
|
||||
if (newCustomer != null) {
|
||||
// 1. Aggiorniamo il form delle operazioni
|
||||
operationsCubit.updateOperationFields(
|
||||
customerId: newCustomer.id,
|
||||
customerDisplayName: newCustomer.name,
|
||||
);
|
||||
|
||||
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
|
||||
if (context.mounted) {
|
||||
Navigator.pop(modalContext);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Lista Clienti dal Bloc
|
||||
Expanded(
|
||||
child: BlocBuilder<CustomersCubit, CustomersState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == CustomersStatus.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.customers.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Nessun cliente trovato.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: state.customers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final customer = state.customers[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: Text(
|
||||
customer.name.substring(0, 1).toUpperCase(),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
customer.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${customer.phoneNumber} • ${customer.email}',
|
||||
),
|
||||
onTap: () {
|
||||
// Aggiorniamo il form tramite il Cubit delle operazioni
|
||||
context
|
||||
.read<OperationsCubit>()
|
||||
.updateOperationFields(
|
||||
customerId: customer.id, // customer.id
|
||||
customerDisplayName:
|
||||
customer.name, // customer.name
|
||||
);
|
||||
Navigator.pop(modalContext);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
423
lib/features/operations/ui/widgets/details_section.dart
Normal file
423
lib/features/operations/ui/widgets/details_section.dart
Normal file
@@ -0,0 +1,423 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||
import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
|
||||
class DetailsSection extends StatelessWidget {
|
||||
final OperationModel? currentOp;
|
||||
final String currentType;
|
||||
final TextEditingController freeTextSubtypeController;
|
||||
final TextEditingController freeTextDescriptionController;
|
||||
final Widget durationQuickPicks;
|
||||
|
||||
const DetailsSection({
|
||||
super.key,
|
||||
required this.currentOp,
|
||||
required this.currentType,
|
||||
required this.freeTextSubtypeController,
|
||||
required this.freeTextDescriptionController,
|
||||
required this.durationQuickPicks,
|
||||
});
|
||||
|
||||
bool _doesProviderMatchOperationType(dynamic provider, String operationType) {
|
||||
if (operationType == 'Custom') return true;
|
||||
switch (operationType) {
|
||||
case 'AL':
|
||||
case 'MNP':
|
||||
return provider.mobile == true;
|
||||
case 'NIP':
|
||||
return provider.landline == true;
|
||||
case 'UNICA':
|
||||
return provider.landline == true || provider.mobile == true;
|
||||
case 'Energy':
|
||||
return provider.energy == true;
|
||||
case 'Fin':
|
||||
return provider.financing == true;
|
||||
case 'Entertainment':
|
||||
return provider.entertainment == true;
|
||||
case 'TELEPASS':
|
||||
return provider.telepass == true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void _showProviderModal(BuildContext context, String operationType) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.5,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.8,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Seleziona Gestore',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(modalContext),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final allProviders = state.activeProviders;
|
||||
final filteredProviders = allProviders
|
||||
.where(
|
||||
(p) => _doesProviderMatchOperationType(
|
||||
p,
|
||||
operationType,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (filteredProviders.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Nessun gestore compatibile con questo servizio.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: filteredProviders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = filteredProviders[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.business),
|
||||
title: Text(
|
||||
provider.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
context
|
||||
.read<OperationsCubit>()
|
||||
.updateOperationFields(
|
||||
providerId: provider.id,
|
||||
providerDisplayName: provider.name,
|
||||
);
|
||||
Navigator.pop(modalContext);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showModelModal(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Seleziona Modello',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(modalContext),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cerca modello (es. iPhone 15...)',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: (query) =>
|
||||
context.read<ProductsCubit>().searchModels(query),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Aggiungi Modello al Volo'),
|
||||
onPressed: () async {
|
||||
final operationsCubit = context.read<OperationsCubit>();
|
||||
final existingBrands = context
|
||||
.read<ProductsCubit>()
|
||||
.state
|
||||
.brands;
|
||||
|
||||
final newModel = await showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ProductsCubit>(),
|
||||
child: QuickProductDialog(
|
||||
existingBrands: existingBrands,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (newModel != null) {
|
||||
operationsCubit.updateOperationFields(
|
||||
modelId: newModel.id,
|
||||
modelDisplayName: newModel.nameWithBrand,
|
||||
);
|
||||
if (context.mounted) Navigator.pop(modalContext);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: BlocBuilder<ProductsCubit, ProductState>(
|
||||
builder: (context, state) {
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: state.models.length,
|
||||
itemBuilder: (context, index) {
|
||||
final deviceModel = state.models[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.devices),
|
||||
title: Text(
|
||||
deviceModel.nameWithBrand,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
context
|
||||
.read<OperationsCubit>()
|
||||
.updateOperationFields(
|
||||
modelId: deviceModel.id,
|
||||
modelDisplayName: deviceModel.nameWithBrand,
|
||||
);
|
||||
Navigator.pop(modalContext);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// PROVIDER (Mostrato quasi sempre)
|
||||
ListTile(
|
||||
title: const Text('Seleziona Gestore'),
|
||||
subtitle: Text(
|
||||
(currentOp?.providerDisplayName != null &&
|
||||
currentOp!.providerDisplayName!.isNotEmpty)
|
||||
? currentOp!.providerDisplayName!
|
||||
: 'Nessun gestore selezionato',
|
||||
style: TextStyle(
|
||||
color:
|
||||
(currentOp?.providerId == null ||
|
||||
currentOp!.providerId!.isEmpty)
|
||||
? Colors.grey
|
||||
: null,
|
||||
fontWeight:
|
||||
(currentOp?.providerId == null ||
|
||||
currentOp!.providerId!.isEmpty)
|
||||
? FontWeight.normal
|
||||
: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_drop_down),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: theme.dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: () => _showProviderModal(context, currentType),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 1. SCENARIO ENERGY (Dropdown Fisso)
|
||||
if (currentType == 'Energy') ...[
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue:
|
||||
(currentOp?.subtype != null && currentOp!.subtype!.isNotEmpty)
|
||||
? currentOp!.subtype
|
||||
: null,
|
||||
decoration: const InputDecoration(labelText: 'Dettaglio Fornitura'),
|
||||
items: [
|
||||
'Luce',
|
||||
'Gas',
|
||||
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
subtype: val,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: freeTextDescriptionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: currentType == 'Energy'
|
||||
? 'Offerta scelta'
|
||||
: 'Nome del servizio/offerta',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
|
||||
if (currentType == 'Fin') ...[
|
||||
ListTile(
|
||||
title: const Text('Seleziona Dispositivo/Prodotto'),
|
||||
subtitle: Text(
|
||||
(currentOp?.modelDisplayName != null &&
|
||||
currentOp!.modelDisplayName!.isNotEmpty)
|
||||
? currentOp!.modelDisplayName!
|
||||
: 'Nessun modello selezionato',
|
||||
style: TextStyle(
|
||||
color:
|
||||
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
|
||||
? Colors.grey
|
||||
: null,
|
||||
fontWeight:
|
||||
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
|
||||
? FontWeight.normal
|
||||
: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_drop_down),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: theme.dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: () => _showModelModal(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// 3. SCENARIO ENTERTAINMENT O CUSTOM (Testo libero)
|
||||
if (currentType == 'Entertainment' || currentType == 'Custom') ...[
|
||||
TextFormField(
|
||||
controller: freeTextSubtypeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: currentType == 'Entertainment'
|
||||
? 'Piattaforma (es. Netflix, DAZN, Spotify...)'
|
||||
: 'Specifica il servizio (es. Monopattino)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// SCADENZA (Reattivo per tipi complessi)
|
||||
if ([
|
||||
'Energy',
|
||||
'Fin',
|
||||
'Entertainment',
|
||||
'Custom',
|
||||
].contains(currentType)) ...[
|
||||
const SizedBox(height: 8),
|
||||
durationQuickPicks, // Passiamo i chips dall'esterno
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('Data di Scadenza Effettiva'),
|
||||
subtitle: Text(
|
||||
currentOp?.expirationDate != null
|
||||
? "${currentOp!.expirationDate!.day}/${currentOp!.expirationDate!.month}/${currentOp!.expirationDate!.year}"
|
||||
: 'Nessuna scadenza impostata',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
trailing: const Icon(Icons.calendar_month, color: Colors.blue),
|
||||
tileColor: Colors.blue.withValues(alpha: 0.05),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Colors.blue, width: 0.5),
|
||||
),
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate:
|
||||
currentOp?.expirationDate ??
|
||||
DateTime.now().add(const Duration(days: 365)),
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 3650)),
|
||||
);
|
||||
if (date != null && context.mounted) {
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
expirationDate: date,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
761
lib/features/operations/ui/widgets/operation_files_section.dart
Normal file
761
lib/features/operations/ui/widgets/operation_files_section.dart
Normal file
@@ -0,0 +1,761 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flux/features/attachments/data/attachments_repository.dart';
|
||||
import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart';
|
||||
import 'package:flux/features/attachments/ui/quick_rename_dialog.dart';
|
||||
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:pdf/pdf.dart' as p; // Se ti serve formattazione core
|
||||
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
|
||||
|
||||
class _ExportItem {
|
||||
final Uint8List bytes;
|
||||
final String sourceName;
|
||||
final bool isMultiPage;
|
||||
final int pageIndex;
|
||||
|
||||
_ExportItem({
|
||||
required this.bytes,
|
||||
required this.sourceName,
|
||||
required this.isMultiPage,
|
||||
required this.pageIndex,
|
||||
});
|
||||
}
|
||||
|
||||
class OperationFilesSection extends StatefulWidget {
|
||||
final OperationModel currentOp;
|
||||
|
||||
const OperationFilesSection({super.key, required this.currentOp});
|
||||
|
||||
@override
|
||||
State<OperationFilesSection> createState() => _OperationFilesSectionState();
|
||||
}
|
||||
|
||||
class _OperationFilesSectionState extends State<OperationFilesSection> {
|
||||
String? _exportDirectory;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadExportDirectory();
|
||||
}
|
||||
|
||||
// --- GESTIONE CARTELLA CITRIX (SOLO DESKTOP) ---
|
||||
Future<void> _loadExportDirectory() async {
|
||||
if (kIsWeb) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_exportDirectory = prefs.getString('citrix_export_path');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectExportDirectory() async {
|
||||
final String? selectedDirectory = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Seleziona la cartella di esportazione per TIM/Citrix',
|
||||
);
|
||||
|
||||
if (selectedDirectory != null) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('citrix_export_path', selectedDirectory);
|
||||
setState(() {
|
||||
_exportDirectory = selectedDirectory;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Cartella Export impostata: $selectedDirectory'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SELEZIONE FILE DAL PC/TELEFONO ---
|
||||
Future<void> _pickFiles() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
allowMultiple: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'],
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result != null && mounted) {
|
||||
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
|
||||
context.read<OperationFilesBloc>().add(
|
||||
AddOperationFilesEvent(result.files),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- APERTURA VIEWER ---
|
||||
void _openFile(AttachmentModel file) {
|
||||
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
|
||||
final operationFilesBloc = context.read<OperationFilesBloc>();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (viewerContext) => BlocProvider.value(
|
||||
value: operationFilesBloc,
|
||||
child: AttachmentViewerScreen(
|
||||
attachment: file,
|
||||
onRename: (newName) {
|
||||
// Spara l'evento al BLoC e lui farà il resto!
|
||||
operationFilesBloc.add(RenameOperationFileEvent(file, newName));
|
||||
},
|
||||
onDelete: () {
|
||||
operationFilesBloc.add(DeleteSpecificOperationFileEvent(file));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportMergedPdf(List<AttachmentModel> selectedFiles) async {
|
||||
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. "FLATTEN" DI TUTTO (Stessa magia di prima)
|
||||
List<Uint8List> allPagesAsImages = [];
|
||||
final repository = GetIt.I.get<AttachmentsRepository>();
|
||||
|
||||
for (var file in selectedFiles) {
|
||||
Uint8List? fileBytes;
|
||||
|
||||
if (file.localBytes != null) {
|
||||
fileBytes = file.localBytes;
|
||||
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
||||
fileBytes = await repository.downloadAttachmentBytes(
|
||||
file.storagePath!,
|
||||
);
|
||||
}
|
||||
|
||||
if (fileBytes == null) continue;
|
||||
|
||||
if (file.extension == 'pdf') {
|
||||
final document = await px.PdfDocument.openData(fileBytes);
|
||||
for (int i = 1; i <= document.pagesCount; i++) {
|
||||
final page = await document.getPage(i);
|
||||
final pageImage = await page.render(
|
||||
width: page.width * 2,
|
||||
height: page.height * 2,
|
||||
format: px.PdfPageImageFormat.jpeg,
|
||||
);
|
||||
if (pageImage != null) {
|
||||
allPagesAsImages.add(pageImage.bytes);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
await document.close();
|
||||
} else {
|
||||
// È un'immagine
|
||||
allPagesAsImages.add(fileBytes);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) Navigator.pop(context); // Togliamo il loading
|
||||
|
||||
// Se per qualche motivo la lista è vuota, usciamo
|
||||
if (allPagesAsImages.isEmpty) return;
|
||||
|
||||
// 2. LOGICA DEL NOME SUGGERITO
|
||||
String suggestedName;
|
||||
if (selectedFiles.length == 1) {
|
||||
// Se c'è un solo file (es. ho selezionato 3 foto ma poi ho deselezionato le altre)
|
||||
suggestedName = selectedFiles.first.name;
|
||||
} else {
|
||||
// Se sono più file uniti
|
||||
suggestedName = '${widget.currentOp.customerDisplayName}_Unito';
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 3. DIALOG DI CONFERMA (Mostriamo la PRIMA pagina come anteprima per fargli capire cos'è)
|
||||
final finalName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => QuickRenameDialog(
|
||||
suggestedName: suggestedName,
|
||||
previewWidget: Image.memory(
|
||||
allPagesAsImages.first,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (finalName == null || finalName.isEmpty) return; // Ha annullato
|
||||
|
||||
// 4. CREAZIONE DEL PDF UNICO (IL MERGE VERO E PROPRIO)
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Cicliamo su tutte le immagini estratte e creiamo una pagina per ognuna
|
||||
for (var imageBytes in allPagesAsImages) {
|
||||
final pdfImage = pw.MemoryImage(imageBytes);
|
||||
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
margin: pw.EdgeInsets.zero,
|
||||
build: (pw.Context context) {
|
||||
return pw.Center(child: pw.Image(pdfImage));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final mergedPdfBytes = await pdf.save();
|
||||
|
||||
// 5. SALVATAGGIO SUL DISCO
|
||||
if (kIsWeb) {
|
||||
// Trigger download web
|
||||
} else {
|
||||
final fileToSave = File('$_exportDirectory/$finalName.pdf');
|
||||
await fileToSave.writeAsBytes(mergedPdfBytes);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PDF Multi-pagina creato e salvato con successo!'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
// Se il loading è ancora aperto, lo chiudiamo
|
||||
if (Navigator.canPop(context)) Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Errore durante l\'unione: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportSplitPdfs(List<AttachmentModel> selectedFiles) async {
|
||||
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. "FLATTEN" INTELLIGENTE (Ora usiamo la nostra classe _ExportItem)
|
||||
List<_ExportItem> itemsToExport = [];
|
||||
final repository = GetIt.I.get<AttachmentsRepository>();
|
||||
|
||||
for (var file in selectedFiles) {
|
||||
Uint8List? fileBytes;
|
||||
|
||||
if (file.localBytes != null) {
|
||||
fileBytes = file.localBytes;
|
||||
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
|
||||
fileBytes = await repository.downloadAttachmentBytes(
|
||||
file.storagePath!,
|
||||
);
|
||||
}
|
||||
|
||||
if (fileBytes == null) continue;
|
||||
|
||||
// Recuperiamo il nome che l'utente ha (magari) già impostato
|
||||
final baseName = file.name ?? 'Documento';
|
||||
|
||||
if (file.extension == 'pdf') {
|
||||
final document = await px.PdfDocument.openData(fileBytes);
|
||||
final isMulti =
|
||||
document.pagesCount > 1; // Controlliamo se è multipagina!
|
||||
|
||||
for (int i = 1; i <= document.pagesCount; i++) {
|
||||
final page = await document.getPage(i);
|
||||
|
||||
final pageImage = await page.render(
|
||||
width: page.width * 2,
|
||||
height: page.height * 2,
|
||||
format: px.PdfPageImageFormat.jpeg,
|
||||
);
|
||||
|
||||
if (pageImage != null) {
|
||||
// Salviamo l'immagine CON il suo contesto storico
|
||||
itemsToExport.add(
|
||||
_ExportItem(
|
||||
bytes: pageImage.bytes,
|
||||
sourceName: baseName,
|
||||
isMultiPage: isMulti,
|
||||
pageIndex: i,
|
||||
),
|
||||
);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
await document.close();
|
||||
} else {
|
||||
// SE È UN'IMMAGINE, la salviamo come singola pagina
|
||||
itemsToExport.add(
|
||||
_ExportItem(
|
||||
bytes: fileBytes,
|
||||
sourceName: baseName,
|
||||
isMultiPage: false,
|
||||
pageIndex: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
// 2. IL CICLO UX
|
||||
for (var item in itemsToExport) {
|
||||
if (!mounted) return;
|
||||
|
||||
// LA TUA MAGIA UX SUI NOMI:
|
||||
// Se è singolo (foto o PDF da 1 pag) -> Usa il nome originale nudo e crudo!
|
||||
// Se è multipagina -> Usa il nome originale + il numero di pagina
|
||||
String suggestedName = item.sourceName;
|
||||
if (item.isMultiPage) {
|
||||
suggestedName = '${item.sourceName}_Pag_${item.pageIndex}';
|
||||
}
|
||||
|
||||
final finalName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => QuickRenameDialog(
|
||||
suggestedName: suggestedName,
|
||||
previewWidget: Image.memory(item.bytes, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
|
||||
if (finalName == null || finalName.isEmpty) continue;
|
||||
|
||||
// CREAZIONE DEL PDF SINGOLO
|
||||
final pdf = pw.Document();
|
||||
final pdfImage = pw.MemoryImage(item.bytes); // Usiamo item.bytes!
|
||||
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
margin: pw.EdgeInsets.zero,
|
||||
build: (pw.Context context) {
|
||||
return pw.Center(child: pw.Image(pdfImage));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final singlePdfBytes = await pdf.save();
|
||||
|
||||
if (kIsWeb) {
|
||||
// Trigger download web
|
||||
} else {
|
||||
final fileToSave = File('$_exportDirectory/$finalName.pdf');
|
||||
await fileToSave.writeAsBytes(singlePdfBytes);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Esportazione completata con successo!'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Errore: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// USIAMO IL TUO BLOC!
|
||||
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
|
||||
builder: (context, state) {
|
||||
final allFiles = state.allFiles;
|
||||
final selectedFiles = state.selectedFiles;
|
||||
final hasSelection = selectedFiles.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 1. SETTINGS CARTELLA (Solo visibile su Desktop)
|
||||
if (!kIsWeb)
|
||||
Card(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.folder_special,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: const Text(
|
||||
'Cartella Export (Es. Citrix TIM)',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
_exportDirectory ??
|
||||
'Nessuna cartella selezionata. Clicca per impostare.',
|
||||
style: TextStyle(
|
||||
color: _exportDirectory == null
|
||||
? theme.colorScheme.error
|
||||
: null,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.settings),
|
||||
onTap: _selectExportDirectory,
|
||||
),
|
||||
),
|
||||
|
||||
// 2. ACTION BAR DINAMICA
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Bottone di Aggiunta
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add_photo_alternate),
|
||||
label: const Text('Aggiungi File'),
|
||||
onPressed: state.status == OperationFilesStatus.uploading
|
||||
? null
|
||||
: _pickFiles,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// NUOVO: SELEZIONA / DESELEZIONA TUTTO
|
||||
if (allFiles.isNotEmpty) ...[
|
||||
TextButton.icon(
|
||||
icon: Icon(
|
||||
selectedFiles.length == allFiles.length
|
||||
? Icons.deselect
|
||||
: Icons.select_all,
|
||||
),
|
||||
label: Text(
|
||||
selectedFiles.length == allFiles.length
|
||||
? 'Deseleziona Tutto'
|
||||
: 'Seleziona Tutto',
|
||||
),
|
||||
onPressed: () {
|
||||
if (selectedFiles.length == allFiles.length) {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
ClearOperationFileSelectionEvent(),
|
||||
);
|
||||
} else {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
SelectAllOperationFilesEvent(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Loader di upload
|
||||
if (state.status == OperationFilesStatus.uploading)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Azioni visibili SOLO se c'è una selezione!
|
||||
if (hasSelection) ...[
|
||||
// Bottone Elimina
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: 'Elimina selezionati',
|
||||
onPressed: () {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
DeleteOperationFilesEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Bottone Associa a Cliente
|
||||
if (widget.currentOp.customerId != null &&
|
||||
widget.currentOp.customerId!.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_add, color: Colors.blue),
|
||||
tooltip: 'Copia nei documenti del Cliente',
|
||||
onPressed: () {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
LinkFilesToCustomerEvent(
|
||||
customerId: widget.currentOp.customerId!,
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('File copiati nella scheda cliente!'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// IL NUOVO BOTTONE ESPORTA CON MENU A TENDINA
|
||||
PopupMenuButton<String>(
|
||||
tooltip: 'Opzioni di esportazione',
|
||||
position: PopupMenuPosition
|
||||
.under, // Opzionale: fa aprire il menu sotto al bottone
|
||||
onSelected: (value) {
|
||||
if (value == 'merge') {
|
||||
_exportMergedPdf(selectedFiles);
|
||||
} else if (value == 'split') {
|
||||
_exportSplitPdfs(selectedFiles);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<String>>[
|
||||
const PopupMenuItem<String>(
|
||||
value: 'merge',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.merge_type,
|
||||
color: Colors.blue,
|
||||
),
|
||||
title: Text('Unisci in un singolo PDF'),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'split',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.splitscreen,
|
||||
color: Colors.orange,
|
||||
),
|
||||
title: Text(
|
||||
'Dividi: un PDF per ogni pagina/foto',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// IL FIX È QUI SOTTO: AbsorbPointer + onPressed vuoto
|
||||
child: AbsorbPointer(
|
||||
child: FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
label: Text('Esporta (${selectedFiles.length})'),
|
||||
onPressed: () {}, // Manteniamo vivo il colore!
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. GRIGLIA DEI FILE
|
||||
if (allFiles.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.dividerColor,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Column(
|
||||
children: [
|
||||
Icon(Icons.upload_file, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Nessun file allegato. Usa il pulsante per aggiungere documenti o foto.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 150,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: allFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = allFiles[index];
|
||||
final isPdf = file.extension == 'pdf';
|
||||
final isSelected = selectedFiles.contains(file);
|
||||
final isLocal =
|
||||
file.localBytes !=
|
||||
null; // Per capire se è un file in bozza
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// CARD DEL FILE
|
||||
InkWell(
|
||||
onTap: () => _openFile(file),
|
||||
onLongPress: () {
|
||||
// Selezione rapida con long press!
|
||||
context.read<OperationFilesBloc>().add(
|
||||
ToggleOperationFileSelectionEvent(file),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.dividerColor,
|
||||
width: isSelected ? 3 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Anteprima
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: isPdf
|
||||
? const Icon(
|
||||
Icons.picture_as_pdf,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
)
|
||||
: isLocal
|
||||
? ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(
|
||||
top: Radius.circular(8),
|
||||
),
|
||||
child: Image.memory(
|
||||
file.localBytes!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.image,
|
||||
size: 48,
|
||||
color: Colors.blue,
|
||||
), // Da remoto metterai il tuo NetworkImage se vuoi
|
||||
),
|
||||
),
|
||||
// Nome File
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
file.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// CHECKBOX DI SELEZIONE
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.read<OperationFilesBloc>().add(
|
||||
ToggleOperationFileSelectionEvent(file),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: Colors.white.withValues(alpha: 0.8),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Icon(
|
||||
isSelected ? Icons.check : Icons.circle,
|
||||
size: 16,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// BADGE "IN ATTESA" (Se è locale ma la pratica è salvata)
|
||||
if (isLocal)
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Bozza',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
133
lib/features/operations/ui/widgets/staff_section.dart
Normal file
133
lib/features/operations/ui/widgets/staff_section.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
// IMPORTA IL TUO CUBIT DELLO STAFF
|
||||
// import 'package:flux/features/staff/blocs/staff_cubit.dart';
|
||||
|
||||
class StaffSection extends StatelessWidget {
|
||||
final OperationModel? currentOp;
|
||||
|
||||
const StaffSection({super.key, required this.currentOp});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final selectedStaffId =
|
||||
currentOp?.staffId ??
|
||||
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Text(
|
||||
'Operatore',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocBuilder<StaffCubit, StaffState>(
|
||||
builder: (context, state) {
|
||||
// Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder!
|
||||
final staffMembers = state.storeStaff;
|
||||
final currentLoggedStaffMember = GetIt.I
|
||||
.get<SessionCubit>()
|
||||
.state
|
||||
.currentStaffMember;
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: staffMembers.map((staff) {
|
||||
final isSelected = staff.id == selectedStaffId;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// Aggiorniamo la form con un solo tap!
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
staffId: staff.id,
|
||||
staffDisplayName: staff.name,
|
||||
);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.only(right: 12.0),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 10.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.dividerColor,
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: isSelected
|
||||
? Colors.white
|
||||
: theme.colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
staff.name.substring(0, 1).toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
staff == currentLoggedStaffMember
|
||||
? 'Tu (${staff.name})'
|
||||
: staff.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/utils/string_extensions.dart';
|
||||
import 'package:flux/features/services/data/services_repository.dart';
|
||||
import 'package:flux/features/services/models/energy_service_model.dart';
|
||||
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
||||
import 'package:flux/features/services/models/fin_service_model.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';
|
||||
import 'package:collection/collection.dart';
|
||||
part 'services_state.dart';
|
||||
|
||||
class ServicesCubit extends Cubit<ServicesState> {
|
||||
final ServicesRepository _repository = GetIt.I<ServicesRepository>();
|
||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||
|
||||
ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial));
|
||||
|
||||
// --- CARICAMENTO E PAGINAZIONE ---
|
||||
|
||||
Future<void> loadServices({bool refresh = false}) async {
|
||||
// Se stiamo già caricando, evitiamo chiamate doppie
|
||||
if (state.status == ServicesStatus.loading) return;
|
||||
|
||||
// Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo
|
||||
if (!refresh && state.hasReachedMax) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.loading,
|
||||
errorMessage: null,
|
||||
// Se è un refresh, svuotiamo la lista attuale per mostrare lo shimmer/loading
|
||||
allServices: refresh ? [] : state.allServices,
|
||||
hasReachedMax: refresh ? false : state.hasReachedMax,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final currentOffset = refresh ? 0 : state.allServices.length;
|
||||
final companyId = _sessionCubit.state.company?.id;
|
||||
|
||||
if (companyId == null) {
|
||||
throw Exception("Company ID non trovato nella sessione");
|
||||
}
|
||||
|
||||
final newServices = await _repository.fetchServices(
|
||||
companyId: companyId,
|
||||
offset: currentOffset,
|
||||
limit: 50,
|
||||
searchTerm: state.query,
|
||||
dateRange: state.dateRange,
|
||||
);
|
||||
|
||||
// Se ricevi meno record del limite, significa che non ce ne sono altri sul DB
|
||||
final bool reachedMax = newServices.length < 50;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.ready,
|
||||
allServices: refresh
|
||||
? newServices
|
||||
: [...state.allServices, ...newServices],
|
||||
hasReachedMax: reachedMax,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage: "Errore nel caricamento servizi: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GESTIONE FILTRI ---
|
||||
|
||||
/// Aggiorna i parametri di ricerca e ricarica da zero
|
||||
void updateFilters({String? query, DateTimeRange? range}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
query: query ?? state.query,
|
||||
dateRange: range ?? state.dateRange,
|
||||
),
|
||||
);
|
||||
loadServices(refresh: true);
|
||||
}
|
||||
|
||||
/// Pulisce tutti i filtri
|
||||
void clearFilters() {
|
||||
emit(state.copyWith(query: '', dateRange: null));
|
||||
loadServices(refresh: true);
|
||||
}
|
||||
|
||||
// --- GESTIONE BOZZA (DRAFT) ---
|
||||
|
||||
/// Inizializza un nuovo servizio o ne carica uno esistente per la modifica
|
||||
void initServiceForm({
|
||||
ServiceModel? existingService,
|
||||
String? serviceId,
|
||||
}) async {
|
||||
if (existingService != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: existingService,
|
||||
status: ServicesStatus.ready,
|
||||
),
|
||||
);
|
||||
} else if (serviceId != null) {
|
||||
ServiceModel? serviceModel = state.allServices.firstWhereOrNull(
|
||||
(s) => s.id == serviceId,
|
||||
);
|
||||
serviceModel ??= await _repository.fetchServiceById(serviceId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: serviceModel,
|
||||
status: ServicesStatus.ready,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Crea un template vuoto con lo store di default (se disponibile)
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: ServiceModel(
|
||||
storeId: _sessionCubit.state.currentStore?.id ?? '',
|
||||
number: '', // Sarà compilato dall'utente
|
||||
createdAt: DateTime.now(),
|
||||
companyId: _sessionCubit.state.company!.id!,
|
||||
),
|
||||
status: ServicesStatus.ready,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Metodo generico per aggiornare i campi base (AL, MNP, Note, ecc.)
|
||||
void updateField({
|
||||
int? al,
|
||||
int? mnp,
|
||||
int? nip,
|
||||
int? unica,
|
||||
int? telepass,
|
||||
String? note,
|
||||
String? number,
|
||||
bool? isBozza,
|
||||
bool? resultOk,
|
||||
String? customerId,
|
||||
String? customerDisplayName,
|
||||
}) {
|
||||
if (state.currentService == null) return;
|
||||
|
||||
final updated = state.currentService!.copyWith(
|
||||
al: al,
|
||||
mnp: mnp,
|
||||
nip: nip,
|
||||
unica: unica,
|
||||
telepass: telepass,
|
||||
note: note,
|
||||
number: number,
|
||||
isBozza: isBozza,
|
||||
resultOk: resultOk,
|
||||
customerId: customerId,
|
||||
customerDisplayName: customerDisplayName,
|
||||
);
|
||||
|
||||
emit(state.copyWith(currentService: updated));
|
||||
}
|
||||
|
||||
// --- GESTIONE MODULI COMPLESSI ---
|
||||
|
||||
void updateEnergyServices(List<EnergyServiceModel> energyList) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: state.currentService?.copyWith(
|
||||
energyServices: energyList,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateFinServices(List<FinServiceModel> finList) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: state.currentService?.copyWith(finServices: finList),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateEntertainmentServices(List<EntertainmentServiceModel> entList) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: state.currentService?.copyWith(
|
||||
entertainmentServices: entList,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- PERSISTENZA ---
|
||||
|
||||
Future<void> saveCurrentService({
|
||||
required bool isBozza,
|
||||
bool shouldPop = true,
|
||||
List<ServiceFileModel>? files,
|
||||
}) async {
|
||||
if (state.currentService == null) return;
|
||||
|
||||
emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null));
|
||||
try {
|
||||
// 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente
|
||||
final serviceToSave = state.currentService!.copyWith(
|
||||
isBozza: isBozza,
|
||||
files: files,
|
||||
);
|
||||
|
||||
// 2. Salvataggio corazzato
|
||||
final updatedService = await _repository.saveFullService(serviceToSave);
|
||||
|
||||
// 3. Reset e ricaricamento
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: shouldPop ? ServicesStatus.saved : ServicesStatus.savedNoPop,
|
||||
currentService: shouldPop ? null : updatedService,
|
||||
),
|
||||
);
|
||||
await loadServices(refresh: true);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GESTIONE ALLEGATI LOCALI ---
|
||||
|
||||
void addAttachments(List<PlatformFile> files) {
|
||||
final newAttachments = files.map((file) {
|
||||
return ServiceFileModel(
|
||||
id: null, // Meglio null se non è su DB
|
||||
serviceId: state.currentService?.id ?? '',
|
||||
name: file.name.fileNameWithoutExtension(),
|
||||
extension: file.name.fileExtension(),
|
||||
storagePath: '',
|
||||
fileSize: file.size,
|
||||
localBytes: file.bytes,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Creiamo una nuova lista pulita
|
||||
final List<ServiceFileModel> updatedList = [
|
||||
...(state.currentService?.files ?? []),
|
||||
...newAttachments,
|
||||
];
|
||||
|
||||
// Emettiamo lo stato assicurandoci che il ServiceModel venga clonato
|
||||
if (state.currentService != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: state.currentService!.copyWith(files: updatedList),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void removeAttachment(int index) {
|
||||
if (state.currentService == null) return;
|
||||
|
||||
final updatedList = List<ServiceFileModel>.from(
|
||||
state.currentService!.files,
|
||||
);
|
||||
updatedList.removeAt(index);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: state.currentService?.copyWith(files: updatedList),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void saveAndCopyFileToCustomer(List<ServiceFileModel> selectedFiles) async {
|
||||
final currentService = state.currentService;
|
||||
|
||||
// 1. Check di sicurezza: se non c'è il cliente, non sappiamo dove copiare
|
||||
if (currentService == null || currentService.customerId == null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage:
|
||||
"Impossibile copiare: nessun cliente associato alla pratica.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: ServicesStatus.loading));
|
||||
|
||||
try {
|
||||
// 2. SALVATAGGIO CORAZZATO
|
||||
// Chiamiamo il repo e otteniamo la pratica con TUTTI i file ora dotati di ID e storagePath
|
||||
final updatedService = await _repository.saveFullService(currentService);
|
||||
|
||||
// 3. COPIA RELAZIONALE
|
||||
// Per ogni file che l'utente ha selezionato nella UI, cerchiamo la sua versione
|
||||
// "ufficiale" (quella con lo storagePath) nel modello appena tornato dal DB.
|
||||
for (var selectedFile in selectedFiles) {
|
||||
// Cerchiamo il match nel modello aggiornato
|
||||
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.",
|
||||
),
|
||||
);
|
||||
|
||||
// Creiamo il link nel database del cliente
|
||||
await _repository.copyFileToCustomer(
|
||||
file: persistedFile,
|
||||
customerId: currentService.customerId!,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. AGGIORNAMENTO STATO
|
||||
// Aggiorniamo il Cubit con il servizio salvato così la UI mostra i file come "Remoti"
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.success,
|
||||
currentService: updatedService,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage: "Errore durante il salvataggio e copia: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.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/models/customer_file_model.dart';
|
||||
import 'package:flux/features/services/models/service_file_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/service_model.dart';
|
||||
|
||||
class ServicesRepository {
|
||||
final _supabase = Supabase.instance.client;
|
||||
final companyId = GetIt.I.get<SessionCubit>().state.company!.id;
|
||||
final CustomerRepository _customerRepository = GetIt.I<CustomerRepository>();
|
||||
|
||||
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
|
||||
Future<ServiceModel> fetchServiceById(String id) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('service')
|
||||
.select('''
|
||||
*,
|
||||
customer(nome),
|
||||
energy_service(*),
|
||||
fin_service(*),
|
||||
entertainment_service(*),
|
||||
service_file(*)
|
||||
''')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
return ServiceModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw Exception('Errore nel caricamento del servizio: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
|
||||
Future<List<ServiceModel>> fetchServices({
|
||||
required String companyId,
|
||||
required int offset,
|
||||
int limit = 50,
|
||||
String? searchTerm,
|
||||
DateTimeRange? dateRange,
|
||||
}) async {
|
||||
try {
|
||||
// Nota: 'customer(name, surname)' serve per il display name nella card
|
||||
var query = _supabase
|
||||
.from('service')
|
||||
.select('''
|
||||
*,
|
||||
customer(nome),
|
||||
energy_service(*),
|
||||
fin_service(*),
|
||||
entertainment_service(*),
|
||||
service_file(*)
|
||||
''')
|
||||
.eq('company_id', companyId);
|
||||
|
||||
// Filtro Range Date
|
||||
if (dateRange != null) {
|
||||
query = query
|
||||
.gte('created_at', dateRange.start.toIso8601String())
|
||||
.lte('created_at', dateRange.end.toIso8601String());
|
||||
}
|
||||
|
||||
if (searchTerm != null && searchTerm.isNotEmpty) {
|
||||
// Filtra sui campi della tabella principale O su quelli della tabella joinata
|
||||
query = query.or(
|
||||
'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.nome.ilike.%$searchTerm%',
|
||||
);
|
||||
}
|
||||
|
||||
final response = await query
|
||||
.order('created_at', ascending: false)
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
return (response as List)
|
||||
.map((map) => ServiceModel.fromMap(map))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('Errore nel caricamento servizi: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
||||
Future<ServiceModel> saveFullService(ServiceModel service) async {
|
||||
try {
|
||||
// 1. Upsert del record principale
|
||||
final serviceData = await _supabase
|
||||
.from('service')
|
||||
.upsert(service.toMap())
|
||||
.select()
|
||||
.single();
|
||||
|
||||
final String newId = serviceData['id'];
|
||||
|
||||
// 2. MODIFICA: Pulizia atomica dei figli
|
||||
// Se stiamo modificando (id != null), resettiamo le tabelle collegate
|
||||
if (service.id != null) {
|
||||
await Future.wait([
|
||||
_supabase.from('energy_service').delete().eq('service_id', newId),
|
||||
_supabase.from('fin_service').delete().eq('service_id', newId),
|
||||
_supabase
|
||||
.from('entertainment_service')
|
||||
.delete()
|
||||
.eq('service_id', newId),
|
||||
// Aggiungi qui eventuali altre tabelle pivot o file
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Inserimento dei moduli in parallelo per velocità
|
||||
final List<Future> insertTasks = [];
|
||||
|
||||
if (service.energyServices.isNotEmpty) {
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('energy_service')
|
||||
.insert(
|
||||
service.energyServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (service.finServices.isNotEmpty) {
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('fin_service')
|
||||
.insert(
|
||||
service.finServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (service.entertainmentServices.isNotEmpty) {
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('entertainment_service')
|
||||
.insert(
|
||||
service.entertainmentServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (insertTasks.isNotEmpty) {
|
||||
await Future.wait(insertTasks);
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
|
||||
for (var file in localFilesToUpload) {
|
||||
final storagePath =
|
||||
'$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}';
|
||||
final String mimeType = file.extension.toLowerCase() == 'pdf'
|
||||
? 'application/pdf'
|
||||
: 'image/${file.extension}';
|
||||
|
||||
final fileToSave = file.copyWith(
|
||||
serviceId: newId,
|
||||
storagePath: storagePath,
|
||||
);
|
||||
|
||||
// Creiamo una funzione asincrona per caricare file e scrivere nel DB
|
||||
Future<void> uploadAndLink() async {
|
||||
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
|
||||
await _supabase.storage
|
||||
.from('documents')
|
||||
.uploadBinary(
|
||||
storagePath,
|
||||
fileToSave.localBytes!,
|
||||
fileOptions: FileOptions(contentType: mimeType, upsert: true),
|
||||
);
|
||||
|
||||
// B. Inserimento riga nel DB relazionale
|
||||
await _supabase.from('service_file').insert(fileToSave.toMap());
|
||||
}
|
||||
|
||||
uploadTasks.add(uploadAndLink());
|
||||
}
|
||||
|
||||
// Eseguiamo tutti gli upload in parallelo per la massima velocità
|
||||
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) {
|
||||
// Qui potresti aggiungere una logica di "rollback manuale" se necessario
|
||||
throw Exception('Errore durante il salvataggio corazzato: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- ELIMINAZIONE ---
|
||||
Future<void> deleteService(String id) async {
|
||||
try {
|
||||
await _supabase.from('service').delete().eq('id', id);
|
||||
} catch (e) {
|
||||
throw Exception('Errore durante l\'eliminazione: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
|
||||
Future<List<String>> fetchTopEntertainmentTypes(String companyId) async {
|
||||
try {
|
||||
// Cerchiamo i tipi più frequenti associati ai servizi di questa company
|
||||
// Nota: dobbiamo passare attraverso la tabella 'service' per filtrare per company_id
|
||||
final response = await _supabase
|
||||
.from('entertainment_service')
|
||||
.select('type, service!inner(store!inner(company_id))')
|
||||
.eq('service.store.company_id', companyId)
|
||||
.limit(100); // Prendiamo un campione
|
||||
|
||||
// Logica rapida per contare le occorrenze e prendere i primi 5
|
||||
final Map<String, int> counts = {};
|
||||
for (var item in (response as List)) {
|
||||
final type = item['type'] as String;
|
||||
counts[type] = (counts[type] ?? 0) + 1;
|
||||
}
|
||||
|
||||
var sortedKeys = counts.keys.toList()
|
||||
..sort((a, b) => counts[b]!.compareTo(counts[a]!));
|
||||
|
||||
return sortedKeys.take(5).toList();
|
||||
} catch (e) {
|
||||
return [
|
||||
"Netflix",
|
||||
"DAZN",
|
||||
"Disney+",
|
||||
"Sky",
|
||||
]; // Fallback se non c'è ancora storia
|
||||
}
|
||||
}
|
||||
|
||||
/// 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({
|
||||
required ServiceFileModel file,
|
||||
required String customerId,
|
||||
}) async {
|
||||
CustomerFileModel fileToCopy = CustomerFileModel(
|
||||
customerId: customerId,
|
||||
name: file.name,
|
||||
storagePath: file.storagePath,
|
||||
extension: file.extension,
|
||||
fileSize: file.fileSize,
|
||||
);
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum EnergyType { luce, gas } // Mappa il tuo public.energy_type
|
||||
|
||||
class EnergyServiceModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final EnergyType type;
|
||||
final DateTime expiration;
|
||||
final String providerId;
|
||||
final String? serviceId;
|
||||
|
||||
const EnergyServiceModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.type,
|
||||
required this.expiration,
|
||||
required this.providerId,
|
||||
this.serviceId,
|
||||
});
|
||||
|
||||
EnergyServiceModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
EnergyType? type,
|
||||
DateTime? expiration,
|
||||
String? providerId,
|
||||
String? serviceId,
|
||||
}) {
|
||||
return EnergyServiceModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
expiration: expiration ?? this.expiration,
|
||||
providerId: providerId ?? this.providerId,
|
||||
serviceId: serviceId ?? this.serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
type,
|
||||
expiration,
|
||||
providerId,
|
||||
serviceId,
|
||||
];
|
||||
|
||||
factory EnergyServiceModel.fromMap(Map<String, dynamic> map) {
|
||||
return EnergyServiceModel(
|
||||
id: map['id'],
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
type: map['type'] == 'gas' ? EnergyType.gas : EnergyType.luce,
|
||||
expiration: DateTime.parse(map['expiration']),
|
||||
providerId: map['provider_id'],
|
||||
serviceId: map['service_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'type': type.name, // .name trasforma l'enum in 'luce' o 'gas'
|
||||
'expiration': expiration.toIso8601String(),
|
||||
'provider_id': providerId,
|
||||
'service_id': serviceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class EntertainmentServiceModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final String type; // es. Sky, DAZN, ecc.
|
||||
final bool constrained; // Vincolato?
|
||||
final DateTime constrainExpiration;
|
||||
final String? serviceId;
|
||||
final String? providerId;
|
||||
|
||||
const EntertainmentServiceModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.type,
|
||||
required this.constrained,
|
||||
required this.constrainExpiration,
|
||||
this.serviceId,
|
||||
this.providerId,
|
||||
});
|
||||
|
||||
EntertainmentServiceModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? type,
|
||||
bool? constrained,
|
||||
DateTime? constrainExpiration,
|
||||
String? serviceId,
|
||||
String? providerId,
|
||||
}) {
|
||||
return EntertainmentServiceModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
constrained: constrained ?? this.constrained,
|
||||
constrainExpiration: constrainExpiration ?? this.constrainExpiration,
|
||||
serviceId: serviceId ?? this.serviceId,
|
||||
providerId: providerId ?? this.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
type,
|
||||
constrained,
|
||||
constrainExpiration,
|
||||
serviceId,
|
||||
providerId,
|
||||
];
|
||||
|
||||
factory EntertainmentServiceModel.fromMap(Map<String, dynamic> map) {
|
||||
return EntertainmentServiceModel(
|
||||
id: map['id'],
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
type: map['type'],
|
||||
constrained: map['constrained'] ?? false,
|
||||
constrainExpiration: DateTime.parse(map['constrain_expiration']),
|
||||
serviceId: map['service_id'],
|
||||
providerId: map['provider_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'type': type,
|
||||
'constrained': constrained,
|
||||
'constrain_expiration': constrainExpiration.toIso8601String(),
|
||||
'service_id': serviceId,
|
||||
'provider_id': providerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class FinServiceModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final DateTime expiration;
|
||||
final String? serviceId;
|
||||
final String? modelId; // FK verso model (es. iPhone, Samsung, ecc.)
|
||||
final String? providerId;
|
||||
|
||||
const FinServiceModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.expiration,
|
||||
this.serviceId,
|
||||
this.modelId,
|
||||
this.providerId,
|
||||
});
|
||||
|
||||
FinServiceModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
DateTime? expiration,
|
||||
String? serviceId,
|
||||
String? modelId,
|
||||
String? providerId,
|
||||
}) {
|
||||
return FinServiceModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
expiration: expiration ?? this.expiration,
|
||||
serviceId: serviceId ?? this.serviceId,
|
||||
modelId: modelId ?? this.modelId,
|
||||
providerId: providerId ?? this.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, createdAt, expiration, serviceId, modelId];
|
||||
|
||||
factory FinServiceModel.fromMap(Map<String, dynamic> map) {
|
||||
return FinServiceModel(
|
||||
id: map['id'],
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
expiration: DateTime.parse(map['expiration']),
|
||||
serviceId: map['service_id'],
|
||||
modelId: map['model_id'],
|
||||
providerId: map['provider_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'expiration': expiration.toIso8601String(),
|
||||
'service_id': serviceId,
|
||||
'model_id': modelId,
|
||||
'provider_id': providerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/utils/string_extensions.dart';
|
||||
import 'package:flux/features/services/models/energy_service_model.dart';
|
||||
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
||||
import 'package:flux/features/services/models/fin_service_model.dart';
|
||||
import 'package:flux/features/services/models/service_file_model.dart'; // <-- Aggiunto Import
|
||||
|
||||
class ServiceModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final String storeId;
|
||||
final String? employeeId;
|
||||
final String? customerId;
|
||||
final String number;
|
||||
final bool isBozza;
|
||||
final String note;
|
||||
final bool resultOk;
|
||||
final String? customerDisplayName;
|
||||
final String companyId;
|
||||
|
||||
// Telefonia
|
||||
final int al;
|
||||
final int mnp;
|
||||
final int nip;
|
||||
final int unica;
|
||||
final int telepass;
|
||||
|
||||
// Moduli (Liste)
|
||||
final List<EnergyServiceModel> energyServices;
|
||||
final List<FinServiceModel> finServices;
|
||||
final List<EntertainmentServiceModel> entertainmentServices;
|
||||
|
||||
// ALLEGATI (Aggiunto)
|
||||
final List<ServiceFileModel> files;
|
||||
|
||||
const ServiceModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
required this.storeId,
|
||||
this.employeeId,
|
||||
this.customerId,
|
||||
required this.number,
|
||||
this.isBozza = true,
|
||||
this.note = '',
|
||||
this.resultOk = true,
|
||||
this.al = 0,
|
||||
this.mnp = 0,
|
||||
this.nip = 0,
|
||||
this.unica = 0,
|
||||
this.telepass = 0,
|
||||
this.energyServices = const [],
|
||||
this.finServices = const [],
|
||||
this.entertainmentServices = const [],
|
||||
this.files = const [], // <-- Aggiunto default vuoto
|
||||
this.customerDisplayName,
|
||||
required this.companyId,
|
||||
});
|
||||
|
||||
ServiceModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? storeId,
|
||||
String? employeeId,
|
||||
String? customerId,
|
||||
String? number,
|
||||
bool? isBozza,
|
||||
String? note,
|
||||
bool? resultOk,
|
||||
int? al,
|
||||
int? mnp,
|
||||
int? nip,
|
||||
int? unica,
|
||||
int? telepass,
|
||||
List<EnergyServiceModel>? energyServices,
|
||||
List<FinServiceModel>? finServices,
|
||||
List<EntertainmentServiceModel>? entertainmentServices,
|
||||
List<ServiceFileModel>? files, // <-- Aggiunto
|
||||
String? customerDisplayName,
|
||||
String? companyId,
|
||||
}) {
|
||||
return ServiceModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
storeId: storeId ?? this.storeId,
|
||||
employeeId: employeeId ?? this.employeeId,
|
||||
customerId: customerId ?? this.customerId,
|
||||
number: number ?? this.number,
|
||||
isBozza: isBozza ?? this.isBozza,
|
||||
note: note ?? this.note,
|
||||
resultOk: resultOk ?? this.resultOk,
|
||||
al: al ?? this.al,
|
||||
mnp: mnp ?? this.mnp,
|
||||
nip: nip ?? this.nip,
|
||||
unica: unica ?? this.unica,
|
||||
telepass: telepass ?? this.telepass,
|
||||
energyServices: energyServices ?? this.energyServices,
|
||||
finServices: finServices ?? this.finServices,
|
||||
entertainmentServices:
|
||||
entertainmentServices ?? this.entertainmentServices,
|
||||
files: files ?? this.files, // <-- Aggiunto
|
||||
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
|
||||
companyId: companyId ?? this.companyId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
storeId,
|
||||
employeeId,
|
||||
customerId,
|
||||
number,
|
||||
isBozza,
|
||||
note,
|
||||
resultOk,
|
||||
al,
|
||||
mnp,
|
||||
nip,
|
||||
unica,
|
||||
telepass,
|
||||
energyServices,
|
||||
finServices,
|
||||
entertainmentServices,
|
||||
files, // <-- Aggiunto
|
||||
customerDisplayName,
|
||||
companyId,
|
||||
];
|
||||
|
||||
factory ServiceModel.fromMap(Map<String, dynamic> map) {
|
||||
return ServiceModel(
|
||||
id: map['id'].toString(),
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: DateTime.now(),
|
||||
storeId: map['store_id'] ?? '',
|
||||
employeeId: map['employee_id']?.toString(),
|
||||
customerId: map['customer_id']?.toString(),
|
||||
number: map['number']?.toString() ?? '',
|
||||
isBozza: map['bozza'] ?? true,
|
||||
note: map['note'] ?? '',
|
||||
resultOk: map['result_ok'] ?? true,
|
||||
al: map['al'] ?? 0,
|
||||
mnp: map['mnp'] ?? 0,
|
||||
nip: map['nip'] ?? 0,
|
||||
unica: map['unica'] ?? 0,
|
||||
telepass: map['telepass'] ?? 0,
|
||||
|
||||
// Estrazione sicura liste collegate
|
||||
energyServices:
|
||||
(map['energy_service'] as List?)
|
||||
?.map((x) => EnergyServiceModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
finServices:
|
||||
(map['fin_service'] as List?)
|
||||
?.map((x) => FinServiceModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
entertainmentServices:
|
||||
(map['entertainment_service'] as List?)
|
||||
?.map((x) => EntertainmentServiceModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
|
||||
// I FILE! (Assicurati che la foreign key su Supabase usi esattamente questo nome)
|
||||
files:
|
||||
(map['service_file'] as List?)
|
||||
?.map((x) => ServiceFileModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
|
||||
// Display name del cliente con fallback
|
||||
customerDisplayName: map['customer'] != null
|
||||
? "${map['customer']['nome'] ?? ''}".myFormat()
|
||||
: "Cliente non assegnato",
|
||||
companyId: map['company_id'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'store_id': storeId,
|
||||
'employee_id': employeeId,
|
||||
'customer_id': customerId,
|
||||
'number': number,
|
||||
'bozza': isBozza,
|
||||
'note': note,
|
||||
'result_ok': resultOk,
|
||||
'al': al,
|
||||
'mnp': mnp,
|
||||
'nip': nip,
|
||||
'unica': unica,
|
||||
'telepass': telepass,
|
||||
'company_id': companyId,
|
||||
// Le liste non le mettiamo qui perché vanno in tabelle diverse!
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ActionCard extends StatelessWidget {
|
||||
final String label;
|
||||
final int count;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ActionCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isActive = count > 0;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 110, // Larghezza fissa per avere una griglia ordinata
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? color.withValues(alpha: 0.15)
|
||||
: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isActive ? color : Colors.grey.withValues(alpha: 0.3),
|
||||
width: isActive ? 2 : 1,
|
||||
),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: isActive ? color : Colors.grey, size: 28),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? color : Colors.grey.shade700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (isActive) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.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/services/blocs/service_files_bloc.dart';
|
||||
import 'package:flux/features/services/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/services/models/service_file_model.dart';
|
||||
|
||||
class AttachmentsSection extends StatelessWidget {
|
||||
const AttachmentsSection({super.key});
|
||||
|
||||
Future<void> _pickFiles(BuildContext context) async {
|
||||
// Usiamo withData: true fondamentale per avere i bytes e caricare su Supabase Storage
|
||||
FilePickerResult? result = await FilePicker.pickFiles(
|
||||
allowMultiple: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
context.read<ServiceFilesBloc>().add(AddServiceFilesEvent(result.files));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ServiceFilesBloc serviceFilesBloc = BlocProvider.of<ServiceFilesBloc>(
|
||||
context,
|
||||
);
|
||||
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- HEADER SEZIONE ---
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"DOCUMENTI ALLEGATI",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.attach_file),
|
||||
label: const Text("Aggiungi File"),
|
||||
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),
|
||||
|
||||
// --- LISTA VUOTA ---
|
||||
if (state.allFiles.isEmpty) // Furbata: usiamo allFiles.isEmpty!
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade300,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade50,
|
||||
),
|
||||
child: const Text(
|
||||
"Nessun documento allegato alla bozza.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
// --- LISTA PIENA ---
|
||||
else ...[
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: state.allFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = state.allFiles[index];
|
||||
final sizeMb = (file.fileSize / (1024 * 1024))
|
||||
.toStringAsFixed(2);
|
||||
final isPdf = file.extension.toLowerCase() == 'pdf';
|
||||
final isSelected = state.selectedFiles.contains(file);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => serviceFilesBloc.add(
|
||||
ToggleServiceFileSelectionEvent(file),
|
||||
),
|
||||
onDoubleTap: () => _handleDoubleClick(context, file),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 0,
|
||||
// UX Fina: cambiamo colore del bordo se selezionato
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
// UX Fina: Sfondo leggermente colorato se selezionato
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.05)
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isSelected
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(
|
||||
file.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
file.isLocal ? "$sizeMb MB • (Nuovo)" : " MB",
|
||||
),
|
||||
trailing: Icon(
|
||||
isPdf ? Icons.picture_as_pdf : Icons.image,
|
||||
color: isPdf ? Colors.red : Colors.blue,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// --- PANNELLO AZIONI CONTESTUALI (LA MAGIA) ---
|
||||
// Appare SOLO se c'è almeno un file selezionato
|
||||
if (state.selectedFiles.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Contatore
|
||||
Text(
|
||||
"${state.selectedFiles.length} file selezionati",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// Bottone Elimina
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text("Elimina"),
|
||||
onPressed: () {
|
||||
// Qui lancerai l'evento per eliminare i file selezionati!
|
||||
// Es: serviceFilesBloc.add(DeleteSelectedFilesEvent());
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Bottone Copia
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text("Copia in Cliente"),
|
||||
onPressed: () => saveAndCopyFilesToCustomer(
|
||||
context,
|
||||
state.selectedFiles,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleGenerateQr(BuildContext context) async {
|
||||
final cubit = context.read<ServicesCubit>();
|
||||
var currentService = cubit.state.currentService;
|
||||
|
||||
// 1. CATTURIAMO IL BLOC MENTRE SIAMO ANCORA NELLA PAGINA
|
||||
final serviceFilesBloc = context.read<ServiceFilesBloc>();
|
||||
|
||||
// 2. SE LA PRATICA E' NUOVA (Manca l'ID)
|
||||
if (currentService == null || currentService.id == null) {
|
||||
// NIENTE BlocListener qui! Solo un semplice Dialog di conferma
|
||||
final bool? confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Salvataggio Necessario"),
|
||||
content: const Text(
|
||||
"Per generare il QR Code e caricare file dal telefono, la pratica deve essere prima salvata in BOZZA.\n\nVuoi salvare ora?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text("Salva in Bozza"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return; // Utente ha annullato
|
||||
|
||||
// Salviamo forzatamente in bozza
|
||||
await cubit.saveCurrentService(
|
||||
isBozza: true,
|
||||
shouldPop: false,
|
||||
files: serviceFilesBloc.state.localFiles,
|
||||
);
|
||||
|
||||
// Recuperiamo il servizio aggiornato con l'ID!
|
||||
currentService = cubit.state.currentService;
|
||||
|
||||
if (currentService?.id == null) return;
|
||||
}
|
||||
|
||||
// 3. MOSTRIAMO IL QR CODE (Con il Ponte e l'Auto-Chiusura!)
|
||||
if (context.mounted) {
|
||||
final nomePratica = "Pratica ${currentService?.customerDisplayName ?? ''}"
|
||||
.trim();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => BlocProvider.value(
|
||||
// INIETTIAMO IL BLOC NEL CONTESTO DEL DIALOG ALIENO
|
||||
value: serviceFilesBloc,
|
||||
|
||||
// ORA METTIAMO L'AUTO-CHIUSURA SUL QR CODE!
|
||||
child: BlocListener<ServiceFilesBloc, ServiceFilesState>(
|
||||
listener: (context, state) {
|
||||
// Se arrivano file remoti e lo stato è success, chiudiamo il QR!
|
||||
// (Nota: usiamo dialogContext per assicurarci di chiudere il popup giusto)
|
||||
if (state.status == ServiceFilesStatus.success &&
|
||||
state.remoteFiles.isNotEmpty) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
},
|
||||
child: QrUploadDialog(
|
||||
deepLinkUrl:
|
||||
'fluxapp:///service/${currentService!.id}/upload?name=${Uri.encodeComponent(nomePratica)}',
|
||||
title: 'Scatta per\n$nomePratica',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- LOGICA DI COPIA AL CLIENTE ---
|
||||
void saveAndCopyFilesToCustomer(
|
||||
BuildContext context,
|
||||
List<ServiceFileModel> files,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Copia nei documenti Cliente"),
|
||||
content: const Text(
|
||||
"Vuoi copiare i file selezionati nell'anagrafica del cliente? \n\n"
|
||||
"Attenzione: per procedere, la pratica attuale verrà prima salvata in stato BOZZA.",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
// 1. Diciamo al Cubit di salvare in Bozza e fare la copia
|
||||
context.read<ServicesCubit>().saveAndCopyFileToCustomer(files);
|
||||
},
|
||||
child: const Text("Salva e Copia"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- LOGICA DI VISUALIZZAZIONE OVERLAY ---
|
||||
void _handleDoubleClick(BuildContext context, ServiceFileModel 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.isNotEmpty
|
||||
? file.storagePath
|
||||
: null,
|
||||
bytes: file.localBytes,
|
||||
)
|
||||
: ImageViewerWidget(
|
||||
storagePath: file.storagePath.isNotEmpty
|
||||
? file.storagePath
|
||||
: null,
|
||||
bytes: file.localBytes,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/features/customers/ui/customer_search_sheet.dart';
|
||||
import 'package:flux/features/services/models/service_model.dart';
|
||||
|
||||
class CustomerSection extends StatelessWidget {
|
||||
final ServiceModel service;
|
||||
|
||||
const CustomerSection({super.key, required this.service});
|
||||
|
||||
void _openCustomerSearch(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(modalContext).viewInsets.bottom,
|
||||
),
|
||||
// La modale di ricerca
|
||||
child: const CustomerSearchSheet(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Niente BlocBuilder qui! Leggiamo solo la variabile 'service'
|
||||
final hasCustomer = service.customerId != null;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Dati Cliente",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (!hasCustomer)
|
||||
Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _openCustomerSearch(context),
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text("Seleziona o Crea Cliente"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
service.customerDisplayName ?? "Cliente Selezionato",
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _openCustomerSearch(context),
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text("Cambia"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/services/models/energy_service_model.dart'; // Assicurati degli import
|
||||
|
||||
class EnergyServiceDialog extends StatefulWidget {
|
||||
final List<EnergyServiceModel> initialServices;
|
||||
final String
|
||||
currentStoreId; // Ci serve per sapere per quale negozio caricare i gestori
|
||||
|
||||
const EnergyServiceDialog({
|
||||
super.key,
|
||||
required this.initialServices,
|
||||
required this.currentStoreId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EnergyServiceDialog> createState() => _EnergyServiceDialogState();
|
||||
}
|
||||
|
||||
class _EnergyServiceDialogState extends State<EnergyServiceDialog> {
|
||||
// Lista temporanea per non "sporcare" il cubit finché non si preme Conferma
|
||||
late List<EnergyServiceModel> _tempList;
|
||||
bool _isAddingNew = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempList = List.from(widget.initialServices);
|
||||
// Al caricamento della modale, chiediamo al Cubit di recuperare i gestori veri!
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
widget.currentStoreId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.bolt, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isAddingNew ? "Nuovo Contratto" : "Servizi Energia"),
|
||||
],
|
||||
),
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: SizedBox(
|
||||
width: double.maxFinite,
|
||||
// Cambia vista in base al flag
|
||||
child: _isAddingNew
|
||||
? _EnergyForm(
|
||||
onSave: (newService) {
|
||||
setState(() {
|
||||
_tempList.add(newService);
|
||||
_isAddingNew = false; // Torna alla lista
|
||||
});
|
||||
},
|
||||
onCancel: () {
|
||||
setState(() => _isAddingNew = false);
|
||||
},
|
||||
)
|
||||
: _EnergyList(
|
||||
services: _tempList,
|
||||
onDelete: (index) {
|
||||
setState(() => _tempList.removeAt(index));
|
||||
},
|
||||
onAddTap: () {
|
||||
setState(() => _isAddingNew = true); // Passa al form
|
||||
},
|
||||
activeProviders: [
|
||||
// Passiamo i provider attivi filtrati per tipo Energia
|
||||
...context
|
||||
.read<ProvidersCubit>()
|
||||
.state
|
||||
.activeProviders
|
||||
.where((p) => p.energia == true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (!_isAddingNew) ...[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _tempList),
|
||||
child: const Text("Conferma Tutti"),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VISTA 1: LA LISTA DEI CONTRATTI
|
||||
// ==========================================
|
||||
class _EnergyList extends StatelessWidget {
|
||||
final List<EnergyServiceModel> services;
|
||||
final List<ProviderModel>
|
||||
activeProviders; // <--- NUOVO: La lista vera dal Cubit
|
||||
final Function(int) onDelete;
|
||||
final VoidCallback onAddTap;
|
||||
|
||||
const _EnergyList({
|
||||
required this.services,
|
||||
required this.activeProviders, // <--- Richiesto
|
||||
required this.onDelete,
|
||||
required this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (services.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text(
|
||||
"Nessun contratto energia inserito.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: services.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final s = services[index];
|
||||
final isLuce = s.type == EnergyType.luce;
|
||||
|
||||
// LA MAGIA: Troviamo il nome partendo dall'ID salvato nel servizio
|
||||
final providerIndex = activeProviders.indexWhere(
|
||||
(p) => p.id == s.providerId,
|
||||
);
|
||||
final providerName = providerIndex >= 0
|
||||
? (activeProviders[providerIndex].nome)
|
||||
: 'Gestore Rimosso/Sconosciuto';
|
||||
|
||||
// Formattazione data pulita (es. 04/09/2025)
|
||||
final day = s.expiration.day.toString().padLeft(2, '0');
|
||||
final month = s.expiration.month.toString().padLeft(2, '0');
|
||||
final formattedDate = "$day/$month/${s.expiration.year}";
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isLuce
|
||||
? Colors.orange.shade100
|
||||
: Colors.blue.shade100,
|
||||
child: Icon(
|
||||
isLuce
|
||||
? Icons.lightbulb_outline
|
||||
: Icons.local_fire_department,
|
||||
color: isLuce ? Colors.orange : Colors.blue,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
providerName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text("Scadenza: $formattedDate"),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onDelete(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi Contratto"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VISTA 2: IL FORM DI INSERIMENTO
|
||||
// ==========================================
|
||||
class _EnergyForm extends StatefulWidget {
|
||||
final Function(EnergyServiceModel) onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _EnergyForm({required this.onSave, required this.onCancel});
|
||||
|
||||
@override
|
||||
State<_EnergyForm> createState() => _EnergyFormState();
|
||||
}
|
||||
|
||||
class _EnergyFormState extends State<_EnergyForm> {
|
||||
EnergyType _selectedType = EnergyType.luce;
|
||||
String? _selectedProviderId;
|
||||
DateTime? _selectedExpiration;
|
||||
int? _selectedMonthsPreset;
|
||||
|
||||
void _applyPreset(int? months) {
|
||||
if (months == null) return;
|
||||
setState(() {
|
||||
_selectedMonthsPreset = months;
|
||||
// Calcoliamo la data: oggi + X mesi
|
||||
final now = DateTime.now();
|
||||
_selectedExpiration = DateTime(now.year, now.month + months, now.day);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now().add(
|
||||
const Duration(days: 365),
|
||||
), // Default 1 anno
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _selectedExpiration = picked);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. Tipo (Luce o Gas) - Segmented Button stile M3
|
||||
SegmentedButton<EnergyType>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: EnergyType.luce,
|
||||
label: Text("Luce"),
|
||||
icon: Icon(Icons.lightbulb_outline),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: EnergyType.gas,
|
||||
label: Text("Gas"),
|
||||
icon: Icon(Icons.local_fire_department),
|
||||
),
|
||||
],
|
||||
selected: {_selectedType},
|
||||
onSelectionChanged: (Set<EnergyType> newSelection) {
|
||||
setState(() => _selectedType = newSelection.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 2. SCADENZA INTELLIGENTE (La parte PRO)
|
||||
const Text(
|
||||
"Scadenza Contratto",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
SegmentedButton<int?>(
|
||||
showSelectedIcon: false, // Per un look più pulito
|
||||
segments: const [
|
||||
ButtonSegment(value: 12, label: Text("12m")),
|
||||
ButtonSegment(value: 24, label: Text("24m")),
|
||||
ButtonSegment(value: 36, label: Text("36m")),
|
||||
ButtonSegment(
|
||||
value: null,
|
||||
label: Icon(Icons.calendar_month, size: 20),
|
||||
),
|
||||
],
|
||||
selected: {_selectedMonthsPreset},
|
||||
onSelectionChanged: (Set<int?> newSelection) {
|
||||
final val = newSelection.first;
|
||||
if (val == null) {
|
||||
_pickDate(); // Se clicca l'icona calendario, apre il picker
|
||||
} else {
|
||||
_applyPreset(val); // Altrimenti applica 12, 24 o 36
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Visualizzazione della data calcolata (o scelta)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _selectedExpiration != null
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event,
|
||||
size: 18,
|
||||
color: _selectedExpiration != null
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_selectedExpiration != null
|
||||
? "Scade il: ${_selectedExpiration!.day.toString().padLeft(2, '0')}/${_selectedExpiration!.month.toString().padLeft(2, '0')}/${_selectedExpiration!.year}"
|
||||
: "Seleziona una scadenza",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _selectedExpiration != null
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 2. Provider Dropdown
|
||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(
|
||||
child: LinearProgressIndicator(),
|
||||
); // Mostra una barretta di caricamento
|
||||
}
|
||||
|
||||
if (state.activeProviders.isEmpty) {
|
||||
return const Text(
|
||||
"Nessun gestore associato a questo negozio.",
|
||||
style: TextStyle(color: Colors.red),
|
||||
);
|
||||
}
|
||||
// Filtra solo i provider di tipo Energia (Se hai una categoria nel modello)
|
||||
// Se non hai una categoria nel ProviderModel, puoi rimuovere il .where
|
||||
final energyProviders = state.activeProviders;
|
||||
return DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Gestore / Provider",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
initialValue: _selectedProviderId,
|
||||
items: energyProviders.map((p) {
|
||||
return DropdownMenuItem(value: p.id, child: Text(p.nome));
|
||||
}).toList(),
|
||||
onChanged: (val) => setState(() => _selectedProviderId = val),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. Scadenza (DatePicker integrato in un TextField)
|
||||
TextFormField(
|
||||
readOnly: true,
|
||||
onTap: _pickDate,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Data Scadenza",
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: const Icon(Icons.calendar_month),
|
||||
),
|
||||
// Mostra la data se selezionata, altrimenti vuoto
|
||||
controller: TextEditingController(
|
||||
text: _selectedExpiration != null
|
||||
? "${_selectedExpiration!.day}/${_selectedExpiration!.month}/${_selectedExpiration!.year}"
|
||||
: "",
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 4. Pulsanti Interni al Form
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text("Indietro"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
(_selectedProviderId == null || _selectedExpiration == null)
|
||||
? null // Disabilitato se mancano dati obbligatori
|
||||
: () {
|
||||
final newService = EnergyServiceModel(
|
||||
type: _selectedType,
|
||||
expiration: _selectedExpiration!,
|
||||
providerId: _selectedProviderId!,
|
||||
);
|
||||
widget.onSave(newService);
|
||||
},
|
||||
child: const Text("Salva Contratto"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/services/data/services_repository.dart';
|
||||
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class EntertainmentServiceDialog extends StatefulWidget {
|
||||
final List<EntertainmentServiceModel> initialServices;
|
||||
final String currentStoreId;
|
||||
|
||||
const EntertainmentServiceDialog({
|
||||
super.key,
|
||||
required this.initialServices,
|
||||
required this.currentStoreId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EntertainmentServiceDialog> createState() =>
|
||||
_EntertainmentServiceDialogState();
|
||||
}
|
||||
|
||||
class _EntertainmentServiceDialogState
|
||||
extends State<EntertainmentServiceDialog> {
|
||||
late List<EntertainmentServiceModel> _tempList;
|
||||
bool _isAddingNew = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempList = List.from(widget.initialServices);
|
||||
// Carichiamo i provider attivi per lo store corrente
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
widget.currentStoreId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.movie_filter_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isAddingNew ? "Nuovo Servizio" : "Servizi Intrattenimento"),
|
||||
],
|
||||
),
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
child: _isAddingNew
|
||||
? _EntertainmentForm(
|
||||
// Il form che abbiamo creato prima
|
||||
onSave: (newService) => setState(() {
|
||||
_tempList.add(newService);
|
||||
_isAddingNew = false;
|
||||
}),
|
||||
onCancel: () => setState(() => _isAddingNew = false),
|
||||
)
|
||||
: BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
// Passiamo allProviders per garantire la visione dello storico
|
||||
return _EntertainmentList(
|
||||
services: _tempList,
|
||||
allProviders: state.allProviders,
|
||||
onDelete: (index) =>
|
||||
setState(() => _tempList.removeAt(index)),
|
||||
onAddTap: () => setState(() => _isAddingNew = true),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: !_isAddingNew
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _tempList),
|
||||
child: const Text("Conferma Tutti"),
|
||||
),
|
||||
]
|
||||
: null, // I pulsanti del form sono interni al form stesso
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EntertainmentList extends StatelessWidget {
|
||||
final List<EntertainmentServiceModel> services;
|
||||
final List<ProviderModel> allProviders;
|
||||
final Function(int) onDelete;
|
||||
final VoidCallback onAddTap;
|
||||
|
||||
const _EntertainmentList({
|
||||
required this.services,
|
||||
required this.allProviders,
|
||||
required this.onDelete,
|
||||
required this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (services.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text(
|
||||
"Nessun servizio intrattenimento.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: services.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final s = services[index];
|
||||
|
||||
final providerName = allProviders
|
||||
.firstWhere(
|
||||
(p) => p.id == s.providerId,
|
||||
orElse: () => ProviderModel(
|
||||
id: '',
|
||||
nome: 'Fornitore Storico',
|
||||
companyId: '',
|
||||
isActive: false,
|
||||
energia: false,
|
||||
telefoniaFissa: false,
|
||||
telefoniaMobile: false,
|
||||
assicurazioni: false,
|
||||
finanziamenti: false,
|
||||
altro: false,
|
||||
intrattenimento: false,
|
||||
),
|
||||
)
|
||||
.nome;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.purple.shade100,
|
||||
child: const Icon(
|
||||
Icons.movie_creation_outlined,
|
||||
color: Colors.purple,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
"${s.type} • $providerName",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
s.constrained
|
||||
? "Vincolo fino al: ${s.constrainExpiration.day}/${s.constrainExpiration.month}/${s.constrainExpiration.year}"
|
||||
: "Senza vincoli",
|
||||
style: TextStyle(
|
||||
color: s.constrained
|
||||
? Colors.red.shade700
|
||||
: Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onDelete(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi Servizio"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---ENTERTAINMENT FORM (MODALE)---
|
||||
|
||||
class _EntertainmentForm extends StatefulWidget {
|
||||
final Function(EntertainmentServiceModel) onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _EntertainmentForm({required this.onSave, required this.onCancel});
|
||||
|
||||
@override
|
||||
State<_EntertainmentForm> createState() => _EntertainmentFormState();
|
||||
}
|
||||
|
||||
class _EntertainmentFormState extends State<_EntertainmentForm> {
|
||||
String? _selectedProviderId;
|
||||
final TextEditingController _typeController = TextEditingController();
|
||||
bool _isConstrained = false;
|
||||
DateTime _expirationDate = DateTime.now().add(
|
||||
const Duration(days: 365),
|
||||
); // Default 12 mesi
|
||||
|
||||
// Preset rapidi per il vincolo (es: 12, 24 mesi)
|
||||
int? _selectedPresetMonths;
|
||||
|
||||
void _applyPreset(int months) {
|
||||
setState(() {
|
||||
_selectedPresetMonths = months;
|
||||
_isConstrained = true;
|
||||
final now = DateTime.now();
|
||||
_expirationDate = DateTime(now.year, now.month + months, now.day);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _expirationDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_expirationDate = picked;
|
||||
_selectedPresetMonths = null;
|
||||
_isConstrained = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. GESTORE (Filtro intrattenimento)
|
||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
final filtered = state.activeProviders
|
||||
.where((p) => p.intrattenimento)
|
||||
.toList();
|
||||
return DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Fornitore (es: Sky, TIM)",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: filtered
|
||||
.map(
|
||||
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) => setState(() => _selectedProviderId = val),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. TIPO SERVIZIO (TextField con suggerimenti rapidi sotto)
|
||||
TextFormField(
|
||||
controller: _typeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Servizio",
|
||||
hintText: "es: Netflix, DAZN, Disney+",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (val) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Suggerimenti rapidi (Chip)
|
||||
FutureBuilder<List<String>>(
|
||||
future: GetIt.I<ServicesRepository>().fetchTopEntertainmentTypes(
|
||||
GetIt.I<SessionCubit>().state.company!.id!,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final suggestions = snapshot.data ?? ["Netflix", "DAZN", "Sky"];
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: suggestions.map((s) {
|
||||
return ActionChip(
|
||||
label: Text(s, style: const TextStyle(fontSize: 12)),
|
||||
onPressed: () => setState(() => _typeController.text = s),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. VINCOLO CONTRATTUALE
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Vincolo di permanenza",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Switch(
|
||||
value: _isConstrained,
|
||||
onChanged: (val) => setState(() {
|
||||
_isConstrained = val;
|
||||
if (!val) _selectedPresetMonths = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_isConstrained) ...[
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<int?>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 12, label: Text("12m")),
|
||||
ButtonSegment(value: 24, label: Text("24m")),
|
||||
ButtonSegment(
|
||||
value: null,
|
||||
label: Icon(Icons.calendar_month, size: 20),
|
||||
),
|
||||
],
|
||||
selected: {_selectedPresetMonths},
|
||||
onSelectionChanged: (val) {
|
||||
if (val.first == null) {
|
||||
_pickDate();
|
||||
} else {
|
||||
_applyPreset(val.first!);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Box data scadenza vincolo
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.event_busy, size: 18, color: Colors.redAccent),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Scadenza vincolo: ${_expirationDate.day.toString().padLeft(2, '0')}/${_expirationDate.month.toString().padLeft(2, '0')}/${_expirationDate.year}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// PULSANTI
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
(_selectedProviderId == null || _typeController.text.isEmpty)
|
||||
? null
|
||||
: () => widget.onSave(
|
||||
EntertainmentServiceModel(
|
||||
providerId: _selectedProviderId!,
|
||||
type: _typeController.text,
|
||||
constrained: _isConstrained,
|
||||
constrainExpiration: _expirationDate,
|
||||
),
|
||||
),
|
||||
child: const Text("Aggiungi"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||
import 'package:flux/features/master_data/products/models/model_model.dart';
|
||||
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
|
||||
import 'package:flux/features/services/models/fin_service_model.dart';
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
|
||||
// ===========================================================================
|
||||
// DIALOG PRINCIPALE
|
||||
// ===========================================================================
|
||||
class FinanceServiceDialog extends StatefulWidget {
|
||||
final List<FinServiceModel> initialServices;
|
||||
final String currentStoreId;
|
||||
final ProductCubit productCubit;
|
||||
|
||||
const FinanceServiceDialog({
|
||||
super.key,
|
||||
required this.initialServices,
|
||||
required this.currentStoreId,
|
||||
required this.productCubit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FinanceServiceDialog> createState() => _FinanceServiceDialogState();
|
||||
}
|
||||
|
||||
class _FinanceServiceDialogState extends State<FinanceServiceDialog> {
|
||||
late List<FinServiceModel> _tempList;
|
||||
bool _isAddingNew = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempList = List.from(widget.initialServices);
|
||||
// Carichiamo i dati necessari dai Cubit
|
||||
context.read<ProvidersCubit>().loadActiveProvidersForStore(
|
||||
widget.currentStoreId,
|
||||
);
|
||||
context.read<ProductCubit>().loadBrands();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: widget.productCubit,
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.payments_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_isAddingNew ? "Dettagli Finanziamento" : "Finanziamenti"),
|
||||
],
|
||||
),
|
||||
content: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
child: _isAddingNew
|
||||
? _FinanceForm(
|
||||
onSave: (newFin) => setState(() {
|
||||
_tempList.add(newFin);
|
||||
_isAddingNew = false;
|
||||
}),
|
||||
onCancel: () => setState(() => _isAddingNew = false),
|
||||
)
|
||||
: BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, provState) {
|
||||
return BlocBuilder<ProductCubit, ProductState>(
|
||||
builder: (context, prodState) {
|
||||
return _FinanceList(
|
||||
services: _tempList,
|
||||
allProviders:
|
||||
provState.allProviders, // Per vedere lo storico
|
||||
allModels: prodState.models,
|
||||
onDelete: (index) =>
|
||||
setState(() => _tempList.removeAt(index)),
|
||||
onAddTap: () => setState(() => _isAddingNew = true),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: !_isAddingNew
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _tempList),
|
||||
child: const Text("Conferma"),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// VISTA LISTA (STORICA)
|
||||
// ===========================================================================
|
||||
class _FinanceList extends StatelessWidget {
|
||||
final List<FinServiceModel> services;
|
||||
final List<ProviderModel> allProviders;
|
||||
final List<ModelModel> allModels;
|
||||
final Function(int) onDelete;
|
||||
final VoidCallback onAddTap;
|
||||
|
||||
const _FinanceList({
|
||||
required this.services,
|
||||
required this.allProviders,
|
||||
required this.allModels,
|
||||
required this.onDelete,
|
||||
required this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (services.isEmpty) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text(
|
||||
"Nessun finanziamento inserito.",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi primo"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: services.length,
|
||||
separatorBuilder: (_, _) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final s = services[index];
|
||||
|
||||
// Cerchiamo il nome del provider in TUTTI quelli caricati (storico)
|
||||
final providerName = allProviders
|
||||
.firstWhere(
|
||||
(p) => p.id == s.providerId,
|
||||
orElse: () => ProviderModel(
|
||||
id: '',
|
||||
nome: 'Operatore Storico',
|
||||
companyId: '',
|
||||
isActive: false,
|
||||
energia: false,
|
||||
telefoniaFissa: false,
|
||||
telefoniaMobile: false,
|
||||
assicurazioni: false,
|
||||
altro: false,
|
||||
intrattenimento: false,
|
||||
finanziamenti: false,
|
||||
),
|
||||
)
|
||||
.nome;
|
||||
|
||||
// Cerchiamo il nome del modello
|
||||
final modelName = allModels
|
||||
.firstWhere(
|
||||
(m) => m.id == s.modelId,
|
||||
orElse: () => ModelModel(
|
||||
id: '',
|
||||
name: 'Prodotto',
|
||||
nameWithBrand: 'Prodotto Storico',
|
||||
brandId: '',
|
||||
),
|
||||
)
|
||||
.nameWithBrand;
|
||||
|
||||
final dateStr =
|
||||
"${s.expiration.day.toString().padLeft(2, '0')}/${s.expiration.month.toString().padLeft(2, '0')}/${s.expiration.year}";
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
modelName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text("$providerName • Scade: $dateStr"),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onDelete(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: onAddTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Aggiungi altro"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// FORM CON OMNI-SEARCH
|
||||
// ===========================================================================
|
||||
class _FinanceForm extends StatefulWidget {
|
||||
final Function(FinServiceModel) onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _FinanceForm({required this.onSave, required this.onCancel});
|
||||
|
||||
@override
|
||||
State<_FinanceForm> createState() => _FinanceFormState();
|
||||
}
|
||||
|
||||
class _FinanceFormState extends State<_FinanceForm> {
|
||||
String? _selectedProviderId;
|
||||
ModelModel? _selectedModel;
|
||||
int _selectedMonths = 30; // Default richiesto
|
||||
Timer? _debounce;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
late DateTime _selectedExpirationDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final now = DateTime.now();
|
||||
_selectedExpirationDate = DateTime(
|
||||
now.year,
|
||||
now.month + _selectedMonths,
|
||||
now.day,
|
||||
); // Inizialmente 30 mesi dalla data attuale
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||
context.read<ProductCubit>().searchModels(query);
|
||||
});
|
||||
}
|
||||
|
||||
// Funzione per aggiornare la data quando si clicca sui segmenti 24, 30, 48
|
||||
void _updateExpirationByMonths(int months) {
|
||||
setState(() {
|
||||
_selectedMonths = months;
|
||||
final now = DateTime.now();
|
||||
// Calcolo preciso: aggiungiamo i mesi alla data attuale
|
||||
_selectedExpirationDate = DateTime(now.year, now.month + months, now.day);
|
||||
});
|
||||
}
|
||||
|
||||
// Funzione per il picker manuale
|
||||
Future<void> _selectManualDate() async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedExpirationDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(
|
||||
const Duration(days: 365 * 10),
|
||||
), // Fino a 10 anni
|
||||
);
|
||||
if (picked != null && picked != _selectedExpirationDate) {
|
||||
setState(() {
|
||||
_selectedExpirationDate = picked;
|
||||
_selectedMonths = 0; // Resettiamo i segmenti perché è una data custom
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. SCELTA ISTITUTO (Solo attivi)
|
||||
BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
final finProviders = state.activeProviders
|
||||
.where((p) => p.finanziamenti)
|
||||
.toList(); // Già filtrati dal caricamento della dialog
|
||||
return DropdownButtonFormField<String>(
|
||||
initialValue: _selectedProviderId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Gestore",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: finProviders
|
||||
.map(
|
||||
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (val) => setState(() => _selectedProviderId = val),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. RICERCA MODELLO
|
||||
if (_selectedModel == null) ...[
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Cerca modello (es: iPhone...)",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () => _showQuickCreate(context),
|
||||
),
|
||||
),
|
||||
onChanged: (val) {
|
||||
_onSearchChanged(val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSearchSuggestions(),
|
||||
] else
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.phone_android),
|
||||
title: Text(
|
||||
_selectedModel!.nameWithBrand,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => setState(() => _selectedModel = null),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. DURATA PRESET
|
||||
const Text(
|
||||
"Durata Rate",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 24, label: Text("24m")),
|
||||
ButtonSegment(value: 30, label: Text("30m")),
|
||||
ButtonSegment(value: 48, label: Text("48m")),
|
||||
],
|
||||
selected: {_selectedMonths},
|
||||
onSelectionChanged: (val) => _updateExpirationByMonths(val.first),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// RIEPILOGO DATA E PICKER MANUALE (Stile Energia)
|
||||
const Text(
|
||||
"Scadenza Finanziamento",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
InkWell(
|
||||
onTap: _selectManualDate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 18,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
"${_selectedExpirationDate.day.toString().padLeft(2, '0')}/${_selectedExpirationDate.month.toString().padLeft(2, '0')}/${_selectedExpirationDate.year}",
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Icon(Icons.edit, size: 18, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text("Indietro"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: (_selectedProviderId == null || _selectedModel == null)
|
||||
? null
|
||||
: () {
|
||||
final now = DateTime.now();
|
||||
widget.onSave(
|
||||
FinServiceModel(
|
||||
providerId: _selectedProviderId!,
|
||||
modelId: _selectedModel!.id!,
|
||||
expiration: DateTime(
|
||||
now.year,
|
||||
now.month + _selectedMonths,
|
||||
now.day,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text("Salva"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchSuggestions() {
|
||||
return BlocBuilder<ProductCubit, ProductState>(
|
||||
builder: (context, state) {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
if (query.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final filtered = state.models
|
||||
.where((m) => m.nameWithBrand.toLowerCase().contains(query))
|
||||
.take(3)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
children: filtered
|
||||
.map(
|
||||
(m) => ListTile(
|
||||
title: Text(m.nameWithBrand),
|
||||
onTap: () => setState(() => _selectedModel = m),
|
||||
dense: true,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showQuickCreate(BuildContext context) {
|
||||
// Implementazione rapida dialog creazione Brand/Modello come discusso prima
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/services/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/services/models/service_model.dart';
|
||||
|
||||
class GeneralInfoSection extends StatelessWidget {
|
||||
final ServiceModel service;
|
||||
const GeneralInfoSection({super.key, required this.service});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Info Generali",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Numero di Riferimento / Telefono
|
||||
TextFormField(
|
||||
initialValue: service.number,
|
||||
keyboardType: TextInputType
|
||||
.phone, // Fa aprire il tastierino numerico su mobile
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Numero di Telefono / Riferimento",
|
||||
hintText: "Es. 3331234567",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.phone),
|
||||
),
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(number: val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// I due Switch affiancati (Bozza e A buon fine)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: const Text("Bozza"),
|
||||
subtitle: const Text(
|
||||
"Pratica in lavorazione",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
value: service.isBozza,
|
||||
activeThumbColor: Colors.orange,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(isBozza: val);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: const Text("A buon fine"),
|
||||
subtitle: const Text(
|
||||
"Esito positivo",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
value: service.resultOk,
|
||||
activeThumbColor: Colors.green,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(resultOk: val);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Campo Note
|
||||
TextFormField(
|
||||
initialValue: service.note,
|
||||
maxLines: 4,
|
||||
minLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Note Operazione",
|
||||
hintText:
|
||||
"Scrivi qui eventuali dettagli o richieste del cliente...",
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
onChanged: (val) {
|
||||
context.read<ServicesCubit>().updateField(note: val);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import 'dart:async'; // Necessario per il Timer
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> updateCountDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
int currentValue,
|
||||
Function(int) onSave,
|
||||
) async {
|
||||
int tempValue =
|
||||
currentValue; // Variabile locale per gestire il conteggio nella dialog
|
||||
|
||||
final result = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text("Imposta $title"),
|
||||
content: QuickCounter(
|
||||
initialValue: tempValue,
|
||||
onChanged: (val) => tempValue =
|
||||
val, // Aggiorna il valore locale quando il counter cambia
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("Annulla"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, tempValue),
|
||||
child: const Text("Conferma"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
onSave(result);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Widget Interno Specifico per il Counter Veloce ---
|
||||
class QuickCounter extends StatefulWidget {
|
||||
final int initialValue;
|
||||
final ValueChanged<int>
|
||||
onChanged; // Callback per notificare il padre dei cambiamenti
|
||||
|
||||
const QuickCounter({
|
||||
super.key,
|
||||
required this.initialValue,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<QuickCounter> createState() => _QuickCounterState();
|
||||
}
|
||||
|
||||
class _QuickCounterState extends State<QuickCounter> {
|
||||
late int _value;
|
||||
Timer? _longPressTimer; // Il timer per l'auto-incremento
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_value = widget.initialValue;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_longPressTimer
|
||||
?.cancel(); // IMPORTANTE: Annulla sempre il timer alla distruzione
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Logica comune per incremento/decremento singolo o rapido
|
||||
void _update(int delta) {
|
||||
setState(() {
|
||||
_value += delta;
|
||||
if (_value < 0) _value = 0; // Impedisci numeri negativi
|
||||
});
|
||||
widget.onChanged(_value); // Notifica il padre
|
||||
}
|
||||
|
||||
// Gestione dell'inizio della pressione prolungata
|
||||
void _startLongPress(int delta) {
|
||||
_update(delta); // Esegui subito il primo aggiornamento al tocco iniziale
|
||||
_longPressTimer = Timer.periodic(const Duration(milliseconds: 100), (
|
||||
timer,
|
||||
) {
|
||||
_update(delta); // Aggiorna velocemente finché la pressione continua
|
||||
});
|
||||
}
|
||||
|
||||
// Gestione della fine della pressione prolungata
|
||||
void _stopLongPress() {
|
||||
_longPressTimer?.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canDecrement = _value > 0;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// --- Pulsante MENO ---
|
||||
GestureDetector(
|
||||
onLongPressStart: canDecrement ? (_) => _startLongPress(-1) : null,
|
||||
onLongPressEnd: (_) => _stopLongPress(),
|
||||
onLongPressCancel: () => _stopLongPress(),
|
||||
onTap: canDecrement ? () => _update(-1) : null,
|
||||
child: Opacity(
|
||||
// Visivamente disabilitato se < 0
|
||||
opacity: canDecrement ? 1.0 : 0.4,
|
||||
child: const ActionButton(icon: Icons.remove, color: Colors.red),
|
||||
),
|
||||
),
|
||||
|
||||
// --- Valore Centrale ---
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
_value.toString(),
|
||||
style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// --- Pulsante PIU' ---
|
||||
GestureDetector(
|
||||
onLongPressStart: (_) => _startLongPress(1),
|
||||
onLongPressEnd: (_) => _stopLongPress(),
|
||||
onLongPressCancel: () => _stopLongPress(),
|
||||
onTap: () => _update(1),
|
||||
child: const ActionButton(icon: Icons.add, color: Colors.green),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Piccolo widget di utilità per l'aspetto del pulsante
|
||||
class ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const ActionButton({super.key, required this.icon, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color, width: 2),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 30),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/services/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/services/models/service_model.dart';
|
||||
import 'package:flux/features/services/ui/service_form_screen/attachment_section.dart';
|
||||
import 'package:flux/features/services/ui/service_form_screen/customer_section.dart';
|
||||
import 'package:flux/features/services/ui/service_form_screen/general_info_section.dart';
|
||||
import 'package:flux/features/services/ui/service_form_screen/services_grid.dart';
|
||||
|
||||
class ServiceFormScreen extends StatefulWidget {
|
||||
final String? serviceId;
|
||||
final ServiceModel? existingService; // <-- AGGIUNTO
|
||||
|
||||
const ServiceFormScreen({
|
||||
super.key,
|
||||
this.serviceId,
|
||||
this.existingService, // <-- AGGIUNTO
|
||||
});
|
||||
|
||||
@override
|
||||
State<ServiceFormScreen> createState() => _ServiceFormScreenState();
|
||||
}
|
||||
|
||||
class _ServiceFormScreenState extends State<ServiceFormScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Diamo in pasto al Cubit tutto quello che abbiamo!
|
||||
context.read<ServicesCubit>().initServiceForm(
|
||||
existingService: widget.existingService,
|
||||
serviceId: widget.serviceId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _performSave(BuildContext context, {required bool isBozza}) {
|
||||
FocusScope.of(context).unfocus();
|
||||
context.read<ServicesCubit>().saveCurrentService(isBozza: isBozza);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<ServicesCubit, ServicesState>(
|
||||
listener: (context, state) {
|
||||
if (state.status == ServicesStatus.saved) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Pratica salvata con successo!"),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
if (state.status == ServicesStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Errore: ${state.errorMessage ?? ''}"),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.status == ServicesStatus.savedNoPop) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Pratica salvata con successo!"),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final service = state.currentService;
|
||||
final isSaving = state.status == ServicesStatus.saving;
|
||||
final isEditMode = widget.serviceId != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(isEditMode ? "Modifica Pratica" : "Nuova Pratica"),
|
||||
actions: [
|
||||
if (isSaving)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 20.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (service != null) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_note),
|
||||
tooltip: "Salva come Bozza",
|
||||
onPressed: () => _performSave(context, isBozza: true),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: Colors.green,
|
||||
),
|
||||
tooltip: "Conferma Pratica",
|
||||
onPressed: () => _performSave(context, isBozza: false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: (service == null)
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomerSection(service: service),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
GeneralInfoSection(service: service),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
ServicesGrid(service: service),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
AttachmentsSection(),
|
||||
const SizedBox(height: 32),
|
||||
_buildBottomActionButtons(context, isSaving: isSaving),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActionButtons(
|
||||
BuildContext context, {
|
||||
required bool isSaving,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
icon: const Icon(Icons.edit_note),
|
||||
label: const Text("Salva in Bozza"),
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => _performSave(context, isBozza: true),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green.shade600,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text(
|
||||
"CONFERMA PRATICA",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1),
|
||||
),
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => _performSave(context, isBozza: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user