From 94ad524baee55a89dadcc6a801309527c3652102 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Mon, 4 May 2026 15:36:42 +0200 Subject: [PATCH] reworked operation (#12) Reviewed-on: https://gitea.catelli.it/brontomark/flux/pulls/12 Co-authored-by: Mark M2 Macbook Co-committed-by: Mark M2 Macbook --- analysis_options.yaml | 15 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- l10n.yaml | 3 + lib/core/data/core_repository.dart | 16 +- lib/core/layout/app_shell.dart | 17 +- lib/core/routes/app_router.dart | 109 ++- lib/core/utils/app_message.dart | 20 + ...string_extensions.dart => extensions.dart} | 7 + lib/core/widgets/image_viewer_widget.dart | 5 +- lib/core/widgets/pdf_viewer_widget.dart | 5 +- lib/core/widgets/qr_upload_dialog.dart | 3 +- lib/core/widgets/set_password_screen.dart | 37 +- .../data/attachments_repository.dart | 23 + .../models/attachment_model.dart} | 95 ++- .../ui/attachment_viewer_screen.dart | 220 +++++ .../attachments/ui/quick_rename_dialog.dart | 85 ++ lib/features/auth/bloc/auth_cubit.dart | 10 +- lib/features/auth/bloc/auth_state.dart | 4 +- lib/features/auth/ui/auth_screen.dart | 30 +- lib/features/company/bloc/company_state.dart | 2 +- .../company/data/company_repository.dart | 2 +- .../company/models/company_model.dart | 130 +-- .../company/ui/create_company_screen.dart | 56 +- .../customers/blocs/customer_files_bloc.dart | 6 +- .../blocs/customer_files_events.dart | 2 +- .../customers/blocs/customer_files_state.dart | 8 +- ...stomer_cubit.dart => customers_cubit.dart} | 33 +- ...stomer_state.dart => customers_state.dart} | 23 +- .../customers/data/customer_repository.dart | 103 ++- .../customers/models/customer_file_model.dart | 91 --- .../customers/models/customer_model.dart | 80 +- .../customers/ui/customer_detail_screen.dart | 18 +- lib/features/customers/ui/customer_form.dart | 18 +- .../customers/ui/customer_search_sheet.dart | 202 ----- .../customers/ui/customers_content.dart | 24 +- .../customers/ui/quick_customer_dialog.dart | 18 +- .../bloc/latest_store_operations_bloc.dart | 66 ++ .../bloc/latest_store_operations_events.dart | 17 + .../bloc/latest_store_operations_state.dart | 30 + .../ui/latest_store_operations_card.dart | 189 +++++ .../home/ui/dashboard_action_card.dart | 44 - .../home/ui/dashboard_adaptive_grid.dart | 75 -- lib/features/home/ui/dashboard_content.dart | 125 --- lib/features/home/ui/home_screen.dart | 36 +- .../products/blocs/product_cubit.dart | 29 +- .../products/data/product_repository.dart | 19 +- .../products/models/brand_model.dart | 2 +- .../products/models/model_model.dart | 2 +- .../products/ui/brand_selector.dart | 2 +- .../master_data/products/ui/models_list.dart | 2 +- .../products/ui/product_dialogs.dart | 4 +- .../products/ui/products_screen.dart | 4 +- .../products/ui/quick_product_dialog.dart | 2 +- .../providers/data/provider_repository.dart | 2 +- .../providers/models/provider_model.dart | 119 +-- .../providers/ui/provider_form_sheet.dart | 84 +- .../ui/providers_master_data_screen.dart | 15 +- .../master_data/staff/blocs/staff_cubit.dart | 2 +- .../master_data/staff/blocs/staff_state.dart | 5 + .../master_data/staff/ui/staff_screen.dart | 4 +- .../store/data/store_repository.dart | 2 +- .../master_data/store/models/store_model.dart | 80 +- .../store/ui/create_store_screen.dart | 20 +- .../master_data/store/ui/store_card.dart | 10 +- .../master_data/store/ui/store_form.dart | 20 +- .../onboarding/blocs/onboarding_cubit.dart | 2 +- .../onboarding/ui/store_onboarding_form.dart | 13 +- .../blocs/operation_files_bloc.dart | 389 +++++++++ .../blocs/operation_files_events.dart | 81 ++ .../blocs/operation_files_state.dart | 52 ++ .../operations/blocs/operations_cubit.dart | 304 +++++++ .../blocs/operations_state.dart} | 36 +- .../data/operations_repository.dart | 305 +++++++ .../operations/models/operation_model.dart | 248 ++++++ .../ui/operation_action_card.dart} | 4 +- .../operations/ui/operation_form_screen.dart | 481 +++++++++++ .../ui/operation_mobile_upload_screen.dart} | 33 +- .../ui/operations_screen.dart} | 124 ++- .../ui/widgets/customer_section.dart | 222 +++++ .../ui/widgets/details_section.dart | 423 ++++++++++ .../ui/widgets/operation_files_section.dart | 761 ++++++++++++++++++ .../operations/ui/widgets/staff_section.dart | 133 +++ .../services/blocs/service_files_bloc.dart | 232 ------ .../services/blocs/service_files_events.dart | 56 -- .../services/blocs/service_files_state.dart | 52 -- .../services/blocs/services_cubit.dart | 348 -------- .../services/data/services_repository.dart | 359 --------- .../services/models/energy_service_model.dart | 72 -- .../models/entertainment_service_model.dart | 77 -- .../services/models/fin_service_model.dart | 63 -- .../services/models/service_model.dart | 200 ----- .../ui/service_form_screen/action_card.dart | 85 -- .../attachment_section.dart | 384 --------- .../service_form_screen/customer_section.dart | 96 --- .../energy_service_dialog.dart | 417 ---------- .../entertainment_service_card.dart | 393 --------- .../finance_service_dialog.dart | 479 ----------- .../general_info_section.dart | 111 --- .../ui/service_form_screen/int_dialogs.dart | 158 ---- .../service_form_screen.dart | 182 ----- .../ui/service_form_screen/services_grid.dart | 196 ----- .../services/utils/service_actions.dart | 82 -- lib/l10n/app_it.arb | 91 +++ lib/l10n/app_localizations.dart | 482 +++++++++++ lib/l10n/app_localizations_en.dart | 57 ++ lib/l10n/app_localizations_it.dart | 206 +++++ lib/main.dart | 24 +- macos/Runner/Base.lproj/MainMenu.xib | 4 +- pubspec.lock | 79 +- pubspec.yaml | 8 +- 110 files changed, 5831 insertions(+), 5306 deletions(-) create mode 100644 l10n.yaml create mode 100644 lib/core/utils/app_message.dart rename lib/core/utils/{string_extensions.dart => extensions.dart} (86%) create mode 100644 lib/features/attachments/data/attachments_repository.dart rename lib/features/{services/models/service_file_model.dart => attachments/models/attachment_model.dart} (54%) create mode 100644 lib/features/attachments/ui/attachment_viewer_screen.dart create mode 100644 lib/features/attachments/ui/quick_rename_dialog.dart rename lib/features/customers/blocs/{customer_cubit.dart => customers_cubit.dart} (82%) rename lib/features/customers/blocs/{customer_state.dart => customers_state.dart} (61%) delete mode 100644 lib/features/customers/models/customer_file_model.dart delete mode 100644 lib/features/customers/ui/customer_search_sheet.dart create mode 100644 lib/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart create mode 100644 lib/features/home/latest_store_operations/bloc/latest_store_operations_events.dart create mode 100644 lib/features/home/latest_store_operations/bloc/latest_store_operations_state.dart create mode 100644 lib/features/home/latest_store_operations/ui/latest_store_operations_card.dart delete mode 100644 lib/features/home/ui/dashboard_action_card.dart delete mode 100644 lib/features/home/ui/dashboard_adaptive_grid.dart delete mode 100644 lib/features/home/ui/dashboard_content.dart create mode 100644 lib/features/operations/blocs/operation_files_bloc.dart create mode 100644 lib/features/operations/blocs/operation_files_events.dart create mode 100644 lib/features/operations/blocs/operation_files_state.dart create mode 100644 lib/features/operations/blocs/operations_cubit.dart rename lib/features/{services/blocs/services_state.dart => operations/blocs/operations_state.dart} (58%) create mode 100644 lib/features/operations/data/operations_repository.dart create mode 100644 lib/features/operations/models/operation_model.dart rename lib/features/{services/ui/service_action_card.dart => operations/ui/operation_action_card.dart} (96%) create mode 100644 lib/features/operations/ui/operation_form_screen.dart rename lib/features/{services/ui/service_form_screen/service_mobile_upload_screen.dart => operations/ui/operation_mobile_upload_screen.dart} (92%) rename lib/features/{services/ui/services_screen.dart => operations/ui/operations_screen.dart} (54%) create mode 100644 lib/features/operations/ui/widgets/customer_section.dart create mode 100644 lib/features/operations/ui/widgets/details_section.dart create mode 100644 lib/features/operations/ui/widgets/operation_files_section.dart create mode 100644 lib/features/operations/ui/widgets/staff_section.dart delete mode 100644 lib/features/services/blocs/service_files_bloc.dart delete mode 100644 lib/features/services/blocs/service_files_events.dart delete mode 100644 lib/features/services/blocs/service_files_state.dart delete mode 100644 lib/features/services/blocs/services_cubit.dart delete mode 100644 lib/features/services/data/services_repository.dart delete mode 100644 lib/features/services/models/energy_service_model.dart delete mode 100644 lib/features/services/models/entertainment_service_model.dart delete mode 100644 lib/features/services/models/fin_service_model.dart delete mode 100644 lib/features/services/models/service_model.dart delete mode 100644 lib/features/services/ui/service_form_screen/action_card.dart delete mode 100644 lib/features/services/ui/service_form_screen/attachment_section.dart delete mode 100644 lib/features/services/ui/service_form_screen/customer_section.dart delete mode 100644 lib/features/services/ui/service_form_screen/energy_service_dialog.dart delete mode 100644 lib/features/services/ui/service_form_screen/entertainment_service_card.dart delete mode 100644 lib/features/services/ui/service_form_screen/finance_service_dialog.dart delete mode 100644 lib/features/services/ui/service_form_screen/general_info_section.dart delete mode 100644 lib/features/services/ui/service_form_screen/int_dialogs.dart delete mode 100644 lib/features/services/ui/service_form_screen/service_form_screen.dart delete mode 100644 lib/features/services/ui/service_form_screen/services_grid.dart delete mode 100644 lib/features/services/utils/service_actions.dart create mode 100644 lib/l10n/app_it.arb create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_en.dart create mode 100644 lib/l10n/app_localizations_it.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index f9b3034..d78598d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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 \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e4ef43f..87b6bad 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..aba5d99 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_it.arb +output-localization-file: app_localizations.dart \ No newline at end of file diff --git a/lib/core/data/core_repository.dart b/lib/core/data/core_repository.dart index 192e412..2998a5c 100644 --- a/lib/core/data/core_repository.dart +++ b/lib/core/data/core_repository.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'); } } diff --git a/lib/core/layout/app_shell.dart b/lib/core/layout/app_shell.dart index d5eb3af..dcf2bdd 100644 --- a/lib/core/layout/app_shell.dart +++ b/lib/core/layout/app_shell.dart @@ -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, ), ], ), diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 89c892f..1ac0980 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -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().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().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() + .state + .currentStore! + .id!; + context.read().loadCustomers(); + context.read().loadActiveProvidersForStore( + currentStoreId, + ); + context.read().loadModels(); + context.read().loadBrands(); + context.read().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() + .state + .currentStore! + .id!; + context.read().loadCustomers(); + context.read().loadActiveProvidersForStore( + currentStoreId, + ); + context.read().loadModels(); + context.read().loadBrands(); + context.read().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, ), ); }, diff --git a/lib/core/utils/app_message.dart b/lib/core/utils/app_message.dart new file mode 100644 index 0000000..66ce076 --- /dev/null +++ b/lib/core/utils/app_message.dart @@ -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'; + } + } +} diff --git a/lib/core/utils/string_extensions.dart b/lib/core/utils/extensions.dart similarity index 86% rename from lib/core/utils/string_extensions.dart rename to lib/core/utils/extensions.dart index 060d8df..27b2e53 100644 --- a/lib/core/utils/string_extensions.dart +++ b/lib/core/utils/extensions.dart @@ -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)!; +} diff --git a/lib/core/widgets/image_viewer_widget.dart b/lib/core/widgets/image_viewer_widget.dart index cfb90d0..ef5f1bb 100644 --- a/lib/core/widgets/image_viewer_widget.dart +++ b/lib/core/widgets/image_viewer_widget.dart @@ -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), ); } diff --git a/lib/core/widgets/pdf_viewer_widget.dart b/lib/core/widgets/pdf_viewer_widget.dart index f83196a..6bcb85e 100644 --- a/lib/core/widgets/pdf_viewer_widget.dart +++ b/lib/core/widgets/pdf_viewer_widget.dart @@ -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 { 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), diff --git a/lib/core/widgets/qr_upload_dialog.dart b/lib/core/widgets/qr_upload_dialog.dart index 32ea978..65d37ee 100644 --- a/lib/core/widgets/qr_upload_dialog.dart +++ b/lib/core/widgets/qr_upload_dialog.dart @@ -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, diff --git a/lib/core/widgets/set_password_screen.dart b/lib/core/widgets/set_password_screen.dart index 33e54b9..5f5d72d 100644 --- a/lib/core/widgets/set_password_screen.dart +++ b/lib/core/widgets/set_password_screen.dart @@ -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 { 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 { // 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 { 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 { 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 { ), child: _isLoading ? const CircularProgressIndicator(color: Colors.white) - : const Text( - "SALVA E INIZIA", + : Text( + context.l10n.setPasswordScreenSaveAndStart, style: TextStyle(fontSize: 16), ), ), diff --git a/lib/features/attachments/data/attachments_repository.dart b/lib/features/attachments/data/attachments_repository.dart new file mode 100644 index 0000000..a7760b3 --- /dev/null +++ b/lib/features/attachments/data/attachments_repository.dart @@ -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 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"); + } + } +} diff --git a/lib/features/services/models/service_file_model.dart b/lib/features/attachments/models/attachment_model.dart similarity index 54% rename from lib/features/services/models/service_file_model.dart rename to lib/features/attachments/models/attachment_model.dart index f804166..8e61b3e 100644 --- a/lib/features/services/models/service_file_model.dart +++ b/lib/features/attachments/models/attachment_model.dart @@ -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 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 map) { - return ServiceFileModel( + factory AttachmentModel.fromMap(Map 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 get props => [ - id, - createdAt, - name, - extension, - storagePath, - serviceId, - fileSize, - localBytes, - ]; } diff --git a/lib/features/attachments/ui/attachment_viewer_screen.dart b/lib/features/attachments/ui/attachment_viewer_screen.dart new file mode 100644 index 0000000..b1a9347 --- /dev/null +++ b/lib/features/attachments/ui/attachment_viewer_screen.dart @@ -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 createState() => _AttachmentViewerScreenState(); +} + +class _AttachmentViewerScreenState extends State { + 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 _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!)), + ); + } + } +} diff --git a/lib/features/attachments/ui/quick_rename_dialog.dart b/lib/features/attachments/ui/quick_rename_dialog.dart new file mode 100644 index 0000000..22e1da3 --- /dev/null +++ b/lib/features/attachments/ui/quick_rename_dialog.dart @@ -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 createState() => _QuickRenameDialogState(); +} + +class _QuickRenameDialogState extends State { + 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)'), + ), + ], + ); + } +} diff --git a/lib/features/auth/bloc/auth_cubit.dart b/lib/features/auth/bloc/auth_cubit.dart index 53411e0..b4faf4a 100644 --- a/lib/features/auth/bloc/auth_cubit.dart +++ b/lib/features/auth/bloc/auth_cubit.dart @@ -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 { 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 { emit( state.copyWith( status: AuthStatus.pwResetSent, - infoMessage: "Email per reset password inviata a $email!", + infoMessage: AppMessage( + key: 'authCubitResetPasswordEmailSentTo', + argument: email, + ), ), ); } diff --git a/lib/features/auth/bloc/auth_state.dart b/lib/features/auth/bloc/auth_state.dart index f3c237e..5bb2dcf 100644 --- a/lib/features/auth/bloc/auth_state.dart +++ b/lib/features/auth/bloc/auth_state.dart @@ -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, diff --git a/lib/features/auth/ui/auth_screen.dart b/lib/features/auth/ui/auth_screen.dart index f6327d8..428b6d7 100644 --- a/lib/features/auth/ui/auth_screen.dart +++ b/lib/features/auth/ui/auth_screen.dart @@ -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 { 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 { // --- 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 { 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 { // --- 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 { ), ) : 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 { 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 { .read() .requestPasswordReset(_emailController.text.trim()), child: Text( - 'Pw dimenticata/Invito scaduto?', + context.l10n.authScreenForgotPassword, style: TextStyle( color: context.accent, fontWeight: FontWeight.bold, diff --git a/lib/features/company/bloc/company_state.dart b/lib/features/company/bloc/company_state.dart index 5cef955..1460579 100644 --- a/lib/features/company/bloc/company_state.dart +++ b/lib/features/company/bloc/company_state.dart @@ -22,5 +22,5 @@ class CompanyState extends Equatable { } @override - List get props => [status, errorMessage]; + List get props => [status, errorMessage, company]; } diff --git a/lib/features/company/data/company_repository.dart b/lib/features/company/data/company_repository.dart index 2c70f4f..5a6bb17 100644 --- a/lib/features/company/data/company_repository.dart +++ b/lib/features/company/data/company_repository.dart @@ -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(); } } diff --git a/lib/features/company/models/company_model.dart b/lib/features/company/models/company_model.dart index 4c5c4aa..3e270ab 100644 --- a/lib/features/company/models/company_model.dart +++ b/lib/features/company/models/company_model.dart @@ -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; diff --git a/lib/features/company/ui/create_company_screen.dart b/lib/features/company/ui/create_company_screen.dart index e92e7d2..07ead76 100644 --- a/lib/features/company/ui/create_company_screen.dart +++ b/lib/features/company/ui/create_company_screen.dart @@ -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 { 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 { 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 { 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 { 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 { children: [ Expanded( child: FluxTextField( - label: 'Partita IVA', + label: context.l10n.createCompanyScreenVatId, icon: Icons.numbers, controller: _pIvaController, ), @@ -138,7 +141,7 @@ class _CreateCompanyScreenState extends State { 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 { ), 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 { 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 { 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 { 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 { 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 { 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 { ), 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 { : () => _onSave(), child: state.status == CompanyStatus.loading ? const CircularProgressIndicator() - : const Text('SALVA AZIENDA'), + : Text(context.l10n.createCompanyScreenSaveCompany), ), ); } @@ -282,7 +288,7 @@ class _CreateCompanyScreenState extends State { ), 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 { ), 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, diff --git a/lib/features/customers/blocs/customer_files_bloc.dart b/lib/features/customers/blocs/customer_files_bloc.dart index 0fdffe1..c8662e0 100644 --- a/lib/features/customers/blocs/customer_files_bloc.dart +++ b/lib/features/customers/blocs/customer_files_bloc.dart @@ -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 { LoadCustomerFilesEvent event, Emitter emit, ) async { - await emit.forEach>( + await emit.forEach>( _repository.getCustomerFilesStream(customerId), onData: (customerFiles) => CustomerFilesState( status: CustomerFilesStatus.success, @@ -128,7 +128,7 @@ class CustomerFilesBloc extends Bloc { ToggleCustomerFileSelectionEvent event, Emitter emit, ) { - List selectedFiles = List.from(state.selectedFiles); + List selectedFiles = List.from(state.selectedFiles); if (selectedFiles.contains(event.file)) { selectedFiles.remove(event.file); } else { diff --git a/lib/features/customers/blocs/customer_files_events.dart b/lib/features/customers/blocs/customer_files_events.dart index ad235d1..b893ce8 100644 --- a/lib/features/customers/blocs/customer_files_events.dart +++ b/lib/features/customers/blocs/customer_files_events.dart @@ -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); } diff --git a/lib/features/customers/blocs/customer_files_state.dart b/lib/features/customers/blocs/customer_files_state.dart index 88fbe5b..bdb525d 100644 --- a/lib/features/customers/blocs/customer_files_state.dart +++ b/lib/features/customers/blocs/customer_files_state.dart @@ -12,8 +12,8 @@ class CustomerFilesState extends Equatable { final CustomerFilesStatus status; final String? error; - final List customerFiles; - final List selectedFiles; + final List customerFiles; + final List selectedFiles; @override List get props => [status, error, customerFiles, selectedFiles]; @@ -21,8 +21,8 @@ class CustomerFilesState extends Equatable { CustomerFilesState copyWith({ CustomerFilesStatus? status, String? error, - List? customerFiles, - List? selectedFiles, + List? customerFiles, + List? selectedFiles, }) { return CustomerFilesState( status: status ?? this.status, diff --git a/lib/features/customers/blocs/customer_cubit.dart b/lib/features/customers/blocs/customers_cubit.dart similarity index 82% rename from lib/features/customers/blocs/customer_cubit.dart rename to lib/features/customers/blocs/customers_cubit.dart index d2d3f7c..44e58d5 100644 --- a/lib/features/customers/blocs/customer_cubit.dart +++ b/lib/features/customers/blocs/customers_cubit.dart @@ -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 { +class CustomersCubit extends Cubit { final CustomerRepository _repository = GetIt.I(); final SessionCubit _sessionCubit = GetIt.I(); // Variabile per gestire il debounce della ricerca Timer? _searchDebounce; - CustomerCubit() : super(const CustomerState()); + CustomersCubit() : super(const CustomersState()); // --- LETTURA --- Future 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 { // --- CREAZIONE --- Future 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 { emit( state.copyWith( - status: CustomerStatus.success, + status: CustomersStatus.success, customers: updatedList, lastCreatedCustomer: newCustomer, ), @@ -58,7 +57,7 @@ class CustomerCubit extends Cubit { } catch (e) { emit( state.copyWith( - status: CustomerStatus.failure, + status: CustomersStatus.failure, errorMessage: e.toString(), ), ); @@ -67,7 +66,7 @@ class CustomerCubit extends Cubit { // --- AGGIORNAMENTO --- Future 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 { 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 { } catch (e) { emit( state.copyWith( - status: CustomerStatus.failure, + status: CustomersStatus.failure, errorMessage: e.toString(), ), ); @@ -116,12 +115,12 @@ class CustomerCubit extends Cubit { 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 { String? email, }) async { final newCustomer = CustomerModel( - nome: name, - telefono: phone ?? '', + name: name, + phoneNumber: phone ?? '', email: email ?? '', companyId: _sessionCubit.state.company!.id!, note: '', diff --git a/lib/features/customers/blocs/customer_state.dart b/lib/features/customers/blocs/customers_state.dart similarity index 61% rename from lib/features/customers/blocs/customer_state.dart rename to lib/features/customers/blocs/customers_state.dart index 9e26c69..453aaf7 100644 --- a/lib/features/customers/blocs/customer_state.dart +++ b/lib/features/customers/blocs/customers_state.dart @@ -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 customers; final CustomerModel? lastCreatedCustomer; final String? errorMessage; - final List 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? customers, CustomerModel? lastCreatedCustomer, String? errorMessage, - List? 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, ]; } diff --git a/lib/features/customers/data/customer_repository.dart b/lib/features/customers/data/customer_repository.dart index d2a8f7a..43c31ff 100644 --- a/lib/features/customers/data/customer_repository.dart +++ b/lib/features/customers/data/customer_repository.dart @@ -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> getCustomerFilesStream(String customerId) { + Stream> 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> getCustomerFiles(String customerId) async { + Future> 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 uploadAndRegisterFile({ + Future 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 saveFileReference(CustomerFileModel file) async { - await _supabase.from('customer_file').upsert(file.toMap()); + Future saveFileReference(AttachmentModel file) async { + await _supabase.from('attachment').upsert(file.toMap()); } - /// Aggiorna la lista degli URL nel database - Future updateCustomerDocuments(int id, List urls) async { - await _supabase - .from('customer') - .update({'document_urls': urls}) - .eq('id', id); - } - - Future deleteDocuments(List files) async { + Future deleteDocuments(List files) async { if (files.isEmpty) return; - // 1. Prepariamo le liste di ID e di Percorsi - final List idsToDelete = files.map((f) => f.id!).toList(); - final List storagePaths = files.map((f) => f.storagePath).toList(); - + final List idsToDelete = []; + final List storagePathsToDelete = []; + final List 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'; } } } diff --git a/lib/features/customers/models/customer_file_model.dart b/lib/features/customers/models/customer_file_model.dart deleted file mode 100644 index 00d1e92..0000000 --- a/lib/features/customers/models/customer_file_model.dart +++ /dev/null @@ -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 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 toMap() { - return { - if (id != null) 'id': id, - 'customer_id': customerId, - 'name': name, - 'storage_path': storagePath, - 'extension': extension, - 'file_size': fileSize, - }; - } - - @override - List get props => [ - id, - customerId, - name, - storagePath, - extension, - createdAt, - fileSize, - ]; -} diff --git a/lib/features/customers/models/customer_model.dart b/lib/features/customers/models/customer_model.dart index fa55090..db92020 100644 --- a/lib/features/customers/models/customer_model.dart +++ b/lib/features/customers/models/customer_model.dart @@ -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 files; + final List 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 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? files, + List? 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 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, }; diff --git a/lib/features/customers/ui/customer_detail_screen.dart b/lib/features/customers/ui/customer_detail_screen.dart index 53224b1..b6e64da 100644 --- a/lib/features/customers/ui/customer_detail_screen.dart +++ b/lib/features/customers/ui/customer_detail_screen.dart @@ -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 { 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 { 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 { : 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 { 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 { void _showDeleteConfirmationDialog({ required BuildContext context, - required List files, + required List 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, diff --git a/lib/features/customers/ui/customer_form.dart b/lib/features/customers/ui/customer_form.dart index 6de9695..c097b16 100644 --- a/lib/features/customers/ui/customer_form.dart +++ b/lib/features/customers/ui/customer_form.dart @@ -30,15 +30,15 @@ class _CustomerFormState extends State { 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 { // 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 ); diff --git a/lib/features/customers/ui/customer_search_sheet.dart b/lib/features/customers/ui/customer_search_sheet.dart deleted file mode 100644 index 4cc3ca9..0000000 --- a/lib/features/customers/ui/customer_search_sheet.dart +++ /dev/null @@ -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 createState() => _CustomerSearchSheetState(); -} - -class _CustomerSearchSheetState extends State { - final TextEditingController _searchController = TextEditingController(); - - @override - void initState() { - super.initState(); - context.read().loadCustomers(); - } - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - void _onSearchChanged(String query) { - context.read().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(); - // 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( - 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().updateField( - customerId: customer.id, - customerDisplayName: displayName, - ); - - // Chiudiamo la modale - Navigator.pop(context); - }, - ); - }, - ); - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/customers/ui/customers_content.dart b/lib/features/customers/ui/customers_content.dart index 46c54cd..c52a9a9 100644 --- a/lib/features/customers/ui/customers_content.dart +++ b/lib/features/customers/ui/customers_content.dart @@ -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 { void _loadInitialCustomers() { final companyId = context.read().state.company?.id; if (companyId != null) { - context.read().loadCustomers(); + context.read().loadCustomers(); } } void _onSearch(String query) { final companyId = context.read().state.company?.id; if (companyId != null) { - context.read().searchCustomers(query); + context.read().searchCustomers(query); } } @@ -86,9 +86,9 @@ class _CustomersContentState extends State { // LISTA CLIENTI Expanded( - child: BlocBuilder( + child: BlocBuilder( 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().createCustomer( + context.read().createCustomer( customerFromForm.copyWith(companyId: companyId), ); } else { // CASO MODIFICA: L'ID e il companyId sono già nel modello - context.read().updateCustomer(customerFromForm); + context.read().updateCustomer(customerFromForm); } Navigator.pop(dialogContext); }, diff --git a/lib/features/customers/ui/quick_customer_dialog.dart b/lib/features/customers/ui/quick_customer_dialog.dart index 3082491..2137dee 100644 --- a/lib/features/customers/ui/quick_customer_dialog.dart +++ b/lib/features/customers/ui/quick_customer_dialog.dart @@ -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 { setState(() => _isLoading = true); // Chiamata al Cubit (aggiorna i parametri in base a come li hai definiti) - final newCustomer = await context.read().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() + .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); diff --git a/lib/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart b/lib/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart new file mode 100644 index 0000000..5d52d3d --- /dev/null +++ b/lib/features/home/latest_store_operations/bloc/latest_store_operations_bloc.dart @@ -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 { + final _repository = GetIt.I.get(); + + LatestStoreOperationsBloc() + : super( + const LatestStoreOperationsState( + status: LatestStoreOperationsStatus.initial, + ), + ) { + on((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 rawOperations) async { + // Questo gira ad ogni "scatto" dello stream di Supabase + List 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 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(), + ), + ); + } + }); + } +} diff --git a/lib/features/home/latest_store_operations/bloc/latest_store_operations_events.dart b/lib/features/home/latest_store_operations/bloc/latest_store_operations_events.dart new file mode 100644 index 0000000..c15c0f8 --- /dev/null +++ b/lib/features/home/latest_store_operations/bloc/latest_store_operations_events.dart @@ -0,0 +1,17 @@ +part of 'latest_store_operations_bloc.dart'; + +sealed class LatestStoreOperationsEvent extends Equatable { + const LatestStoreOperationsEvent(); + + @override + List get props => []; +} + +class InitLastStoreOperationsEvent extends LatestStoreOperationsEvent { + final String storeId; + + const InitLastStoreOperationsEvent(this.storeId); + + @override + List get props => [storeId]; +} diff --git a/lib/features/home/latest_store_operations/bloc/latest_store_operations_state.dart b/lib/features/home/latest_store_operations/bloc/latest_store_operations_state.dart new file mode 100644 index 0000000..d373848 --- /dev/null +++ b/lib/features/home/latest_store_operations/bloc/latest_store_operations_state.dart @@ -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 operations; + + const LatestStoreOperationsState({ + required this.status, + this.error, + this.operations = const [], + }); + + @override + List get props => [status, error, operations]; + + LatestStoreOperationsState copyWith({ + LatestStoreOperationsStatus? status, + String? error, + List? operations, + }) { + return LatestStoreOperationsState( + status: status ?? this.status, + error: error, + operations: operations ?? this.operations, + ); + } +} diff --git a/lib/features/home/latest_store_operations/ui/latest_store_operations_card.dart b/lib/features/home/latest_store_operations/ui/latest_store_operations_card.dart new file mode 100644 index 0000000..1380b0a --- /dev/null +++ b/lib/features/home/latest_store_operations/ui/latest_store_operations_card.dart @@ -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().state.currentStore?.id; + + return BlocProvider( + // 1. Creiamo il Bloc e facciamo partire subito la query + create: (context) => + LatestStoreOperationsBloc() + ..add(InitLastStoreOperationsEvent(currentStoreId ?? '')), + child: BlocListener( + // 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().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, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/ui/dashboard_action_card.dart b/lib/features/home/ui/dashboard_action_card.dart deleted file mode 100644 index 46446c2..0000000 --- a/lib/features/home/ui/dashboard_action_card.dart +++ /dev/null @@ -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)), - ], - ), - ), - ); - } -} diff --git a/lib/features/home/ui/dashboard_adaptive_grid.dart b/lib/features/home/ui/dashboard_adaptive_grid.dart deleted file mode 100644 index 191291d..0000000 --- a/lib/features/home/ui/dashboard_adaptive_grid.dart +++ /dev/null @@ -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: () {}, - ), - ], - ); - }, - ); - } -} diff --git a/lib/features/home/ui/dashboard_content.dart b/lib/features/home/ui/dashboard_content.dart deleted file mode 100644 index d32b204..0000000 --- a/lib/features/home/ui/dashboard_content.dart +++ /dev/null @@ -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( - 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, - ), - ); -} diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 9f9abac..40bef0a 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -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 diff --git a/lib/features/master_data/products/blocs/product_cubit.dart b/lib/features/master_data/products/blocs/product_cubit.dart index b35a6bc..f35c0c1 100644 --- a/lib/features/master_data/products/blocs/product_cubit.dart +++ b/lib/features/master_data/products/blocs/product_cubit.dart @@ -9,19 +9,17 @@ import 'package:get_it/get_it.dart'; part 'product_state.dart'; -class ProductCubit extends Cubit { +class ProductsCubit extends Cubit { final ProductRepository _repository = GetIt.I(); final SessionCubit _sessionCubit = GetIt.I(); - ProductCubit() : super(const ProductState()); + ProductsCubit() : super(const ProductState()); // Caricamento iniziale dei Brand Future 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 { } } + Future 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 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 selectBrand(BrandModel? brand) async { if (brand == null) { diff --git a/lib/features/master_data/products/data/product_repository.dart b/lib/features/master_data/products/data/product_repository.dart index d456ae3..1fdbe68 100644 --- a/lib/features/master_data/products/data/product_repository.dart +++ b/lib/features/master_data/products/data/product_repository.dart @@ -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(); + final String _companyId = GetIt.I().state.company!.id!; // --- BRAND --- /// Recupera tutti i brand dell'azienda - Future> getBrands(String companyId) async { + Future> 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> 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 upsertModel(ModelModel model) async { diff --git a/lib/features/master_data/products/models/brand_model.dart b/lib/features/master_data/products/models/brand_model.dart index 72e02b1..f6c33c6 100644 --- a/lib/features/master_data/products/models/brand_model.dart +++ b/lib/features/master_data/products/models/brand_model.dart @@ -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; diff --git a/lib/features/master_data/products/models/model_model.dart b/lib/features/master_data/products/models/model_model.dart index 4859356..aff7e36 100644 --- a/lib/features/master_data/products/models/model_model.dart +++ b/lib/features/master_data/products/models/model_model.dart @@ -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; diff --git a/lib/features/master_data/products/ui/brand_selector.dart b/lib/features/master_data/products/ui/brand_selector.dart index 857bddc..2e35522 100644 --- a/lib/features/master_data/products/ui/brand_selector.dart +++ b/lib/features/master_data/products/ui/brand_selector.dart @@ -33,7 +33,7 @@ class BrandSelector extends StatelessWidget { return DropdownMenuItem(value: brand, child: Text(brand.name)); }).toList(), onChanged: (brand) => - context.read().selectBrand(brand), + context.read().selectBrand(brand), ), ), const SizedBox(width: 16), diff --git a/lib/features/master_data/products/ui/models_list.dart b/lib/features/master_data/products/ui/models_list.dart index f44e6a5..e9dd5c6 100644 --- a/lib/features/master_data/products/ui/models_list.dart +++ b/lib/features/master_data/products/ui/models_list.dart @@ -64,7 +64,7 @@ class ModelsList extends StatelessWidget { color: model.isActive ? context.accent : Colors.grey, ), onPressed: () => context - .read() + .read() .toggleStatus('model', model.id!, model.isActive), ), ], diff --git a/lib/features/master_data/products/ui/product_dialogs.dart b/lib/features/master_data/products/ui/product_dialogs.dart index 3560d0c..3fe457a 100644 --- a/lib/features/master_data/products/ui/product_dialogs.dart +++ b/lib/features/master_data/products/ui/product_dialogs.dart @@ -40,7 +40,7 @@ void _submitBrand( BrandModel? brand, ) { if (controller.text.trim().isNotEmpty) { - context.read().saveBrand(controller.text, id: brand?.id); + context.read().saveBrand(controller.text, id: brand?.id); Navigator.pop(context); } } @@ -81,7 +81,7 @@ void _submitModel( ModelModel? model, ) { if (controller.text.isNotEmpty) { - context.read().saveModel(controller.text, id: model?.id); + context.read().saveModel(controller.text, id: model?.id); Navigator.pop(context); } } diff --git a/lib/features/master_data/products/ui/products_screen.dart b/lib/features/master_data/products/ui/products_screen.dart index 28ffeab..8602876 100644 --- a/lib/features/master_data/products/ui/products_screen.dart +++ b/lib/features/master_data/products/ui/products_screen.dart @@ -12,7 +12,7 @@ class ProductsScreen extends StatelessWidget { @override Widget build(BuildContext context) { // Carichiamo i brand appena la pagina viene creata - context.read().loadBrands(); + context.read().loadBrands(); return Scaffold( backgroundColor: context.background, @@ -33,7 +33,7 @@ class ProductsScreen extends StatelessWidget { ), ), ), - body: BlocConsumer( + body: BlocConsumer( listener: (context, state) { if (state.status == ProductStatus.error) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/features/master_data/products/ui/quick_product_dialog.dart b/lib/features/master_data/products/ui/quick_product_dialog.dart index aa92bae..d3d1c61 100644 --- a/lib/features/master_data/products/ui/quick_product_dialog.dart +++ b/lib/features/master_data/products/ui/quick_product_dialog.dart @@ -23,7 +23,7 @@ class _QuickProductDialogState extends State { setState(() => _isLoading = true); - final newModel = await context.read().quickCreateProduct( + final newModel = await context.read().quickCreateProduct( brandName: _selectedBrandName.trim(), modelName: _modelCtrl.text.trim(), ); diff --git a/lib/features/master_data/providers/data/provider_repository.dart b/lib/features/master_data/providers/data/provider_repository.dart index ef5e271..54b377d 100644 --- a/lib/features/master_data/providers/data/provider_repository.dart +++ b/lib/features/master_data/providers/data/provider_repository.dart @@ -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) { diff --git a/lib/features/master_data/providers/models/provider_model.dart b/lib/features/master_data/providers/models/provider_model.dart index a4a9ede..5027f30 100644 --- a/lib/features/master_data/providers/models/provider_model.dart +++ b/lib/features/master_data/providers/models/provider_model.dart @@ -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 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 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 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? 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, diff --git a/lib/features/master_data/providers/ui/provider_form_sheet.dart b/lib/features/master_data/providers/ui/provider_form_sheet.dart index 2afc48d..0fead8a 100644 --- a/lib/features/master_data/providers/ui/provider_form_sheet.dart +++ b/lib/features/master_data/providers/ui/provider_form_sheet.dart @@ -15,13 +15,14 @@ class ProviderFormSheet extends StatefulWidget { class _ProviderFormSheetState extends State { 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 _tempSelectedStoreIds = []; // Per gestire la selezione temporanea dei negozi @@ -33,14 +34,15 @@ class _ProviderFormSheetState extends State { 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 { final cubit = context.read(); 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 { ), _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 { store.id, ); return CheckboxListTile( - title: Text(store.nome), + title: Text(store.name), value: isAssociated, onChanged: (val) { setState(() { diff --git a/lib/features/master_data/providers/ui/providers_master_data_screen.dart b/lib/features/master_data/providers/ui/providers_master_data_screen.dart index bc69fe0..465f4da 100644 --- a/lib/features/master_data/providers/ui/providers_master_data_screen.dart +++ b/lib/features/master_data/providers/ui/providers_master_data_screen.dart @@ -93,7 +93,7 @@ class _ProvidersMasterDataScreenState extends State { ), ), title: Text( - provider.nome, + provider.name, style: const TextStyle(fontWeight: FontWeight.bold), ), subtitle: _buildCardSubtitle( @@ -141,12 +141,13 @@ class _ProvidersMasterDataScreenState extends State { 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), ], ); } diff --git a/lib/features/master_data/staff/blocs/staff_cubit.dart b/lib/features/master_data/staff/blocs/staff_cubit.dart index 8cd77f5..d231167 100644 --- a/lib/features/master_data/staff/blocs/staff_cubit.dart +++ b/lib/features/master_data/staff/blocs/staff_cubit.dart @@ -56,7 +56,7 @@ class StaffCubit extends Cubit { 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())); } diff --git a/lib/features/master_data/staff/blocs/staff_state.dart b/lib/features/master_data/staff/blocs/staff_state.dart index 2a3b7d4..f3d0723 100644 --- a/lib/features/master_data/staff/blocs/staff_state.dart +++ b/lib/features/master_data/staff/blocs/staff_state.dart @@ -7,6 +7,7 @@ class StaffState extends Equatable { final List allStaff; final Map> storesByStaff; final Map> staffByStore; + final List 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? allStaff, Map>? storesByStaff, Map>? staffByStore, + List? 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, ]; } diff --git a/lib/features/master_data/staff/ui/staff_screen.dart b/lib/features/master_data/staff/ui/staff_screen.dart index 1213672..ccf77ee 100644 --- a/lib/features/master_data/staff/ui/staff_screen.dart +++ b/lib/features/master_data/staff/ui/staff_screen.dart @@ -126,7 +126,7 @@ class _StaffScreenState extends State { 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 { store.id, ); return FilterChip( - label: Text(store.nome), + label: Text(store.name), selected: isSelected, onSelected: (selected) { setModalState(() { diff --git a/lib/features/master_data/store/data/store_repository.dart b/lib/features/master_data/store/data/store_repository.dart index 01e52c1..0d64d91 100644 --- a/lib/features/master_data/store/data/store_repository.dart +++ b/lib/features/master_data/store/data/store_repository.dart @@ -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) { diff --git a/lib/features/master_data/store/models/store_model.dart b/lib/features/master_data/store/models/store_model.dart index f30e0c1..988d256 100644 --- a/lib/features/master_data/store/models/store_model.dart +++ b/lib/features/master_data/store/models/store_model.dart @@ -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 associatedProviders; // Provider associati final List 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 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? associatedProviders, List? 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 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, }; } } diff --git a/lib/features/master_data/store/ui/create_store_screen.dart b/lib/features/master_data/store/ui/create_store_screen.dart index 7ea5786..918ecc7 100644 --- a/lib/features/master_data/store/ui/create_store_screen.dart +++ b/lib/features/master_data/store/ui/create_store_screen.dart @@ -37,14 +37,14 @@ class _CreateStoreScreenState extends State { final company = context.read().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 { } 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().createStore(store); diff --git a/lib/features/master_data/store/ui/store_card.dart b/lib/features/master_data/store/ui/store_card.dart index 910c3c3..1c2d8b4 100644 --- a/lib/features/master_data/store/ui/store_card.dart +++ b/lib/features/master_data/store/ui/store_card.dart @@ -53,11 +53,11 @@ class _StoreCardState extends State { 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 { 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 { 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) { diff --git a/lib/features/master_data/store/ui/store_form.dart b/lib/features/master_data/store/ui/store_form.dart index 1795169..131009f 100644 --- a/lib/features/master_data/store/ui/store_form.dart +++ b/lib/features/master_data/store/ui/store_form.dart @@ -24,11 +24,11 @@ class _StoreFormState extends State { 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 { 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() .state diff --git a/lib/features/onboarding/blocs/onboarding_cubit.dart b/lib/features/onboarding/blocs/onboarding_cubit.dart index d00cb33..e6a4df8 100644 --- a/lib/features/onboarding/blocs/onboarding_cubit.dart +++ b/lib/features/onboarding/blocs/onboarding_cubit.dart @@ -25,7 +25,7 @@ class OnboardingCubit extends Cubit { Future saveCompany(String companyName) async { emit(state.copyWith(isLoading: true)); final company = CompanyModel.empty().copyWith( - ragioneSociale: companyName, + name: companyName, userId: GetIt.I().auth.currentUser!.id, subscriptionTier: SubscriptionTier.pro, subscriptionStatus: SubscriptionStatus.trialing, diff --git a/lib/features/onboarding/ui/store_onboarding_form.dart b/lib/features/onboarding/ui/store_onboarding_form.dart index 0f26732..e30fadc 100644 --- a/lib/features/onboarding/ui/store_onboarding_form.dart +++ b/lib/features/onboarding/ui/store_onboarding_form.dart @@ -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 { 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().saveStore(newStore); } diff --git a/lib/features/operations/blocs/operation_files_bloc.dart b/lib/features/operations/blocs/operation_files_bloc.dart new file mode 100644 index 0000000..7a48387 --- /dev/null +++ b/lib/features/operations/blocs/operation_files_bloc.dart @@ -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 { + final _repository = GetIt.I.get(); + final String? operationId; + + OperationFilesBloc({this.operationId}) + : super( + OperationFilesState( + status: OperationFilesStatus.initial, + operationId: operationId, + ), + ) { + on(_onOperationsaved); + on(_onLoadOperationFiles); + on(_onAddOperationFiles); + on(_onUploadOperationFiles); + on(_onDeleteOperationFiles); + on(_onToggleOperationFileSelection); + on(_onLinkFilesToCustomer); + on(_onRenameOperationFile); + on(_onDeleteSpecificOperationFiles); + on(_onSelectAllOperationFiles); + on(_onClearOperationFileSelection); + + // Se il BLoC nasce con un ID, accendiamo subito lo stream! + if (operationId != null) { + add(LoadOperationFilesEvent(operationId: operationId)); + } + } + + FutureOr _onOperationsaved( + OperationsavedEvent event, + Emitter 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> 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 _onLoadOperationFiles( + LoadOperationFilesEvent event, + Emitter 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 data) => state.copyWith( + status: OperationFilesStatus.success, + remoteFiles: data, + ), + onError: (error, stackTrace) => state.copyWith( + status: OperationFilesStatus.failure, + error: error.toString(), + ), + ); + } + } + + void _onAddOperationFiles( + AddOperationFilesEvent event, + Emitter emit, + ) async { + final currentId = state.operationId; + + // BIVIO 1: PRATICA NUOVA (Nessun ID - salvataggio locale) + if (currentId == null) { + final companyId = GetIt.I.get().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> 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 _onUploadOperationFiles( + UploadOperationFilesEvent event, + Emitter 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> 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 _onDeleteOperationFiles( + DeleteOperationFilesEvent event, + Emitter 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 _onToggleOperationFileSelection( + ToggleOperationFileSelectionEvent event, + Emitter emit, + ) { + final selectedFiles = List.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 emit, + ) { + // Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati + emit(state.copyWith(selectedFiles: state.allFiles)); + } + + void _onClearOperationFileSelection( + ClearOperationFileSelectionEvent event, + Emitter emit, + ) { + // Svuotiamo brutalmente la lista + emit(state.copyWith(selectedFiles: [])); + } + + FutureOr _onLinkFilesToCustomer( + LinkFilesToCustomerEvent event, + Emitter 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> 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 _onRenameOperationFile( + RenameOperationFileEvent event, + Emitter 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 _onDeleteSpecificOperationFiles( + DeleteSpecificOperationFileEvent event, + Emitter emit, + ) { + if (event.file.localBytes != null) { + final updatedLocalFiles = state.localFiles + .where((f) => f != event.file) + .toList(); + emit(state.copyWith(localFiles: updatedLocalFiles)); + } + } +} diff --git a/lib/features/operations/blocs/operation_files_events.dart b/lib/features/operations/blocs/operation_files_events.dart new file mode 100644 index 0000000..f80dfce --- /dev/null +++ b/lib/features/operations/blocs/operation_files_events.dart @@ -0,0 +1,81 @@ +part of 'operation_files_bloc.dart'; + +abstract class OperationFilesEvent extends Equatable { + const OperationFilesEvent(); + + @override + List get props => []; +} + +class OperationsavedEvent extends OperationFilesEvent { + final String operationId; + const OperationsavedEvent(this.operationId); + + @override + List get props => [operationId]; +} + +class LoadOperationFilesEvent extends OperationFilesEvent { + final String? operationId; + final AttachmentModel? operation; + const LoadOperationFilesEvent({this.operationId, this.operation}); + + @override + List get props => [operationId, operation]; +} + +class AddOperationFilesEvent extends OperationFilesEvent { + final List files; + const AddOperationFilesEvent(this.files); + + @override + List get props => [files]; +} + +class UploadOperationFilesEvent extends OperationFilesEvent { + final List? pickedFiles; + final List? photos; + const UploadOperationFilesEvent({this.pickedFiles, this.photos}); + + @override + List get props => [pickedFiles, photos]; +} + +class LinkFilesToCustomerEvent extends OperationFilesEvent { + final String customerId; + + const LinkFilesToCustomerEvent({required this.customerId}); + + @override + List 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 get props => [file, newName]; +} + +class DeleteSpecificOperationFileEvent extends OperationFilesEvent { + final AttachmentModel file; + + const DeleteSpecificOperationFileEvent(this.file); + + @override + List get props => [file]; +} + +class SelectAllOperationFilesEvent extends OperationFilesEvent {} + +class ClearOperationFileSelectionEvent extends OperationFilesEvent {} diff --git a/lib/features/operations/blocs/operation_files_state.dart b/lib/features/operations/blocs/operation_files_state.dart new file mode 100644 index 0000000..a102dd4 --- /dev/null +++ b/lib/features/operations/blocs/operation_files_state.dart @@ -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 localFiles; + final List remoteFiles; + + final List selectedFiles; + + @override + List get props => [ + operationId, + status, + error, + localFiles, + remoteFiles, + selectedFiles, + ]; + + List get allFiles => [...remoteFiles, ...localFiles]; + + OperationFilesState copyWith({ + String? operationId, + OperationFilesStatus? status, + String? error, + List? localFiles, + List? remoteFiles, + List? 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, + ); + } +} diff --git a/lib/features/operations/blocs/operations_cubit.dart b/lib/features/operations/blocs/operations_cubit.dart new file mode 100644 index 0000000..da3df30 --- /dev/null +++ b/lib/features/operations/blocs/operations_cubit.dart @@ -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 { + final OperationsRepository _repository = GetIt.I(); + final SessionCubit _sessionCubit = GetIt.I(); + final Uuid _uuid = const Uuid(); // Generatore di UUID per il batch + + OperationsCubit() + : super(const OperationsState(status: OperationsStatus.initial)); + + // --- CARICAMENTO E PAGINAZIONE --- + + Future 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 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 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, + ); + } +} diff --git a/lib/features/services/blocs/services_state.dart b/lib/features/operations/blocs/operations_state.dart similarity index 58% rename from lib/features/services/blocs/services_state.dart rename to lib/features/operations/blocs/operations_state.dart index 9d5a15a..97276ad 100644 --- a/lib/features/services/blocs/services_state.dart +++ b/lib/features/operations/blocs/operations_state.dart @@ -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 allServices; - final ServiceModel? currentService; // La bozza che stiamo editando +class OperationsState extends Equatable { + final OperationsStatus status; + final List 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? allServices, - ServiceModel? currentService, + OperationsState copyWith({ + OperationsStatus? status, + List? 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 get props => [ status, - allServices, - currentService, + allOperations, + currentOperation, errorMessage, query, dateRange, diff --git a/lib/features/operations/data/operations_repository.dart b/lib/features/operations/data/operations_repository.dart new file mode 100644 index 0000000..f0cd46a --- /dev/null +++ b/lib/features/operations/data/operations_repository.dart @@ -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().state.company!.id; + + // --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO --- + Future 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> 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> 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 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 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> 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 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> 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 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().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 copyFileToCustomer({ + required AttachmentModel file, + required String customerId, + }) async { + await _supabase + .from('attachment') + .update({'customer_id': customerId}) + .eq('id', file.id!); + } + + Future renameAttachment(String id, String newName) async { + try { + await _supabase.from('attachment').update({'name': newName}).eq('id', id); + } catch (e) { + throw '$e'; + } + } + + Future 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 deleteOperationFiles(List files) async { + if (files.isEmpty) return; + // 1. Prepariamo le liste di ID e di Percorsi + final List idsToDelete = []; + final List idsToEdit = []; + final List 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'; + } + } +} diff --git a/lib/features/operations/models/operation_model.dart b/lib/features/operations/models/operation_model.dart new file mode 100644 index 0000000..57efa90 --- /dev/null +++ b/lib/features/operations/models/operation_model.dart @@ -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 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? 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 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 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 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, + }; + } +} diff --git a/lib/features/services/ui/service_action_card.dart b/lib/features/operations/ui/operation_action_card.dart similarity index 96% rename from lib/features/services/ui/service_action_card.dart rename to lib/features/operations/ui/operation_action_card.dart index ef06dc7..c54c2e8 100644 --- a/lib/features/services/ui/service_action_card.dart +++ b/lib/features/operations/ui/operation_action_card.dart @@ -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, diff --git a/lib/features/operations/ui/operation_form_screen.dart b/lib/features/operations/ui/operation_form_screen.dart new file mode 100644 index 0000000..40f7193 --- /dev/null +++ b/lib/features/operations/ui/operation_form_screen.dart @@ -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 createState() => _OperationFormScreenState(); +} + +class _OperationFormScreenState extends State { + final _formKey = GlobalKey(); + + final _referenceController = TextEditingController(); + final _noteController = TextEditingController(); + final _freeTextSubtypeController = TextEditingController(); + final _freeTextDescriptionController = TextEditingController(); + + final List _availableTypes = [ + 'AL', + 'MNP', + 'NIP', + 'UNICA', + 'TELEPASS', + 'Energy', + 'Fin', + 'Entertainment', + 'Custom', + ]; + + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + final cubit = context.read(); + final currentLoggedStaff = GetIt.I + .get() + .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(); + 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( + 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().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().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().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().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().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), + ), + ); + } +} diff --git a/lib/features/services/ui/service_form_screen/service_mobile_upload_screen.dart b/lib/features/operations/ui/operation_mobile_upload_screen.dart similarity index 92% rename from lib/features/services/ui/service_form_screen/service_mobile_upload_screen.dart rename to lib/features/operations/ui/operation_mobile_upload_screen.dart index 08e306a..730efd0 100644 --- a/lib/features/services/ui/service_form_screen/service_mobile_upload_screen.dart +++ b/lib/features/operations/ui/operation_mobile_upload_screen.dart @@ -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 createState() => - _ServiceMobileUploadScreenState(); + State createState() => + _OperationMobileUploadScreenState(); } -class _ServiceMobileUploadScreenState extends State { +class _OperationMobileUploadScreenState + extends State { // 1. LA NOSTRA STAGING AREA (Il "Carrello") final List _stagedFiles = []; @@ -35,10 +36,10 @@ class _ServiceMobileUploadScreenState extends State { @override Widget build(BuildContext context) { - return BlocListener( + return BlocListener( 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 { ); 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 { }, 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 { // 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(); - bloc.add(UploadMultipleServiceFilesEvent(_stagedFiles)); + final bloc = context.read(); + bloc.add(UploadOperationFilesEvent(pickedFiles: _stagedFiles)); // N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! } diff --git a/lib/features/services/ui/services_screen.dart b/lib/features/operations/ui/operations_screen.dart similarity index 54% rename from lib/features/services/ui/services_screen.dart rename to lib/features/operations/ui/operations_screen.dart index dfb53f9..d2d5eed 100644 --- a/lib/features/services/ui/services_screen.dart +++ b/lib/features/operations/ui/operations_screen.dart @@ -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 createState() => _ServicesScreenState(); + State createState() => _OperationsScreenState(); } -class _ServicesScreenState extends State { +class _OperationsScreenState extends State { final ScrollController _scrollController = ScrollController(); @override @@ -22,12 +21,12 @@ class _ServicesScreenState extends State { // Agganciamo il listener per la paginazione (Scroll Infinito) _scrollController.addListener(_onScroll); // Carichiamo i servizi iniziali - context.read().loadServices(); + context.read().loadOperations(); } void _onScroll() { if (_isBottom) { - context.read().loadServices(); + context.read().loadOperations(); } } @@ -60,16 +59,16 @@ class _ServicesScreenState extends State { ), ], ), - body: BlocBuilder( + body: BlocBuilder( 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 { const Text("Nessuna pratica trovata."), const SizedBox(height: 10), ElevatedButton( - onPressed: () => context.read().loadServices( - refresh: true, - ), + onPressed: () => context + .read() + .loadOperations(refresh: true), child: const Text("Riprova"), ), ], @@ -90,15 +89,15 @@ class _ServicesScreenState extends State { // 3. La Lista (con Pull-to-refresh) return RefreshIndicator( onRefresh: () => - context.read().loadServices(refresh: true), + context.read().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 { ); } - 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 { 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 { 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'); + } } diff --git a/lib/features/operations/ui/widgets/customer_section.dart b/lib/features/operations/ui/widgets/customer_section.dart new file mode 100644 index 0000000..88ec939 --- /dev/null +++ b/lib/features/operations/ui/widgets/customer_section.dart @@ -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().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(); + + // APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER + final newCustomer = await showDialog( + context: context, + builder: (dialogContext) { + return BlocProvider.value( + value: context.read(), + 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( + 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() + .updateOperationFields( + customerId: customer.id, // customer.id + customerDisplayName: + customer.name, // customer.name + ); + Navigator.pop(modalContext); + }, + ); + }, + ); + }, + ), + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/operations/ui/widgets/details_section.dart b/lib/features/operations/ui/widgets/details_section.dart new file mode 100644 index 0000000..9361d37 --- /dev/null +++ b/lib/features/operations/ui/widgets/details_section.dart @@ -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( + 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() + .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().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(); + final existingBrands = context + .read() + .state + .brands; + + final newModel = await showDialog( + context: context, + builder: (dialogContext) { + return BlocProvider.value( + value: context.read(), + 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( + 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() + .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( + 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().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().updateOperationFields( + expirationDate: date, + ); + } + }, + ), + const SizedBox(height: 16), + ], + ], + ); + } +} diff --git a/lib/features/operations/ui/widgets/operation_files_section.dart b/lib/features/operations/ui/widgets/operation_files_section.dart new file mode 100644 index 0000000..42b1a6c --- /dev/null +++ b/lib/features/operations/ui/widgets/operation_files_section.dart @@ -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 createState() => _OperationFilesSectionState(); +} + +class _OperationFilesSectionState extends State { + String? _exportDirectory; + + @override + void initState() { + super.initState(); + _loadExportDirectory(); + } + + // --- GESTIONE CARTELLA CITRIX (SOLO DESKTOP) --- + Future _loadExportDirectory() async { + if (kIsWeb) return; + final prefs = await SharedPreferences.getInstance(); + setState(() { + _exportDirectory = prefs.getString('citrix_export_path'); + }); + } + + Future _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 _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().add( + AddOperationFilesEvent(result.files), + ); + } + } + + // --- APERTURA VIEWER --- + void _openFile(AttachmentModel file) { + // 1. Catturiamo il BLoC dalla pagina corrente prima di navigare + final operationFilesBloc = context.read(); + 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 _exportMergedPdf(List 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 allPagesAsImages = []; + final repository = GetIt.I.get(); + + 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( + 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 _exportSplitPdfs(List 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(); + + 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( + 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( + 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().add( + ClearOperationFileSelectionEvent(), + ); + } else { + context.read().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().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().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( + 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) => + >[ + const PopupMenuItem( + value: 'merge', + child: ListTile( + leading: Icon( + Icons.merge_type, + color: Colors.blue, + ), + title: Text('Unisci in un singolo PDF'), + ), + ), + const PopupMenuItem( + 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().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().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, + ), + ), + ), + ), + ], + ); + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/operations/ui/widgets/staff_section.dart b/lib/features/operations/ui/widgets/staff_section.dart new file mode 100644 index 0000000..00967b1 --- /dev/null +++ b/lib/features/operations/ui/widgets/staff_section.dart @@ -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().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( + builder: (context, state) { + // Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder! + final staffMembers = state.storeStaff; + final currentLoggedStaffMember = GetIt.I + .get() + .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().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(), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/services/blocs/service_files_bloc.dart b/lib/features/services/blocs/service_files_bloc.dart deleted file mode 100644 index 0298ef2..0000000 --- a/lib/features/services/blocs/service_files_bloc.dart +++ /dev/null @@ -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 { - final _repository = GetIt.I.get(); - final String? serviceId; - - ServiceFilesBloc({this.serviceId}) - : super( - ServiceFilesState( - status: ServiceFilesStatus.initial, - serviceId: serviceId, - ), - ) { - on(_onServiceSaved); - on(_onLoadServiceFiles); - on(_onAddServiceFiles); - on(_onUploadServiceFiles); - on(_onUploadMultipleServiceFiles); - on(_onDeleteServiceFiles); - on(_onToggleServiceFileSelection); - // Se il BLoC nasce con un ID, accendiamo subito lo stream! - if (serviceId != null) { - add(LoadServiceFilesEvent(serviceId: serviceId)); - } - } - - FutureOr _onServiceSaved( - ServiceSavedEvent event, - Emitter 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 _onLoadServiceFiles( - LoadServiceFilesEvent event, - Emitter 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 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 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 _onUploadServiceFiles( - UploadServiceFilesEvent event, - Emitter 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 _onUploadMultipleServiceFiles( - UploadMultipleServiceFilesEvent event, - Emitter 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> 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 _onDeleteServiceFiles( - DeleteServiceFilesEvent event, - Emitter 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 _onToggleServiceFileSelection( - ToggleServiceFileSelectionEvent event, - Emitter emit, - ) { - List selectedFiles = List.from(state.selectedFiles); - if (selectedFiles.contains(event.file)) { - selectedFiles.remove(event.file); - } else { - selectedFiles.add(event.file); - } - emit(state.copyWith(selectedFiles: selectedFiles)); - } -} diff --git a/lib/features/services/blocs/service_files_events.dart b/lib/features/services/blocs/service_files_events.dart deleted file mode 100644 index 141d5ac..0000000 --- a/lib/features/services/blocs/service_files_events.dart +++ /dev/null @@ -1,56 +0,0 @@ -part of 'service_files_bloc.dart'; - -abstract class ServiceFilesEvent extends Equatable { - const ServiceFilesEvent(); - - @override - List get props => []; -} - -class ServiceSavedEvent extends ServiceFilesEvent { - final String serviceId; - const ServiceSavedEvent(this.serviceId); - - @override - List get props => [serviceId]; -} - -class LoadServiceFilesEvent extends ServiceFilesEvent { - final String? serviceId; - final ServiceModel? service; - const LoadServiceFilesEvent({this.serviceId, this.service}); - - @override - List get props => [serviceId, service]; -} - -class AddServiceFilesEvent extends ServiceFilesEvent { - final List files; - const AddServiceFilesEvent(this.files); - - @override - List get props => [files]; -} - -class UploadServiceFilesEvent extends ServiceFilesEvent { - final List? pickedFiles; - final List? photos; - const UploadServiceFilesEvent({this.pickedFiles, this.photos}); - - @override - List get props => [pickedFiles, photos]; -} - -class UploadMultipleServiceFilesEvent extends ServiceFilesEvent { - final List files; - const UploadMultipleServiceFilesEvent(this.files); - @override - List get props => [files]; -} - -class DeleteServiceFilesEvent extends ServiceFilesEvent {} - -class ToggleServiceFileSelectionEvent extends ServiceFilesEvent { - final ServiceFileModel file; - const ToggleServiceFileSelectionEvent(this.file); -} diff --git a/lib/features/services/blocs/service_files_state.dart b/lib/features/services/blocs/service_files_state.dart deleted file mode 100644 index f39a133..0000000 --- a/lib/features/services/blocs/service_files_state.dart +++ /dev/null @@ -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 localFiles; - final List remoteFiles; - - final List selectedFiles; - - @override - List get props => [ - serviceId, - status, - error, - localFiles, - remoteFiles, - selectedFiles, - ]; - - List get allFiles => [...remoteFiles, ...localFiles]; - - ServiceFilesState copyWith({ - String? serviceId, - ServiceFilesStatus? status, - String? error, - List? localFiles, - List? remoteFiles, - List? 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, - ); - } -} diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart deleted file mode 100644 index 3417802..0000000 --- a/lib/features/services/blocs/services_cubit.dart +++ /dev/null @@ -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 { - final ServicesRepository _repository = GetIt.I(); - final SessionCubit _sessionCubit = GetIt.I(); - - ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial)); - - // --- CARICAMENTO E PAGINAZIONE --- - - Future 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 energyList) { - emit( - state.copyWith( - currentService: state.currentService?.copyWith( - energyServices: energyList, - ), - ), - ); - } - - void updateFinServices(List finList) { - emit( - state.copyWith( - currentService: state.currentService?.copyWith(finServices: finList), - ), - ); - } - - void updateEntertainmentServices(List entList) { - emit( - state.copyWith( - currentService: state.currentService?.copyWith( - entertainmentServices: entList, - ), - ), - ); - } - - // --- PERSISTENZA --- - - Future saveCurrentService({ - required bool isBozza, - bool shouldPop = true, - List? 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 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 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.from( - state.currentService!.files, - ); - updatedList.removeAt(index); - - emit( - state.copyWith( - currentService: state.currentService?.copyWith(files: updatedList), - ), - ); - } - - void saveAndCopyFileToCustomer(List 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", - ), - ); - } - } -} diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart deleted file mode 100644 index abfb672..0000000 --- a/lib/features/services/data/services_repository.dart +++ /dev/null @@ -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().state.company!.id; - final CustomerRepository _customerRepository = GetIt.I(); - - // --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO --- - Future 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> 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 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 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 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 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 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> 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 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> 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 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 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 deleteServiceFiles(List files) async { - if (files.isEmpty) return; - // 1. Prepariamo le liste di ID e di Percorsi - final List idsToDelete = files.map((f) => f.id!).toList(); - final List 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'; - } - } -} diff --git a/lib/features/services/models/energy_service_model.dart b/lib/features/services/models/energy_service_model.dart deleted file mode 100644 index 9cf9b54..0000000 --- a/lib/features/services/models/energy_service_model.dart +++ /dev/null @@ -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 get props => [ - id, - createdAt, - type, - expiration, - providerId, - serviceId, - ]; - - factory EnergyServiceModel.fromMap(Map 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 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, - }; - } -} diff --git a/lib/features/services/models/entertainment_service_model.dart b/lib/features/services/models/entertainment_service_model.dart deleted file mode 100644 index f34743a..0000000 --- a/lib/features/services/models/entertainment_service_model.dart +++ /dev/null @@ -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 get props => [ - id, - createdAt, - type, - constrained, - constrainExpiration, - serviceId, - providerId, - ]; - - factory EntertainmentServiceModel.fromMap(Map 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 toMap() { - return { - if (id != null) 'id': id, - 'type': type, - 'constrained': constrained, - 'constrain_expiration': constrainExpiration.toIso8601String(), - 'service_id': serviceId, - 'provider_id': providerId, - }; - } -} diff --git a/lib/features/services/models/fin_service_model.dart b/lib/features/services/models/fin_service_model.dart deleted file mode 100644 index 9cdaa5a..0000000 --- a/lib/features/services/models/fin_service_model.dart +++ /dev/null @@ -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 get props => [id, createdAt, expiration, serviceId, modelId]; - - factory FinServiceModel.fromMap(Map 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 toMap() { - return { - if (id != null) 'id': id, - 'expiration': expiration.toIso8601String(), - 'service_id': serviceId, - 'model_id': modelId, - 'provider_id': providerId, - }; - } -} diff --git a/lib/features/services/models/service_model.dart b/lib/features/services/models/service_model.dart deleted file mode 100644 index df58125..0000000 --- a/lib/features/services/models/service_model.dart +++ /dev/null @@ -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 energyServices; - final List finServices; - final List entertainmentServices; - - // ALLEGATI (Aggiunto) - final List 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? energyServices, - List? finServices, - List? entertainmentServices, - List? 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 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 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 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! - }; - } -} diff --git a/lib/features/services/ui/service_form_screen/action_card.dart b/lib/features/services/ui/service_form_screen/action_card.dart deleted file mode 100644 index 51f0b42..0000000 --- a/lib/features/services/ui/service_form_screen/action_card.dart +++ /dev/null @@ -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, - ), - ), - ), - ], - ], - ), - ), - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/attachment_section.dart b/lib/features/services/ui/service_form_screen/attachment_section.dart deleted file mode 100644 index 6883f65..0000000 --- a/lib/features/services/ui/service_form_screen/attachment_section.dart +++ /dev/null @@ -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 _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().add(AddServiceFilesEvent(result.files)); - } - } - - @override - Widget build(BuildContext context) { - ServiceFilesBloc serviceFilesBloc = BlocProvider.of( - context, - ); - - return BlocListener( - 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().add(ServiceSavedEvent(newId)); - }, - child: BlocBuilder( - 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() - .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 _handleGenerateQr(BuildContext context) async { - final cubit = context.read(); - var currentService = cubit.state.currentService; - - // 1. CATTURIAMO IL BLOC MENTRE SIAMO ANCORA NELLA PAGINA - final serviceFilesBloc = context.read(); - - // 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( - 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( - 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 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().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, - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/customer_section.dart b/lib/features/services/ui/service_form_screen/customer_section.dart deleted file mode 100644 index dd1e752..0000000 --- a/lib/features/services/ui/service_form_screen/customer_section.dart +++ /dev/null @@ -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"), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/energy_service_dialog.dart b/lib/features/services/ui/service_form_screen/energy_service_dialog.dart deleted file mode 100644 index 58da992..0000000 --- a/lib/features/services/ui/service_form_screen/energy_service_dialog.dart +++ /dev/null @@ -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 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 createState() => _EnergyServiceDialogState(); -} - -class _EnergyServiceDialogState extends State { - // Lista temporanea per non "sporcare" il cubit finché non si preme Conferma - late List _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().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() - .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 services; - final List - 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 _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( - 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 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( - 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 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( - 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( - 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"), - ), - ], - ), - ], - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/entertainment_service_card.dart b/lib/features/services/ui/service_form_screen/entertainment_service_card.dart deleted file mode 100644 index 4e07001..0000000 --- a/lib/features/services/ui/service_form_screen/entertainment_service_card.dart +++ /dev/null @@ -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 initialServices; - final String currentStoreId; - - const EntertainmentServiceDialog({ - super.key, - required this.initialServices, - required this.currentStoreId, - }); - - @override - State createState() => - _EntertainmentServiceDialogState(); -} - -class _EntertainmentServiceDialogState - extends State { - late List _tempList; - bool _isAddingNew = false; - - @override - void initState() { - super.initState(); - _tempList = List.from(widget.initialServices); - // Carichiamo i provider attivi per lo store corrente - context.read().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( - 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 services; - final List 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 _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( - builder: (context, state) { - final filtered = state.activeProviders - .where((p) => p.intrattenimento) - .toList(); - return DropdownButtonFormField( - 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>( - future: GetIt.I().fetchTopEntertainmentTypes( - GetIt.I().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( - 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"), - ), - ], - ), - ], - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/finance_service_dialog.dart b/lib/features/services/ui/service_form_screen/finance_service_dialog.dart deleted file mode 100644 index b03f4d7..0000000 --- a/lib/features/services/ui/service_form_screen/finance_service_dialog.dart +++ /dev/null @@ -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 initialServices; - final String currentStoreId; - final ProductCubit productCubit; - - const FinanceServiceDialog({ - super.key, - required this.initialServices, - required this.currentStoreId, - required this.productCubit, - }); - - @override - State createState() => _FinanceServiceDialogState(); -} - -class _FinanceServiceDialogState extends State { - late List _tempList; - bool _isAddingNew = false; - - @override - void initState() { - super.initState(); - _tempList = List.from(widget.initialServices); - // Carichiamo i dati necessari dai Cubit - context.read().loadActiveProvidersForStore( - widget.currentStoreId, - ); - context.read().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( - builder: (context, provState) { - return BlocBuilder( - 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 services; - final List allProviders; - final List 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().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 _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( - builder: (context, state) { - final finProviders = state.activeProviders - .where((p) => p.finanziamenti) - .toList(); // Già filtrati dal caricamento della dialog - return DropdownButtonFormField( - 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( - 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( - 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 - } -} diff --git a/lib/features/services/ui/service_form_screen/general_info_section.dart b/lib/features/services/ui/service_form_screen/general_info_section.dart deleted file mode 100644 index e2330bf..0000000 --- a/lib/features/services/ui/service_form_screen/general_info_section.dart +++ /dev/null @@ -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().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().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().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().updateField(note: val); - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/int_dialogs.dart b/lib/features/services/ui/service_form_screen/int_dialogs.dart deleted file mode 100644 index cbda2e7..0000000 --- a/lib/features/services/ui/service_form_screen/int_dialogs.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:async'; // Necessario per il Timer -import 'package:flutter/material.dart'; - -Future 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( - 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 - onChanged; // Callback per notificare il padre dei cambiamenti - - const QuickCounter({ - super.key, - required this.initialValue, - required this.onChanged, - }); - - @override - State createState() => _QuickCounterState(); -} - -class _QuickCounterState extends State { - 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), - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/service_form_screen.dart b/lib/features/services/ui/service_form_screen/service_form_screen.dart deleted file mode 100644 index 6574c5b..0000000 --- a/lib/features/services/ui/service_form_screen/service_form_screen.dart +++ /dev/null @@ -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 createState() => _ServiceFormScreenState(); -} - -class _ServiceFormScreenState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - // Diamo in pasto al Cubit tutto quello che abbiamo! - context.read().initServiceForm( - existingService: widget.existingService, - serviceId: widget.serviceId, - ); - }); - } - - void _performSave(BuildContext context, {required bool isBozza}) { - FocusScope.of(context).unfocus(); - context.read().saveCurrentService(isBozza: isBozza); - } - - @override - Widget build(BuildContext context) { - return BlocConsumer( - 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), - ), - ), - ], - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/services_grid.dart b/lib/features/services/ui/service_form_screen/services_grid.dart deleted file mode 100644 index 28e282a..0000000 --- a/lib/features/services/ui/service_form_screen/services_grid.dart +++ /dev/null @@ -1,196 +0,0 @@ -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/services/blocs/services_cubit.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_model.dart'; -import 'package:flux/features/services/ui/service_form_screen/action_card.dart'; -import 'package:flux/features/services/ui/service_form_screen/energy_service_dialog.dart'; -import 'package:flux/features/services/ui/service_form_screen/entertainment_service_card.dart'; -import 'package:flux/features/services/ui/service_form_screen/finance_service_dialog.dart'; -import 'package:flux/features/services/ui/service_form_screen/int_dialogs.dart'; // Assicurati di importare il modello - -class ServicesGrid extends StatelessWidget { - final ServiceModel service; - - const ServicesGrid({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.layers_outlined, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - "Servizi e Accessori", - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: Wrap( - spacing: 16, - runSpacing: 16, - alignment: WrapAlignment.center, - children: [ - // --- CONTATORI SEMPLICI --- - ActionCard( - label: "AL", - count: service.al, - icon: Icons.sim_card, - color: Colors.blue, - onTap: () => updateCountDialog( - context, - "AL", - service.al, - (val) => - context.read().updateField(al: val), - ), - ), - ActionCard( - label: "MNP", - count: service.mnp, - icon: Icons.phone_android, - color: Colors.indigo, - onTap: () => updateCountDialog( - context, - "MNP", - service.mnp, - (val) => - context.read().updateField(mnp: val), - ), - ), - ActionCard( - label: "NIP", - count: service.nip, - icon: Icons.compare_arrows, - color: Colors.cyan, - onTap: () => updateCountDialog( - context, - "NIP", - service.nip, - (val) => - context.read().updateField(nip: val), - ), - ), - ActionCard( - label: "Unica", - count: service.unica, - icon: Icons.all_inclusive, - color: Colors.purple, - onTap: () => updateCountDialog( - context, - "Unica", - service.unica, - (val) => - context.read().updateField(unica: val), - ), - ), - ActionCard( - label: "Telepass", - count: service.telepass, - icon: Icons.directions_car, - color: Colors.amber.shade700, - onTap: () => updateCountDialog( - context, - "Telepass", - service.telepass, - (val) => context.read().updateField( - telepass: val, - ), - ), - ), - - // --- MODULI COMPLESSI (Le liste) --- - ActionCard( - label: "Energia", - count: service.energyServices.length, - icon: Icons.bolt, - color: Colors.green, - onTap: () async { - // Apriamo la modale e aspettiamo il risultato - final result = await showDialog>( - context: context, - builder: (context) => EnergyServiceDialog( - currentStoreId: service.storeId, - initialServices: service - .energyServices, // Passiamo la lista attuale - ), - ); - - // Se l'utente ha premuto "Conferma" e non "Annulla" o tap fuori - if (result != null && context.mounted) { - context.read().updateEnergyServices( - result, - ); - } - }, - ), - ActionCard( - label: "Finanziam.", - count: service.finServices.length, - icon: Icons.euro_symbol, - color: Colors.teal, - onTap: () async { - final result = await showDialog>( - context: context, - builder: (context) => FinanceServiceDialog( - productCubit: context.read(), - currentStoreId: service.storeId, - initialServices: - service.finServices, // Passiamo la lista attuale - ), - ); - - if (result != null && context.mounted) { - context.read().updateFinServices(result); - } - }, - ), - ActionCard( - label: "Intratten.", - count: service.entertainmentServices.length, - icon: Icons.movie_filter_outlined, - color: Colors.purple, - onTap: () async { - final result = - await showDialog>( - context: context, - builder: (context) => EntertainmentServiceDialog( - initialServices: service.entertainmentServices, - currentStoreId: service.storeId, - ), - ); - - if (result != null && context.mounted) { - context - .read() - .updateEntertainmentServices(result); - } - }, - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/services/utils/service_actions.dart b/lib/features/services/utils/service_actions.dart deleted file mode 100644 index 3159591..0000000 --- a/lib/features/services/utils/service_actions.dart +++ /dev/null @@ -1,82 +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/store/bloc/store_cubit.dart'; -import 'package:flux/features/services/blocs/services_cubit.dart'; -import 'package:flux/features/services/models/service_model.dart'; -import 'package:go_router/go_router.dart'; - -/// Avvia la creazione di un nuovo servizio partendo dalla selezione dell'operatore. -void startNewService(BuildContext context) { - final session = context.read().state; - final currentStoreId = session.currentStore?.id; - - if (currentStoreId == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Seleziona uno store prima di iniziare")), - ); - return; - } - - showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (modalContext) { - // Usiamo lo StoreCubit invece dello StaffCubit! - return BlocBuilder( - builder: (context, storeState) { - // Recuperiamo lo staff assegnato a questo specifico store usando la mappa che avevi già creato - final storeStaff = storeState.staffByStore[currentStoreId] ?? []; - - return Container( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - "Chi sta eseguendo l'operazione?", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 20), - - if (storeStaff.isEmpty) - const Text( - "Nessun membro dello staff configurato per questo store.\nVai in Anagrafica > Negozi per assegnare il personale.", - textAlign: TextAlign.center, - ), - - ...storeStaff.map( - (member) => ListTile( - leading: const CircleAvatar(child: Icon(Icons.person)), - title: Text(member.name), - onTap: () { - // 1. Inizializza il form nel Cubit - context.read().initServiceForm( - existingService: ServiceModel( - storeId: currentStoreId, - employeeId: member.id, - number: '', - createdAt: DateTime.now(), - companyId: session.company!.id!, - ), - ); - - // 2. Chiudi la modal - Navigator.pop(modalContext); - - // 3. Naviga verso il form - context.pushNamed('service-form'); - }, - ), - ), - const SizedBox(height: 16), - ], - ), - ); - }, - ); - }, - ); -} diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb new file mode 100644 index 0000000..86bc2ca --- /dev/null +++ b/lib/l10n/app_it.arb @@ -0,0 +1,91 @@ +{ + "@@locale": "it", + "@authCubitResetPasswordEmailSentTo": { + "placeholders": { + "email": { + "type": "String" + } + } + }, + "@authError": { + "placeholders": { + "message": { + "type": "String" + } + } + }, + "@commonComingSoon": {}, + "@commonError": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "@homeWelcomeBack": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "authCubitCheckEmailToConfirmAccount": "Controlla la tua email per confermare l'account!", + "authCubitResetPasswordEmailSentTo": "Email per reset password inviata a {email}!", + "authError": "Errore di autenticazione: {message}", + "authScreenAlreadyHaveAccount": "Hai già un account?", + "authScreenBusinessEmail": "Email aziendale", + "authScreenCreateAccount": "CREA ACCOUNT", + "authScreenDontHaveAccount": "Non hai un account?", + "authScreenForgotPassword": "Password dimenticata/Invito scaduto?", + "authScreenLogin": "LOGIN", + "authScreenLoginToManageYourBusiness": "Accedi per gestire il tuo business", + "authScreenSignUp": "REGISTRATI", + "authScreenStartTodayToDigitalizeYourStore": "Inizia oggi a digitalizzare il tuo negozio", + "authScreenWelcomeBack": "BENTORNATO", + "commonClose": "Chiudi", + "commonComingSoon": "Coming soon", + "commonDashboard": "Panoramica", + "commonError": "Si è verificato un errore: {error}", + "commonMasterData": "Anagrafiche", + "commonNewPassword": "Nuova Password", + "commonNote": "Nota", + "commonSave": "Salva", + "commonOperation": "Operazione", + "commonSettings": "Impostazioni", + "commonStickyNotes": "Sticky Notes", + "commonTask": "Attività", + "homeExpiringContracts": "Contratti in scadenza", + "homeLatestOperationTickets": "Ultime assistenze", + "homeLatestOperations": "Ultime Operazioni", + "homeMyTasks": "Mie Attività", + "homeNewOperationTicket": "Nuova assistenza", + "homeNoStoreFound": "Nessun negozio trovato", + "homeWelcomeBack": "Bentornato, {name}! 👋", + "imageViewerWidgetErrorOpening": "Errore durante l'apertura dell'immagine", + "pdfViewerAnteprimaPdf": "Anteprima PDF", + "setPasswordInviteAcceptedChoosePassword": "Hai accettato l'invito. Scegli una password sicura per accedere in futuro.", + "setPasswordScreenAtLeast6Chars": "La password deve avere almeno 6 caratteri", + "setPasswordScreenPasswordSetWelcome": "Password impostata! Benvenuto a bordo 🚀", + "setPasswordScreenSaveAndStart": "SALVA E INIZIA", + "setPasswordScreenSetPassword": "Imposta una nuova Password", + "setPasswordScreenWelcomeInFlux": "Benvenuto in FLUX!", + "createCompanyScreenCompanyConfiguration": "Configurazione Azienda", + "commonSavingError": "Errore durante il salvataggio", + "createCompanyScreenFiscalData": "DATI FISCALI", + "createCompanyScreenCompanyName": "Ragione Sociale", + "createCompanyScreenVatId": "Partita IVA", + "createCompanyScreenFiscalCode": "Codice Fiscale", + "createCompanyScreenSdiPec": "Codice Univoco (SDI) / PEC", + "createCompanyScreenCompanyLegalAddress": "SEDE LEGALE", + "commonAddress": "Indirizzo e n. civico", + "commonCity": "Città", + "commonZipCode": "CAP", + "commonProvince": "Prov", + "commonCountry": "Paese", + "createCompanyScreenUploadLogo": "Carica Logo Aziendale", + "createCompanyScreenWillBeUsedForReceipts": "Verrà utilizzato per le tue stampe e ricevute", + "createCompanyScreenSaveCompany": "SALVA AZIENDA", + "createCompanyScreenSetupYourCompany": "Configura la tua Azienda", + "createCompanyScreenFluxNeedsYourFiscalData": "FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.", + "operationFormAttachmentSectionNoCustomer": "Devi prima selezionare un cliente" +} \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..6fd9505 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,482 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_it.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('it')]; + + /// No description provided for @authCubitCheckEmailToConfirmAccount. + /// + /// In it, this message translates to: + /// **'Controlla la tua email per confermare l\'account!'** + String get authCubitCheckEmailToConfirmAccount; + + /// No description provided for @authCubitResetPasswordEmailSentTo. + /// + /// In it, this message translates to: + /// **'Email per reset password inviata a {email}!'** + String authCubitResetPasswordEmailSentTo(String email); + + /// No description provided for @authError. + /// + /// In it, this message translates to: + /// **'Errore di autenticazione: {message}'** + String authError(String message); + + /// No description provided for @authScreenAlreadyHaveAccount. + /// + /// In it, this message translates to: + /// **'Hai già un account?'** + String get authScreenAlreadyHaveAccount; + + /// No description provided for @authScreenBusinessEmail. + /// + /// In it, this message translates to: + /// **'Email aziendale'** + String get authScreenBusinessEmail; + + /// No description provided for @authScreenCreateAccount. + /// + /// In it, this message translates to: + /// **'CREA ACCOUNT'** + String get authScreenCreateAccount; + + /// No description provided for @authScreenDontHaveAccount. + /// + /// In it, this message translates to: + /// **'Non hai un account?'** + String get authScreenDontHaveAccount; + + /// No description provided for @authScreenForgotPassword. + /// + /// In it, this message translates to: + /// **'Password dimenticata/Invito scaduto?'** + String get authScreenForgotPassword; + + /// No description provided for @authScreenLogin. + /// + /// In it, this message translates to: + /// **'LOGIN'** + String get authScreenLogin; + + /// No description provided for @authScreenLoginToManageYourBusiness. + /// + /// In it, this message translates to: + /// **'Accedi per gestire il tuo business'** + String get authScreenLoginToManageYourBusiness; + + /// No description provided for @authScreenSignUp. + /// + /// In it, this message translates to: + /// **'REGISTRATI'** + String get authScreenSignUp; + + /// No description provided for @authScreenStartTodayToDigitalizeYourStore. + /// + /// In it, this message translates to: + /// **'Inizia oggi a digitalizzare il tuo negozio'** + String get authScreenStartTodayToDigitalizeYourStore; + + /// No description provided for @authScreenWelcomeBack. + /// + /// In it, this message translates to: + /// **'BENTORNATO'** + String get authScreenWelcomeBack; + + /// No description provided for @commonClose. + /// + /// In it, this message translates to: + /// **'Chiudi'** + String get commonClose; + + /// No description provided for @commonComingSoon. + /// + /// In it, this message translates to: + /// **'Coming soon'** + String get commonComingSoon; + + /// No description provided for @commonDashboard. + /// + /// In it, this message translates to: + /// **'Panoramica'** + String get commonDashboard; + + /// No description provided for @commonError. + /// + /// In it, this message translates to: + /// **'Si è verificato un errore: {error}'** + String commonError(String error); + + /// No description provided for @commonMasterData. + /// + /// In it, this message translates to: + /// **'Anagrafiche'** + String get commonMasterData; + + /// No description provided for @commonNewPassword. + /// + /// In it, this message translates to: + /// **'Nuova Password'** + String get commonNewPassword; + + /// No description provided for @commonNote. + /// + /// In it, this message translates to: + /// **'Nota'** + String get commonNote; + + /// No description provided for @commonSave. + /// + /// In it, this message translates to: + /// **'Salva'** + String get commonSave; + + /// No description provided for @commonOperation. + /// + /// In it, this message translates to: + /// **'Operazione'** + String get commonOperation; + + /// No description provided for @commonSettings. + /// + /// In it, this message translates to: + /// **'Impostazioni'** + String get commonSettings; + + /// No description provided for @commonStickyNotes. + /// + /// In it, this message translates to: + /// **'Sticky Notes'** + String get commonStickyNotes; + + /// No description provided for @commonTask. + /// + /// In it, this message translates to: + /// **'Attività'** + String get commonTask; + + /// No description provided for @homeExpiringContracts. + /// + /// In it, this message translates to: + /// **'Contratti in scadenza'** + String get homeExpiringContracts; + + /// No description provided for @homeLatestOperationTickets. + /// + /// In it, this message translates to: + /// **'Ultime assistenze'** + String get homeLatestOperationTickets; + + /// No description provided for @homeLatestOperations. + /// + /// In it, this message translates to: + /// **'Ultime Operazioni'** + String get homeLatestOperations; + + /// No description provided for @homeMyTasks. + /// + /// In it, this message translates to: + /// **'Mie Attività'** + String get homeMyTasks; + + /// No description provided for @homeNewOperationTicket. + /// + /// In it, this message translates to: + /// **'Nuova assistenza'** + String get homeNewOperationTicket; + + /// No description provided for @homeNoStoreFound. + /// + /// In it, this message translates to: + /// **'Nessun negozio trovato'** + String get homeNoStoreFound; + + /// No description provided for @homeWelcomeBack. + /// + /// In it, this message translates to: + /// **'Bentornato, {name}! 👋'** + String homeWelcomeBack(String name); + + /// No description provided for @imageViewerWidgetErrorOpening. + /// + /// In it, this message translates to: + /// **'Errore durante l\'apertura dell\'immagine'** + String get imageViewerWidgetErrorOpening; + + /// No description provided for @pdfViewerAnteprimaPdf. + /// + /// In it, this message translates to: + /// **'Anteprima PDF'** + String get pdfViewerAnteprimaPdf; + + /// No description provided for @setPasswordInviteAcceptedChoosePassword. + /// + /// In it, this message translates to: + /// **'Hai accettato l\'invito. Scegli una password sicura per accedere in futuro.'** + String get setPasswordInviteAcceptedChoosePassword; + + /// No description provided for @setPasswordScreenAtLeast6Chars. + /// + /// In it, this message translates to: + /// **'La password deve avere almeno 6 caratteri'** + String get setPasswordScreenAtLeast6Chars; + + /// No description provided for @setPasswordScreenPasswordSetWelcome. + /// + /// In it, this message translates to: + /// **'Password impostata! Benvenuto a bordo 🚀'** + String get setPasswordScreenPasswordSetWelcome; + + /// No description provided for @setPasswordScreenSaveAndStart. + /// + /// In it, this message translates to: + /// **'SALVA E INIZIA'** + String get setPasswordScreenSaveAndStart; + + /// No description provided for @setPasswordScreenSetPassword. + /// + /// In it, this message translates to: + /// **'Imposta una nuova Password'** + String get setPasswordScreenSetPassword; + + /// No description provided for @setPasswordScreenWelcomeInFlux. + /// + /// In it, this message translates to: + /// **'Benvenuto in FLUX!'** + String get setPasswordScreenWelcomeInFlux; + + /// No description provided for @createCompanyScreenCompanyConfiguration. + /// + /// In it, this message translates to: + /// **'Configurazione Azienda'** + String get createCompanyScreenCompanyConfiguration; + + /// No description provided for @commonSavingError. + /// + /// In it, this message translates to: + /// **'Errore durante il salvataggio'** + String get commonSavingError; + + /// No description provided for @createCompanyScreenFiscalData. + /// + /// In it, this message translates to: + /// **'DATI FISCALI'** + String get createCompanyScreenFiscalData; + + /// No description provided for @createCompanyScreenCompanyName. + /// + /// In it, this message translates to: + /// **'Ragione Sociale'** + String get createCompanyScreenCompanyName; + + /// No description provided for @createCompanyScreenVatId. + /// + /// In it, this message translates to: + /// **'Partita IVA'** + String get createCompanyScreenVatId; + + /// No description provided for @createCompanyScreenFiscalCode. + /// + /// In it, this message translates to: + /// **'Codice Fiscale'** + String get createCompanyScreenFiscalCode; + + /// No description provided for @createCompanyScreenSdiPec. + /// + /// In it, this message translates to: + /// **'Codice Univoco (SDI) / PEC'** + String get createCompanyScreenSdiPec; + + /// No description provided for @createCompanyScreenCompanyLegalAddress. + /// + /// In it, this message translates to: + /// **'SEDE LEGALE'** + String get createCompanyScreenCompanyLegalAddress; + + /// No description provided for @commonAddress. + /// + /// In it, this message translates to: + /// **'Indirizzo e n. civico'** + String get commonAddress; + + /// No description provided for @commonCity. + /// + /// In it, this message translates to: + /// **'Città'** + String get commonCity; + + /// No description provided for @commonZipCode. + /// + /// In it, this message translates to: + /// **'CAP'** + String get commonZipCode; + + /// No description provided for @commonProvince. + /// + /// In it, this message translates to: + /// **'Prov'** + String get commonProvince; + + /// No description provided for @commonCountry. + /// + /// In it, this message translates to: + /// **'Paese'** + String get commonCountry; + + /// No description provided for @createCompanyScreenUploadLogo. + /// + /// In it, this message translates to: + /// **'Carica Logo Aziendale'** + String get createCompanyScreenUploadLogo; + + /// No description provided for @createCompanyScreenWillBeUsedForReceipts. + /// + /// In it, this message translates to: + /// **'Verrà utilizzato per le tue stampe e ricevute'** + String get createCompanyScreenWillBeUsedForReceipts; + + /// No description provided for @createCompanyScreenSaveCompany. + /// + /// In it, this message translates to: + /// **'SALVA AZIENDA'** + String get createCompanyScreenSaveCompany; + + /// No description provided for @createCompanyScreenSetupYourCompany. + /// + /// In it, this message translates to: + /// **'Configura la tua Azienda'** + String get createCompanyScreenSetupYourCompany; + + /// No description provided for @createCompanyScreenFluxNeedsYourFiscalData. + /// + /// In it, this message translates to: + /// **'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.'** + String get createCompanyScreenFluxNeedsYourFiscalData; + + /// No description provided for @operationFormAttachmentSectionNoCustomer. + /// + /// In it, this message translates to: + /// **'Devi prima selezionare un cliente'** + String get operationFormAttachmentSectionNoCustomer; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['it'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'it': + return AppLocalizationsIt(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..7630adc --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,57 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get homeExpiringContracts => 'Contratti in scadenza'; + + @override + String get homeLatestOperationTickets => 'Ultime assistenze'; + + @override + String get homeLatestOperations => 'Ultimi Servizi'; + + @override + String get homeMasterData => 'Anagrafiche'; + + @override + String get homeMyTasks => 'Mie Attività'; + + @override + String get homeNewOperation => 'Servizio'; + + @override + String get homeSettings => 'Impostazioni'; + + @override + String get homeStickyNotes => 'Sticky Notes'; + + @override + String homeWelcomeBack(String name) { + return 'Bentornato, $name! 👋'; + } + + @override + String get homeNoStoreFound => 'Nessun negozio trovato'; + + @override + String get homeNewOperationTicket => 'Nuova assistenza'; + + @override + String get homeNewNote => 'Nota'; + + @override + String get homeNewTask => 'Attività'; + + @override + String get commonComingSoon => 'Coming soon'; + + @override + String get commonDashboard => 'Panoramica'; +} diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart new file mode 100644 index 0000000..f284efa --- /dev/null +++ b/lib/l10n/app_localizations_it.dart @@ -0,0 +1,206 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Italian (`it`). +class AppLocalizationsIt extends AppLocalizations { + AppLocalizationsIt([String locale = 'it']) : super(locale); + + @override + String get authCubitCheckEmailToConfirmAccount => + 'Controlla la tua email per confermare l\'account!'; + + @override + String authCubitResetPasswordEmailSentTo(String email) { + return 'Email per reset password inviata a $email!'; + } + + @override + String authError(String message) { + return 'Errore di autenticazione: $message'; + } + + @override + String get authScreenAlreadyHaveAccount => 'Hai già un account?'; + + @override + String get authScreenBusinessEmail => 'Email aziendale'; + + @override + String get authScreenCreateAccount => 'CREA ACCOUNT'; + + @override + String get authScreenDontHaveAccount => 'Non hai un account?'; + + @override + String get authScreenForgotPassword => 'Password dimenticata/Invito scaduto?'; + + @override + String get authScreenLogin => 'LOGIN'; + + @override + String get authScreenLoginToManageYourBusiness => + 'Accedi per gestire il tuo business'; + + @override + String get authScreenSignUp => 'REGISTRATI'; + + @override + String get authScreenStartTodayToDigitalizeYourStore => + 'Inizia oggi a digitalizzare il tuo negozio'; + + @override + String get authScreenWelcomeBack => 'BENTORNATO'; + + @override + String get commonClose => 'Chiudi'; + + @override + String get commonComingSoon => 'Coming soon'; + + @override + String get commonDashboard => 'Panoramica'; + + @override + String commonError(String error) { + return 'Si è verificato un errore: $error'; + } + + @override + String get commonMasterData => 'Anagrafiche'; + + @override + String get commonNewPassword => 'Nuova Password'; + + @override + String get commonNote => 'Nota'; + + @override + String get commonSave => 'Salva'; + + @override + String get commonOperation => 'Operazione'; + + @override + String get commonSettings => 'Impostazioni'; + + @override + String get commonStickyNotes => 'Sticky Notes'; + + @override + String get commonTask => 'Attività'; + + @override + String get homeExpiringContracts => 'Contratti in scadenza'; + + @override + String get homeLatestOperationTickets => 'Ultime assistenze'; + + @override + String get homeLatestOperations => 'Ultime Operazioni'; + + @override + String get homeMyTasks => 'Mie Attività'; + + @override + String get homeNewOperationTicket => 'Nuova assistenza'; + + @override + String get homeNoStoreFound => 'Nessun negozio trovato'; + + @override + String homeWelcomeBack(String name) { + return 'Bentornato, $name! 👋'; + } + + @override + String get imageViewerWidgetErrorOpening => + 'Errore durante l\'apertura dell\'immagine'; + + @override + String get pdfViewerAnteprimaPdf => 'Anteprima PDF'; + + @override + String get setPasswordInviteAcceptedChoosePassword => + 'Hai accettato l\'invito. Scegli una password sicura per accedere in futuro.'; + + @override + String get setPasswordScreenAtLeast6Chars => + 'La password deve avere almeno 6 caratteri'; + + @override + String get setPasswordScreenPasswordSetWelcome => + 'Password impostata! Benvenuto a bordo 🚀'; + + @override + String get setPasswordScreenSaveAndStart => 'SALVA E INIZIA'; + + @override + String get setPasswordScreenSetPassword => 'Imposta una nuova Password'; + + @override + String get setPasswordScreenWelcomeInFlux => 'Benvenuto in FLUX!'; + + @override + String get createCompanyScreenCompanyConfiguration => + 'Configurazione Azienda'; + + @override + String get commonSavingError => 'Errore durante il salvataggio'; + + @override + String get createCompanyScreenFiscalData => 'DATI FISCALI'; + + @override + String get createCompanyScreenCompanyName => 'Ragione Sociale'; + + @override + String get createCompanyScreenVatId => 'Partita IVA'; + + @override + String get createCompanyScreenFiscalCode => 'Codice Fiscale'; + + @override + String get createCompanyScreenSdiPec => 'Codice Univoco (SDI) / PEC'; + + @override + String get createCompanyScreenCompanyLegalAddress => 'SEDE LEGALE'; + + @override + String get commonAddress => 'Indirizzo e n. civico'; + + @override + String get commonCity => 'Città'; + + @override + String get commonZipCode => 'CAP'; + + @override + String get commonProvince => 'Prov'; + + @override + String get commonCountry => 'Paese'; + + @override + String get createCompanyScreenUploadLogo => 'Carica Logo Aziendale'; + + @override + String get createCompanyScreenWillBeUsedForReceipts => + 'Verrà utilizzato per le tue stampe e ricevute'; + + @override + String get createCompanyScreenSaveCompany => 'SALVA AZIENDA'; + + @override + String get createCompanyScreenSetupYourCompany => 'Configura la tua Azienda'; + + @override + String get createCompanyScreenFluxNeedsYourFiscalData => + 'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.'; + + @override + String get operationFormAttachmentSectionNoCustomer => + 'Devi prima selezionare un cliente'; +} diff --git a/lib/main.dart b/lib/main.dart index 93b8891..8f62822 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flux/features/attachments/data/attachments_repository.dart'; import 'package:flux/features/auth/bloc/auth_cubit.dart'; +import 'package:flux/features/operations/data/operations_repository.dart'; +import 'package:flux/l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -14,7 +17,7 @@ import 'package:flux/core/data/core_repository.dart'; import 'package:flux/core/routes/app_router.dart'; import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/bloc/theme_bloc.dart'; -import 'package:flux/features/customers/blocs/customer_cubit.dart'; +import 'package:flux/features/customers/blocs/customers_cubit.dart'; import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; import 'package:flux/features/master_data/products/data/product_repository.dart'; @@ -24,8 +27,7 @@ import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart'; import 'package:flux/features/master_data/staff/data/staff_repository.dart'; import 'package:flux/features/master_data/store/bloc/store_cubit.dart'; import 'package:flux/features/master_data/store/data/store_repository.dart'; -import 'package:flux/features/services/blocs/services_cubit.dart'; -import 'package:flux/features/services/data/services_repository.dart'; +import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/settings/settings.dart'; void main() async { @@ -47,10 +49,10 @@ void main() async { // Cubit delle feature BlocProvider(create: (_) => StoreCubit()), - BlocProvider(create: (_) => CustomerCubit()), - BlocProvider(create: (_) => ProductCubit()), + BlocProvider(create: (_) => CustomersCubit()), + BlocProvider(create: (_) => ProductsCubit()), BlocProvider(create: (_) => StaffCubit()), - BlocProvider(create: (_) => ServicesCubit()), + BlocProvider(create: (_) => OperationsCubit()), BlocProvider(create: (_) => ProvidersCubit()), ], child: const FluxApp(), @@ -83,8 +85,13 @@ Future setupLocator() async { getIt.registerLazySingleton(() => CustomerRepository()); getIt.registerLazySingleton(() => ProductRepository()); getIt.registerLazySingleton(() => StaffRepository()); - getIt.registerLazySingleton(() => ServicesRepository()); + getIt.registerLazySingleton( + () => OperationsRepository(), + ); getIt.registerLazySingleton(() => ProviderRepository()); + getIt.registerLazySingleton( + () => AttachmentsRepository(), + ); // NOTA: CompanyRepository l'ho tolto perché la logica della Company // ora è gestita dal CoreRepository durante l'Onboarding. @@ -152,6 +159,9 @@ class _FluxAppState extends State { darkTheme: fluxDarkTheme, themeMode: themeState.currentTheme.themeMode, routerConfig: _router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('it'), ); }, ); diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib index 80e867a..02538c9 100644 --- a/macos/Runner/Base.lproj/MainMenu.xib +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -35,9 +35,9 @@ - + - + diff --git a/pubspec.lock b/pubspec.lock index b1c4635..399aabd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -49,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" bloc: dependency: transitive description: @@ -254,6 +278,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -316,10 +345,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e + sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" url: "https://pub.dev" source: hosted - version: "8.0.2" + version: "8.1.0" gotrue: dependency: transitive description: @@ -360,14 +389,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" image_picker: dependency: "direct main" description: name: image_picker - sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" image_picker_android: dependency: transitive description: @@ -632,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" pdfx: dependency: "direct main" description: @@ -728,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" postgrest: dependency: transitive description: @@ -957,6 +1010,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + universal_io: + dependency: "direct main" + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" universal_platform: dependency: transitive description: @@ -1030,7 +1091,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" @@ -1041,10 +1102,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373" + sha256: "6409a25046024f0f8c5d8a59fec314081e81f9d436b66ca4015a8b49772bf445" url: "https://pub.dev" source: hosted - version: "1.1.21" + version: "1.2.0" vector_graphics_codec: dependency: transitive description: @@ -1073,10 +1134,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.1.0" + version: "15.2.0" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 732b141..017d845 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,8 @@ dependencies: file_picker: ^11.0.2 flutter: sdk: flutter + flutter_localizations: + sdk: flutter flutter_bloc: ^9.1.1 flutter_dotenv: ^6.0.0 flutter_svg: ^2.2.4 @@ -26,6 +28,9 @@ dependencies: qr_flutter: ^4.1.0 shared_preferences: ^2.5.5 supabase_flutter: ^2.12.2 + uuid: ^4.5.3 + pdf: ^3.12.0 + universal_io: ^2.3.1 dev_dependencies: flutter_test: @@ -34,8 +39,9 @@ dev_dependencies: flutter: uses-material-design: true + generate: true assets: - assets/images/ - assets/svg/ - - .env \ No newline at end of file + - .env