From c3d4f3fac7a1c8e2db52b84f3975ebd83b5f44b9 Mon Sep 17 00:00:00 2001 From: mark-cachy Date: Mon, 20 Apr 2026 16:52:20 +0200 Subject: [PATCH] feat-insert-service (#5) Reviewed-on: http://catelliub.zapto.org:3000/brontomark/flux/pulls/5 Co-authored-by: mark-cachy Co-committed-by: mark-cachy --- ios/Podfile.lock | 88 ++++ ios/Runner.xcodeproj/project.pbxproj | 112 ++++ .../contents.xcworkspacedata | 3 + lib/core/routes/app_router.dart | 14 +- lib/core/utils/string_extensions.dart | 20 + lib/core/widgets/image_viewer_widget.dart | 61 +++ lib/core/widgets/pdf_viewer_widget.dart | 97 ++++ .../customers/blocs/customer_bloc.dart | 117 ----- .../customers/blocs/customer_cubit.dart | 160 ++++++ .../customers/blocs/customer_events.dart | 34 -- .../customers/blocs/customer_state.dart | 7 +- .../customers/data/customer_repository.dart | 106 ++-- .../customers/models/customer_file_model.dart | 68 ++- .../customers/models/customer_model.dart | 48 +- .../customers/ui/customer_search_sheet.dart | 202 ++++++++ .../customers/ui/customers_content.dart | 24 +- .../customers/ui/quick_customer_dialog.dart | 117 +++++ .../home/ui/dashboard_adaptive_grid.dart | 3 +- lib/features/home/ui/home_screen.dart | 312 ++++++++---- .../products/blocs/product_cubit.dart | 56 +- .../products/data/product_repository.dart | 27 +- .../products/models/model_model.dart | 2 +- .../products/ui/quick_product_dialog.dart | 111 ++++ .../providers/blocs/provider_cubit.dart | 36 +- .../providers/models/provider_model.dart | 7 + .../providers/ui/provider_form_sheet.dart | 8 + .../master_data/staff/blocs/staff_cubit.dart | 4 +- .../master_data/store/bloc/store_cubit.dart | 4 +- .../products/blocs/product_cubit.dart | 105 ---- .../products/blocs/product_state.dart | 44 -- .../products/data/product_repository.dart | 86 ---- lib/features/products/models/brand_model.dart | 58 --- lib/features/products/models/model_model.dart | 70 --- lib/features/products/ui/brand_selector.dart | 58 --- lib/features/products/ui/models_list.dart | 79 --- lib/features/products/ui/product_dialogs.dart | 110 ---- lib/features/products/ui/products_screen.dart | 74 --- .../products/ui/round_action_button.dart | 23 - .../services/blocs/services_cubit.dart | 349 ++++++++++--- .../services/blocs/services_state.dart | 54 ++ .../services/data/services_repository.dart | 195 +++++-- .../services/models/service_file_model.dart | 98 ++++ .../services/models/service_model.dart | 45 +- .../services/ui/service_action_card.dart | 76 +++ .../services/ui/service_form_screen.dart | 150 ------ .../ui/service_form_screen/action_card.dart | 85 ++++ .../attachment_section.dart | 184 +++++++ .../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 | 173 +++++++ .../ui/service_form_screen/services_grid.dart | 196 +++++++ lib/features/services/ui/services_screen.dart | 15 +- .../services/utils/service_actions.dart | 82 +++ lib/main.dart | 92 ++-- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 74 ++- pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 63 files changed, 4715 insertions(+), 1371 deletions(-) create mode 100644 ios/Podfile.lock create mode 100644 lib/core/widgets/image_viewer_widget.dart create mode 100644 lib/core/widgets/pdf_viewer_widget.dart delete mode 100644 lib/features/customers/blocs/customer_bloc.dart create mode 100644 lib/features/customers/blocs/customer_cubit.dart delete mode 100644 lib/features/customers/blocs/customer_events.dart create mode 100644 lib/features/customers/ui/customer_search_sheet.dart create mode 100644 lib/features/customers/ui/quick_customer_dialog.dart create mode 100644 lib/features/master_data/products/ui/quick_product_dialog.dart delete mode 100644 lib/features/products/blocs/product_cubit.dart delete mode 100644 lib/features/products/blocs/product_state.dart delete mode 100644 lib/features/products/data/product_repository.dart delete mode 100644 lib/features/products/models/brand_model.dart delete mode 100644 lib/features/products/models/model_model.dart delete mode 100644 lib/features/products/ui/brand_selector.dart delete mode 100644 lib/features/products/ui/models_list.dart delete mode 100644 lib/features/products/ui/product_dialogs.dart delete mode 100644 lib/features/products/ui/products_screen.dart delete mode 100644 lib/features/products/ui/round_action_button.dart create mode 100644 lib/features/services/blocs/services_state.dart create mode 100644 lib/features/services/models/service_file_model.dart create mode 100644 lib/features/services/ui/service_action_card.dart delete mode 100644 lib/features/services/ui/service_form_screen.dart create mode 100644 lib/features/services/ui/service_form_screen/action_card.dart create mode 100644 lib/features/services/ui/service_form_screen/attachment_section.dart create mode 100644 lib/features/services/ui/service_form_screen/customer_section.dart create mode 100644 lib/features/services/ui/service_form_screen/energy_service_dialog.dart create mode 100644 lib/features/services/ui/service_form_screen/entertainment_service_card.dart create mode 100644 lib/features/services/ui/service_form_screen/finance_service_dialog.dart create mode 100644 lib/features/services/ui/service_form_screen/general_info_section.dart create mode 100644 lib/features/services/ui/service_form_screen/int_dialogs.dart create mode 100644 lib/features/services/ui/service_form_screen/service_form_screen.dart create mode 100644 lib/features/services/ui/service_form_screen/services_grid.dart create mode 100644 lib/features/services/utils/service_actions.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..95f4a16 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,88 @@ +PODS: + - app_links (7.0.0): + - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Flutter (1.0.0) + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.5) + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - app_links (from `.symlinks/plugins/app_links/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + +EXTERNAL SOURCES: + app_links: + :path: ".symlinks/plugins/app_links/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + app_links: a754cbec3c255bd4bbb4d236ecc06f28cd9a7ce8 + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1e5c694..da85de0 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 44490B82E7859424F77CB04B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 975E96D2C8BBF1CF6A3F5F40 /* Pods_Runner.framework */; }; + 449A3D64DB8C9C60EBDF7DD1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A2C92D305DE434FC3C442B0 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -43,27 +45,44 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1A2C92D305DE434FC3C442B0 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 35D61C73467480800D34D7BC /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 881F7F471B1559BA585653D1 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 8B6E013555080C92974ED449 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 8D4A0EA2456F02E466FCB0E1 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 975E96D2C8BBF1CF6A3F5F40 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AB44F93458B7D70EE383A3A9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + BDDDA09E437D9C0E7B65B3B1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 0170592D7DFD7A1AE8221644 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 449A3D64DB8C9C60EBDF7DD1 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 44490B82E7859424F77CB04B /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -78,6 +97,15 @@ path = RunnerTests; sourceTree = ""; }; + 6A991A28CCED9666CA172E00 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 975E96D2C8BBF1CF6A3F5F40 /* Pods_Runner.framework */, + 1A2C92D305DE434FC3C442B0 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -96,6 +124,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + F5D002C3092D87755D552D32 /* Pods */, + 6A991A28CCED9666CA172E00 /* Frameworks */, ); sourceTree = ""; }; @@ -124,6 +154,20 @@ path = Runner; sourceTree = ""; }; + F5D002C3092D87755D552D32 /* Pods */ = { + isa = PBXGroup; + children = ( + 881F7F471B1559BA585653D1 /* Pods-Runner.debug.xcconfig */, + BDDDA09E437D9C0E7B65B3B1 /* Pods-Runner.release.xcconfig */, + AB44F93458B7D70EE383A3A9 /* Pods-Runner.profile.xcconfig */, + 8B6E013555080C92974ED449 /* Pods-RunnerTests.debug.xcconfig */, + 35D61C73467480800D34D7BC /* Pods-RunnerTests.release.xcconfig */, + 8D4A0EA2456F02E466FCB0E1 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -131,8 +175,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 7385E42426A562D77ADB127F /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 0170592D7DFD7A1AE8221644 /* Frameworks */, ); buildRules = ( ); @@ -148,12 +194,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 55692154E5E0FA98E80084D6 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 6F6F1B58AD2DC9B50492B34B /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -241,6 +289,67 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 55692154E5E0FA98E80084D6 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 6F6F1B58AD2DC9B50492B34B /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 7385E42426A562D77ADB127F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -382,6 +491,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8B6E013555080C92974ED449 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -399,6 +509,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 35D61C73467480800D34D7BC /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -414,6 +525,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8D4A0EA2456F02E466FCB0E1 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 6ac8f4b..3cab5bd 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -8,7 +8,7 @@ import 'package:flux/features/home/ui/home_screen.dart'; import 'package:flux/features/master_data/products/ui/products_screen.dart'; import 'package:flux/features/master_data/store/ui/create_store_screen.dart'; import 'package:flux/features/services/models/service_model.dart'; -import 'package:flux/features/services/ui/service_form_screen.dart'; +import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart'; import 'package:go_router/go_router.dart'; import 'dart:async'; @@ -80,9 +80,15 @@ class AppRouter { path: '/service-form', name: 'service-form', builder: (context, state) { - // Recuperiamo il ServiceModel se passato come extra - final service = state.extra as ServiceModel?; - return ServiceFormScreen(initialService: service); + // Recuperiamo l'oggetto se passato tramite 'extra' + final existingService = state.extra as ServiceModel?; + // Recuperiamo l'ID se presente nell'URL + final serviceId = state.uri.queryParameters['serviceId']; + + return ServiceFormScreen( + serviceId: serviceId ?? existingService?.id, + existingService: existingService, + ); }, ), ], diff --git a/lib/core/utils/string_extensions.dart b/lib/core/utils/string_extensions.dart index 4772f1d..060d8df 100644 --- a/lib/core/utils/string_extensions.dart +++ b/lib/core/utils/string_extensions.dart @@ -19,4 +19,24 @@ extension MyStringExtensions on String? { }) .join(' '); } + + String fileExtension() { + if (this == null || this!.trim().isEmpty) return ''; + + final parts = this!.split('.'); + if (parts.length < 2) return ''; // Nessuna estensione trovata + + return parts.last.toLowerCase(); + } + + String fileNameWithoutExtension() { + if (this == null || this!.trim().isEmpty) return ''; + this!.replaceAll(RegExp(r'[^a-zA-Z0-9\.\-]'), '_'); + final parts = this!.split('.'); + if (parts.length < 2) return this!; // Nessuna estensione trovata + + return parts + .sublist(0, parts.length - 1) + .join('.'); // Ritorna tutto tranne l'ultima parte + } } diff --git a/lib/core/widgets/image_viewer_widget.dart b/lib/core/widgets/image_viewer_widget.dart new file mode 100644 index 0000000..887c2a5 --- /dev/null +++ b/lib/core/widgets/image_viewer_widget.dart @@ -0,0 +1,61 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; // <--- AGGIUNGI QUESTO + +class ImageViewerWidget extends StatelessWidget { + final String? storagePath; // ATTENZIONE: Ora contiene lo storagePath! + final Uint8List? bytes; + + const ImageViewerWidget({super.key, this.storagePath, this.bytes}) + : assert( + (storagePath != null && storagePath != '') || bytes != null, + 'Errore: Devi fornire un Path valido o i bytes del file!', + ); + + // Funzione che chiede le chiavi a Supabase + Future _getSignedUrl() async { + return await Supabase.instance.client.storage + .from('documents') + .createSignedUrl(storagePath!, 60); // Link che si autodistrugge in 60s + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.black), + onPressed: () => Navigator.pop(context), + ), + ), + body: InteractiveViewer( + maxScale: 5.0, + child: Center( + // Se abbiamo i byte, mostriamo subito. Altrimenti usiamo il FutureBuilder! + child: bytes != null + ? Image.memory(bytes!) + : FutureBuilder( + future: _getSignedUrl(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + if (snapshot.hasError) { + return const Text( + "Errore caricamento immagine (Permessi negati?)", + style: TextStyle(color: Colors.red), + ); + } + if (snapshot.hasData) { + return Image.network(snapshot.data!); + } + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/pdf_viewer_widget.dart b/lib/core/widgets/pdf_viewer_widget.dart new file mode 100644 index 0000000..216f4ba --- /dev/null +++ b/lib/core/widgets/pdf_viewer_widget.dart @@ -0,0 +1,97 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:pdfx/pdfx.dart'; +import 'package:internet_file/internet_file.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class PdfViewerWidget extends StatefulWidget { + final String? storagePath; + final Uint8List? bytes; + + const PdfViewerWidget({super.key, this.storagePath, this.bytes}) + : assert( + (storagePath != null && storagePath != '') || bytes != null, + 'Errore: Devi fornire un URL valido o i bytes del file!', + ); + + @override + State createState() => _PdfViewerWidgetState(); +} + +class _PdfViewerWidgetState extends State { + late PdfControllerPinch _pdfController; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _initPdf(); + } + + Future _initPdf() async { + try { + Uint8List pdfData; + + if (widget.bytes != null) { + // SCENARIO 1: Pratica in bozza, file appena scelto (Locale) + pdfData = widget.bytes!; + } else if (widget.storagePath != null && widget.storagePath!.isNotEmpty) { + // SCENARIO 2: Pratica salvata, scarichiamo da Supabase (Remoto) + final signedUrl = await GetIt.I + .get() + .storage + .from('documents') + .createSignedUrl(widget.storagePath!, 60); + pdfData = await InternetFile.get(signedUrl); + } else { + throw Exception("Nessun documento trovato"); + } + + _pdfController = PdfControllerPinch( + document: PdfDocument.openData(pdfData), + ); + + if (mounted) setState(() => _isLoading = false); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = e.toString(); + }); + } + } + } + + @override + void dispose() { + _pdfController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + if (_errorMessage != null) { + return Scaffold( + appBar: AppBar(leading: const CloseButton()), + body: Center(child: Text("Errore: $_errorMessage")), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text("Anteprima PDF"), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ), + body: PdfViewPinch(controller: _pdfController), + ); + } +} diff --git a/lib/features/customers/blocs/customer_bloc.dart b/lib/features/customers/blocs/customer_bloc.dart deleted file mode 100644 index 72a48ad..0000000 --- a/lib/features/customers/blocs/customer_bloc.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flux/features/customers/data/customer_repository.dart'; -import 'package:flux/features/customers/models/customer_model.dart'; -import 'package:get_it/get_it.dart'; - -part 'customer_events.dart'; -part 'customer_state.dart'; - -class CustomerBloc extends Bloc { - final CustomerRepository _repository = GetIt.I(); - - CustomerBloc() : super(const CustomerState()) { - on(_onLoadCustomers); - on(_onCreateCustomer); - on(_onSearchCustomers); - on(_onUpdateCustomer); - } - - Future _onLoadCustomers( - LoadCustomersRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: CustomerStatus.loading)); - try { - final customers = await _repository.getCustomers(event.companyId); - emit( - state.copyWith(status: CustomerStatus.success, customers: customers), - ); - } catch (e) { - emit( - state.copyWith( - status: CustomerStatus.failure, - errorMessage: e.toString(), - ), - ); - } - } - - Future _onCreateCustomer( - CreateCustomerRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: CustomerStatus.loading)); - try { - final newCustomer = await _repository.createCustomer(event.customer); - - // Aggiorniamo la lista locale aggiungendo il nuovo cliente in cima - final updatedList = List.from(state.customers) - ..insert(0, newCustomer); - - emit( - state.copyWith( - status: CustomerStatus.success, - customers: updatedList, - lastCreatedCustomer: - newCustomer, // Lo passiamo per le Dialog "al volo" - ), - ); - } catch (e) { - emit( - state.copyWith( - status: CustomerStatus.failure, - errorMessage: e.toString(), - ), - ); - } - } - - Future _onUpdateCustomer( - UpdateCustomerRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: CustomerStatus.loading)); - try { - // Qui dovresti aggiungere un metodo updateCustomer nel Repository - // Simile al create ma usando .update().eq('id', customer.id) - final updatedCustomer = await _repository.updateCustomer(event.customer); - - final updatedList = List.from(state.customers); - final index = updatedList.indexWhere((c) => c.id == updatedCustomer.id); - - if (index != -1) { - updatedList[index] = updatedCustomer; - } - - emit( - state.copyWith( - status: CustomerStatus.success, - customers: updatedList, - lastCreatedCustomer: updatedCustomer, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: CustomerStatus.failure, - errorMessage: e.toString(), - ), - ); - } - } - - Future _onSearchCustomers( - SearchCustomersRequested event, - Emitter emit, - ) async { - // Non mettiamo loading per evitare flickering durante la digitazione - try { - final results = await _repository.searchCustomers( - event.companyId, - event.query, - ); - emit(state.copyWith(status: CustomerStatus.success, customers: results)); - } catch (_) {} - } -} diff --git a/lib/features/customers/blocs/customer_cubit.dart b/lib/features/customers/blocs/customer_cubit.dart new file mode 100644 index 0000000..7f39674 --- /dev/null +++ b/lib/features/customers/blocs/customer_cubit.dart @@ -0,0 +1,160 @@ +import 'dart:async'; // Serve per il Timer del debounce +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/features/customers/data/customer_repository.dart'; +import 'package:flux/features/customers/models/customer_model.dart'; +import 'package:get_it/get_it.dart'; + +part 'customer_state.dart'; + +class CustomerCubit extends Cubit { + final CustomerRepository _repository = GetIt.I(); + final SessionBloc _sessionBloc = GetIt.I(); + + // Variabile per gestire il debounce della ricerca + Timer? _searchDebounce; + + CustomerCubit() : super(const CustomerState()); + + // --- LETTURA --- + Future loadCustomers() async { + emit(state.copyWith(status: CustomerStatus.loading)); + try { + final customers = await _repository.getCustomers( + _sessionBloc.state.company!.id, + ); + emit( + state.copyWith(status: CustomerStatus.success, customers: customers), + ); + } catch (e) { + emit( + state.copyWith( + status: CustomerStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + // --- CREAZIONE --- + Future createCustomer(CustomerModel customer) async { + emit(state.copyWith(status: CustomerStatus.loading)); + try { + final newCustomer = await _repository.saveCustomer(customer); + + // Aggiorniamo la lista locale aggiungendo il nuovo cliente in cima + final updatedList = List.from(state.customers) + ..insert(0, newCustomer); + + emit( + state.copyWith( + status: CustomerStatus.success, + customers: updatedList, + lastCreatedCustomer: newCustomer, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CustomerStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + // --- AGGIORNAMENTO --- + Future updateCustomer(CustomerModel customer) async { + emit(state.copyWith(status: CustomerStatus.loading)); + try { + final updatedCustomer = await _repository.updateCustomer(customer); + + final updatedList = List.from(state.customers); + final index = updatedList.indexWhere((c) => c.id == updatedCustomer.id); + + if (index != -1) { + updatedList[index] = updatedCustomer; + } + + emit( + state.copyWith( + status: CustomerStatus.success, + customers: updatedList, + lastCreatedCustomer: + updatedCustomer, // Utile se modifichi un cliente appena creato + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CustomerStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + // --- RICERCA CON DEBOUNCE --- + void searchCustomers(String query) { + // 1. Se c'è già una ricerca in attesa (l'utente sta digitando veloce), la annulliamo + if (_searchDebounce?.isActive ?? false) _searchDebounce!.cancel(); + + // 2. Facciamo partire un timer di 400 millisecondi + _searchDebounce = Timer(const Duration(milliseconds: 300), () async { + // Se cancella tutto e la query è vuota, ricarichiamo la lista base + if (query.trim().isEmpty) { + await loadCustomers(); + return; + } + + // Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive + try { + final results = await _repository.searchCustomers( + _sessionBloc.state.company!.id, + query, + ); + emit( + state.copyWith(status: CustomerStatus.success, customers: results), + ); + } catch (e) { + emit( + state.copyWith( + status: CustomerStatus.failure, + errorMessage: e.toString(), + ), + ); + } + }); + } + + Future quickCreateCustomer({ + required String name, + String? phone, + String? email, + }) async { + final newCustomer = CustomerModel( + nome: name, + telefono: phone ?? '', + email: email ?? '', + companyId: _sessionBloc.state.company!.id, + note: '', + ); + + try { + final saved = await _repository.saveCustomer(newCustomer); + // Lo aggiungiamo in cima ai suggerimenti + emit(state.copyWith(customers: [saved, ...state.customers])); + return saved; + } catch (e) { + return null; + } + } + + // Pulizia della memoria quando il Cubit viene distrutto + @override + Future close() { + _searchDebounce?.cancel(); + return super.close(); + } +} diff --git a/lib/features/customers/blocs/customer_events.dart b/lib/features/customers/blocs/customer_events.dart deleted file mode 100644 index baec73f..0000000 --- a/lib/features/customers/blocs/customer_events.dart +++ /dev/null @@ -1,34 +0,0 @@ -part of 'customer_bloc.dart'; - -abstract class CustomerEvent extends Equatable { - const CustomerEvent(); - @override - List get props => []; -} - -// Carica tutti i clienti dell'azienda -class LoadCustomersRequested extends CustomerEvent { - final String companyId; - const LoadCustomersRequested(this.companyId); -} - -// Crea un cliente (usato sia dalla lista che dalla Dialog operazioni) -class CreateCustomerRequested extends CustomerEvent { - final CustomerModel customer; - const CreateCustomerRequested(this.customer); -} - -// Ricerca in tempo reale -class SearchCustomersRequested extends CustomerEvent { - final String companyId; - final String query; - const SearchCustomersRequested(this.companyId, this.query); -} - -class UpdateCustomerRequested extends CustomerEvent { - final CustomerModel customer; - const UpdateCustomerRequested(this.customer); - - @override - List get props => [customer]; -} diff --git a/lib/features/customers/blocs/customer_state.dart b/lib/features/customers/blocs/customer_state.dart index 43134bf..c8789bd 100644 --- a/lib/features/customers/blocs/customer_state.dart +++ b/lib/features/customers/blocs/customer_state.dart @@ -1,12 +1,11 @@ -part of 'customer_bloc.dart'; +part of 'customer_cubit.dart'; enum CustomerStatus { initial, loading, success, failure } class CustomerState extends Equatable { final CustomerStatus status; - final List customers; // Per la lista generale - final CustomerModel? - lastCreatedCustomer; // <--- Fondamentale per la Dialog "al volo" + final List customers; + final CustomerModel? lastCreatedCustomer; final String? errorMessage; const CustomerState({ diff --git a/lib/features/customers/data/customer_repository.dart b/lib/features/customers/data/customer_repository.dart index 7d8d9fe..ddbef62 100644 --- a/lib/features/customers/data/customer_repository.dart +++ b/lib/features/customers/data/customer_repository.dart @@ -1,37 +1,38 @@ -import 'dart:io'; - import 'package:file_picker/file_picker.dart'; +import 'package:flux/core/blocs/session/session_bloc.dart'; +import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:get_it/get_it.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/customer_model.dart'; class CustomerRepository { - final SupabaseClient _client = GetIt.I(); + final SupabaseClient _supabase = GetIt.I(); + final String companyId = GetIt.I.get().state.company!.id; // Crea un nuovo cliente - Future createCustomer(CustomerModel customer) async { + Future saveCustomer(CustomerModel customer) async { try { - final response = await _client + final response = await _supabase .from('customer') - .insert(customer.toJson()) + .upsert(customer.toJson()) .select() .single(); - return CustomerModel.fromJson(response); + return CustomerModel.fromMap(response); } catch (e) { - throw 'Errore durante la creazione del cliente: $e'; + throw 'Errore durante il salvataggio del cliente: $e'; } } Future updateCustomer(CustomerModel customer) async { try { - final response = await _client + final response = await _supabase .from('customer') .update(customer.toJson()) .eq('id', customer.id!) .select() .single(); - return CustomerModel.fromJson(response); + return CustomerModel.fromMap(response); } catch (e) { throw 'Errore durante la modifica del cliente: $e'; } @@ -40,14 +41,17 @@ class CustomerRepository { // Recupera tutti i clienti dell'azienda Future> getCustomers(String companyId) async { try { - final response = await _client + final response = await _supabase .from('customer') - .select('*, customer_file(count)') + .select(''' + *, + customer_file(*) + ''') .eq('company_id', companyId) .eq('is_active', true) .order('nome'); - return (response as List).map((c) => CustomerModel.fromJson(c)).toList(); + return (response as List).map((c) => CustomerModel.fromMap(c)).toList(); } catch (e) { throw 'Errore nel recupero clienti'; } @@ -59,14 +63,14 @@ class CustomerRepository { String query, ) async { try { - final response = await _client + final response = await _supabase .from('customer') .select() .eq('company_id', companyId) .or('nome.ilike.%$query%,telefono.ilike.%$query%') .limit(10); - return (response as List).map((c) => CustomerModel.fromJson(c)).toList(); + return (response as List).map((c) => CustomerModel.fromMap(c)).toList(); } catch (e) { return []; } @@ -75,13 +79,13 @@ class CustomerRepository { /// Recupera i file di un cliente specifico Future> getCustomerFiles(String customerId) async { try { - final response = await _client + final response = await _supabase .from('customer_file') .select() .eq('customer_id', customerId); return (response as List) - .map((f) => CustomerFileModel.fromJson(f)) + .map((f) => CustomerFileModel.fromMap(f)) .toList(); } catch (e) { throw 'Errore recupero file: $e'; @@ -89,8 +93,8 @@ class CustomerRepository { } /// Salva il riferimento del file nel DB - Future saveFileReference(CustomerFileModel file) async { - await _client.from('customer_file').insert(file.toJson()); + Future saveCustomerFile(CustomerFileModel file) async { + await _supabase.from('customer_file').insert(file.toMap()); } /// Carica un file e salva il riferimento nel database @@ -98,15 +102,24 @@ class CustomerRepository { required String customerId, required PlatformFile pickedFile, }) async { + final cleanFileName = pickedFile.name.replaceAll( + RegExp(r'[^a-zA-Z0-9\.\-]'), + '_', + ); + final storagePath = + '$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName'; + final int fileSize = pickedFile.size; + final fileToSave = CustomerFileModel( + customerId: customerId, + name: cleanFileName.fileNameWithoutExtension(), + extension: cleanFileName.fileExtension(), + url: storagePath, + fileSize: fileSize, + ); + final String mimeType = fileToSave.extension.toLowerCase() == 'pdf' + ? 'application/pdf' + : 'image/${fileToSave.extension}'; try { - final user = _client.auth.currentUser; - if (user == null) throw 'Utente non autenticato'; - - final fileName = pickedFile.name; - final extension = pickedFile.extension ?? ''; - final path = - '${user.id}/$customerId/${DateTime.now().millisecondsSinceEpoch}_$fileName'; - // Usiamo bytes invece del path per massima compatibilità if (pickedFile.bytes == null && pickedFile.path == null) { throw 'Impossibile leggere il contenuto del file'; @@ -114,46 +127,43 @@ class CustomerRepository { // Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes if (pickedFile.bytes != null) { - await _client.storage + await _supabase.storage .from('documents') - .uploadBinary(path, pickedFile.bytes!); - } else { - final file = File(pickedFile.path!); - await _client.storage.from('documents').upload(path, file); + .uploadBinary( + storagePath, + pickedFile.bytes!, + fileOptions: FileOptions(contentType: mimeType, upsert: true), + ); } - final String publicUrl = _client.storage - .from('documents') - .getPublicUrl(path); - - final fileRecord = CustomerFileModel( - customerId: customerId, - name: fileName, - url: publicUrl, - extension: extension, - ); - - final response = await _client + final response = await _supabase .from('customer_file') - .insert(fileRecord.toJson()) + .insert(fileToSave.toMap()) .select() .single(); - return CustomerFileModel.fromJson(response); + return CustomerFileModel.fromMap(response); } catch (e) { throw 'Errore durante l\'upload: $e'; } } + Future saveFileReference(CustomerFileModel file) async { + await _supabase.from('customer_file').upsert(file.toMap()); + } + /// Aggiorna la lista degli URL nel database Future updateCustomerDocuments(int id, List urls) async { - await _client.from('customer').update({'document_urls': urls}).eq('id', id); + await _supabase + .from('customer') + .update({'document_urls': urls}) + .eq('id', id); } /// Elimina un file dallo storage Future deleteDocument(String fullPath) async { // Il path dovrebbe essere ricavato dall'URL final path = fullPath.split('documents/').last; - await _client.storage.from('documents').remove([path]); + await _supabase.storage.from('documents').remove([path]); } } diff --git a/lib/features/customers/models/customer_file_model.dart b/lib/features/customers/models/customer_file_model.dart index c19680d..1b7b11d 100644 --- a/lib/features/customers/models/customer_file_model.dart +++ b/lib/features/customers/models/customer_file_model.dart @@ -7,6 +7,7 @@ class CustomerFileModel extends Equatable { final String url; final String extension; final DateTime? createdAt; + final int fileSize; const CustomerFileModel({ this.id, @@ -15,31 +16,76 @@ class CustomerFileModel extends Equatable { required this.url, required this.extension, this.createdAt, + required this.fileSize, }); - factory CustomerFileModel.fromJson(Map json) { + // 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? url, + String? extension, + DateTime? createdAt, + int? fileSize, + }) { return CustomerFileModel( - id: json['id'] as String, - customerId: json['customer_id'], - name: json['name'], - url: json['url'], - extension: json['extension'] ?? '', - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : null, + id: id ?? this.id, + customerId: customerId ?? this.customerId, + name: name ?? this.name, + url: url ?? this.url, + extension: extension ?? this.extension, + createdAt: createdAt ?? this.createdAt, + fileSize: fileSize ?? this.fileSize, ); } - Map toJson() { + factory CustomerFileModel.fromMap(Map map) { + return CustomerFileModel( + id: map['id'] as String, + customerId: map['customer_id'], + name: map['name'], + url: map['url'], + 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, 'url': url, 'extension': extension, + 'file_size': fileSize, }; } @override - List get props => [id, customerId, name, url, extension, createdAt]; + List get props => [ + id, + customerId, + name, + url, + extension, + createdAt, + fileSize, + ]; } diff --git a/lib/features/customers/models/customer_model.dart b/lib/features/customers/models/customer_model.dart index fc2519d..fa55090 100644 --- a/lib/features/customers/models/customer_model.dart +++ b/lib/features/customers/models/customer_model.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flux/core/utils/string_extensions.dart'; +import 'package:flux/features/customers/models/customer_file_model.dart'; class CustomerModel extends Equatable { final String? id; // Bigint in SQL @@ -12,7 +13,7 @@ class CustomerModel extends Equatable { final bool nonDisturbare; final String companyId; // UUID final bool isActive; - final int fileCount; + final List files; const CustomerModel({ this.id, @@ -25,7 +26,7 @@ class CustomerModel extends Equatable { this.nonDisturbare = false, required this.companyId, this.isActive = true, - this.fileCount = 0, + this.files = const [], }); @override @@ -40,7 +41,7 @@ class CustomerModel extends Equatable { nonDisturbare, companyId, isActive, - fileCount, + files, ]; CustomerModel copyWith({ @@ -54,7 +55,7 @@ class CustomerModel extends Equatable { bool? nonDisturbare, String? companyId, bool? isActive, - int? fileCount, + List? files, }) { return CustomerModel( id: id ?? this.id, @@ -67,32 +68,31 @@ class CustomerModel extends Equatable { nonDisturbare: nonDisturbare ?? this.nonDisturbare, companyId: companyId ?? this.companyId, isActive: isActive ?? this.isActive, - fileCount: fileCount ?? this.fileCount, + files: files ?? this.files, ); } - factory CustomerModel.fromJson(Map json) { - int count = 0; - if (json['customer_file'] != null && - (json['customer_file'] as List).isNotEmpty) { - count = json['customer_file'][0]['count'] ?? 0; - } + factory CustomerModel.fromMap(Map map) { return CustomerModel( - id: json['id'] as String, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) + id: map['id'] as String, + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at']) : null, - nome: (json['nome'] as String).myFormat(), - telefono: json['telefono'], - email: json['email'], - note: json['note'] ?? '', - dataUltimoContatto: json['data_ultimo_contatto'] != null - ? DateTime.parse(json['data_ultimo_contatto']) + nome: (map['nome'] as String).myFormat(), + telefono: map['telefono'], + email: map['email'], + note: map['note'] ?? '', + dataUltimoContatto: map['data_ultimo_contatto'] != null + ? DateTime.parse(map['data_ultimo_contatto']) : null, - nonDisturbare: json['non_disturbare'] ?? false, - companyId: json['company_id'] as String, - isActive: json['is_active'] ?? true, - fileCount: count, + nonDisturbare: map['non_disturbare'] ?? false, + companyId: map['company_id'] as String, + isActive: map['is_active'] ?? true, + files: + (map['customer_file'] as List?) + ?.map((x) => CustomerFileModel.fromMap(x)) + .toList() ?? + const [], ); } diff --git a/lib/features/customers/ui/customer_search_sheet.dart b/lib/features/customers/ui/customer_search_sheet.dart new file mode 100644 index 0000000..4cc3ca9 --- /dev/null +++ b/lib/features/customers/ui/customer_search_sheet.dart @@ -0,0 +1,202 @@ +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 eea3bd4..e7c22dd 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_bloc.dart'; import 'package:flux/core/theme/theme.dart'; -import 'package:flux/features/customers/blocs/customer_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/customer_form.dart'; import 'package:go_router/go_router.dart'; @@ -26,16 +26,14 @@ class _CustomersContentState extends State { void _loadInitialCustomers() { final companyId = context.read().state.company?.id; if (companyId != null) { - context.read().add(LoadCustomersRequested(companyId)); + context.read().loadCustomers(); } } void _onSearch(String query) { final companyId = context.read().state.company?.id; if (companyId != null) { - context.read().add( - SearchCustomersRequested(companyId, query), - ); + context.read().searchCustomers(query); } } @@ -57,16 +55,12 @@ class _CustomersContentState extends State { if (customer == null) { // CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create - context.read().add( - CreateCustomerRequested( - customerFromForm.copyWith(companyId: companyId), - ), + context.read().createCustomer( + customerFromForm.copyWith(companyId: companyId), ); } else { // CASO MODIFICA: L'ID e il companyId sono già nel modello - context.read().add( - UpdateCustomerRequested(customerFromForm), - ); + context.read().updateCustomer(customerFromForm); } Navigator.pop(dialogContext); }, @@ -125,7 +119,7 @@ class _CustomersContentState extends State { // LISTA CLIENTI Expanded( - child: BlocBuilder( + child: BlocBuilder( builder: (context, state) { if (state.status == CustomerStatus.loading && state.customers.isEmpty) { @@ -235,11 +229,11 @@ class _CustomerTile extends StatelessWidget { style: TextStyle(color: context.secondaryText), ), ], - if (customer.fileCount > 0) ...[ + if (customer.files.isNotEmpty) ...[ Text(' - ', style: TextStyle(color: context.secondaryText)), Icon(Icons.attach_file, size: 14, color: context.accent), Text( - '${customer.fileCount} doc', + '${customer.files.length} doc', style: TextStyle( color: context.accent, fontWeight: FontWeight.bold, diff --git a/lib/features/customers/ui/quick_customer_dialog.dart b/lib/features/customers/ui/quick_customer_dialog.dart new file mode 100644 index 0000000..3082491 --- /dev/null +++ b/lib/features/customers/ui/quick_customer_dialog.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/customers/blocs/customer_cubit.dart'; + +class QuickCustomerDialog extends StatefulWidget { + final String initialQuery; + + const QuickCustomerDialog({super.key, required this.initialQuery}); + + @override + State createState() => _QuickCustomerDialogState(); +} + +class _QuickCustomerDialogState extends State { + late final TextEditingController _nameCtrl; + final _phoneCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _noteCtrl = TextEditingController(); + + bool _isLoading = false; + + @override + void initState() { + super.initState(); + // Prendiamo tutta la stringa nuda e cruda! + _nameCtrl = TextEditingController(text: widget.initialQuery.trim()); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _phoneCtrl.dispose(); + _emailCtrl.dispose(); + _noteCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + final NavigatorState navigator = Navigator.of(context); + if (_nameCtrl.text.isEmpty) return; + + 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(), + ); + + setState(() => _isLoading = false); + + if (context.mounted) { + navigator.pop(newCustomer); // Restituiamo il cliente creato + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Nuovo Cliente Rapido"), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameCtrl, + autofocus: true, // Focus immediato! + decoration: const InputDecoration( + labelText: "Nome / Ragione Sociale *", + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 8), + TextField( + controller: _phoneCtrl, + decoration: const InputDecoration(labelText: "Telefono"), + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 8), + TextField( + controller: _emailCtrl, + decoration: const InputDecoration(labelText: "Email"), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 8), + TextField( + controller: _noteCtrl, + decoration: const InputDecoration(labelText: "Note rapide"), + maxLines: 2, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Annulla"), + ), + ElevatedButton( + onPressed: _isLoading ? null : _save, + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text("Salva e Usa"), + ), + ], + ); + } +} diff --git a/lib/features/home/ui/dashboard_adaptive_grid.dart b/lib/features/home/ui/dashboard_adaptive_grid.dart index 221c698..191291d 100644 --- a/lib/features/home/ui/dashboard_adaptive_grid.dart +++ b/lib/features/home/ui/dashboard_adaptive_grid.dart @@ -1,6 +1,7 @@ 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 { @@ -36,7 +37,7 @@ class DashboardAdaptiveGrid extends StatelessWidget { label: 'Nuova Op', icon: Icons.add_task, color: context.accent, - onTap: () {}, + onTap: () => startNewService(context), ), DashboardActionCard( label: 'Clienti', diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index d092197..fbde4fd 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/core/theme/theme.dart'; +import 'package:flux/features/auth/bloc/auth_bloc.dart'; import 'package:flux/features/master_data/master_data_hub_content.dart'; import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/ui/services_screen.dart'; -import 'dashboard_content.dart'; // Importiamo il contenuto della dashboard +import 'package:get_it/get_it.dart'; +import 'dashboard_content.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -21,8 +23,6 @@ class _HomeScreenState extends State { @override void initState() { super.initState(); - // Caricamento "silenzioso" all'avvio dell'app - // Usiamo WidgetsBinding per assicurarci che il contesto sia pronto WidgetsBinding.instance.addPostFrameCallback((_) { context.read().loadServices(); }); @@ -34,15 +34,31 @@ class _HomeScreenState extends State { builder: (context, state) { return LayoutBuilder( builder: (context, constraints) { - // Se lo schermo è più largo di 900px usiamo il layout Desktop final bool isLargeScreen = constraints.maxWidth > 900; + final bool veryLargeScreen = constraints.maxWidth > 1200; + final bool isMenuExtended = veryLargeScreen ? true : _extendRailway; return Scaffold( + // --- APPBAR (Solo Mobile) --- + appBar: isLargeScreen + ? null + : AppBar( + title: const Text( + 'FLUX Gestionale', + style: TextStyle(fontWeight: FontWeight.bold), + ), + elevation: 0, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: _buildUserMenu(context, isExtended: false), + ), + ], + ), body: Row( children: [ // --- SIDEBAR (Desktop) --- - if (isLargeScreen) - _buildNavigationRail(constraints.maxWidth > 1200), + if (isLargeScreen) _buildDesktopSidebar(isMenuExtended), // --- CONTENUTO DINAMICO --- Expanded( @@ -61,7 +77,209 @@ class _HomeScreenState extends State { ); } - // --- BOTTOM NAVIGATION BAR (Mobile) --- + // =========================================================================== + // COMPONENTI UI + // =========================================================================== + + // Costruisce l'intera colonna laterale (Rail + Menu Utente in fondo) + Widget _buildDesktopSidebar(bool isExtended) { + return MouseRegion( + // Spostiamo qui la logica dell'hover! + onEnter: (_) => setState(() => _extendRailway = true), + onExit: (_) => setState(() => _extendRailway = false), + child: Container( + color: context.background, // Mantiene lo stesso colore della Rail + child: Column( + children: [ + Expanded( + child: _buildNavigationRail(isExtended), // Ora la Rail è "nuda" + ), + // --- AVATAR E MENU IN FONDO ALLA SIDEBAR --- + Padding( + padding: const EdgeInsets.only(bottom: 24.0, top: 8.0), + child: _buildUserMenu(context, isExtended: isExtended), + ), + ], + ), + ), + ); + } + + Widget _buildNavigationRail(bool isExtended) { + return NavigationRail( + extended: isExtended, + selectedIndex: _selectedIndex, + onDestinationSelected: (index) => setState(() => _selectedIndex = index), + backgroundColor: Colors + .transparent, // Impostato trasparente per prendere il colore del Container padre + indicatorColor: context.accent.withValues(alpha: 0.2), + leading: _buildRailHeader(isExtended), + selectedIconTheme: IconThemeData(color: context.accent, size: 28), + unselectedIconTheme: IconThemeData( + color: context.secondaryText, + size: 24, + ), + selectedLabelTextStyle: TextStyle( + color: context.accent, + fontWeight: FontWeight.bold, + ), + unselectedLabelTextStyle: TextStyle(color: context.secondaryText), + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: Text('Dashboard'), + ), + NavigationRailDestination( + icon: Icon(Icons.receipt_long_outlined), + selectedIcon: Icon(Icons.receipt_long), + label: Text('Servizi'), + ), + NavigationRailDestination( + icon: Icon(Icons.folder_shared_outlined), + selectedIcon: Icon(Icons.folder_shared), + label: Text('Anagrafiche'), + ), + ], + ); + } + + // --- MENU UTENTE (Il "Pro" Avatar) --- + Widget _buildUserMenu(BuildContext context, {required bool isExtended}) { + // Il PopupMenuButton gestisce da solo l'apertura a tendina + return PopupMenuButton( + offset: const Offset( + 0, + -120, + ), // Apre il menu verso l'alto su desktop se necessario + tooltip: 'Profilo e Impostazioni', + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (value) { + if (value == 'logout') { + _showLogoutDialog(context); + } + }, + itemBuilder: (BuildContext context) => [ + const PopupMenuItem( + value: 'profile', + child: ListTile( + leading: Icon(Icons.person_outline), + title: Text('Il mio Profilo'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: 'logout', + child: ListTile( + leading: Icon(Icons.logout, color: Colors.red), + title: Text( + 'Esci', + style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + contentPadding: EdgeInsets.zero, + ), + ), + ], + // L'aspetto del pulsante (Icona tonda o Icona + Nome se esteso) + child: isExtended + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: context.accent.withValues(alpha: 0.1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: context.accent, + child: const Icon( + Icons.person, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 12), + Text( + GetIt.I.get().state.company?.ragioneSociale ?? + "Utente", + style: TextStyle( + fontWeight: FontWeight.bold, + color: context.accent, + ), + ), + ], + ), + ) + : CircleAvatar( + radius: 18, + backgroundColor: context.accent, + child: const Icon(Icons.person, color: Colors.white), + ), + ); + } + + // --- DIALOG DI CONFERMA LOGOUT --- + void _showLogoutDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.logout, color: Colors.red), + SizedBox(width: 8), + Text("Chiudi sessione"), + ], + ), + content: const Text("Sei sicuro di voler uscire dal gestionale?"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text("Annulla"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: () { + Navigator.pop(dialogContext); // Chiude la Dialog + context.read().add( + LogoutRequested(), + ); // Esegue il logout + }, + child: const Text("Esci"), + ), + ], + ), + ); + } + + // ... mantieni gli altri tuoi metodi intatti (_buildRailHeader, _buildPageContent, _buildBottomNavigationBar) + + Widget _buildRailHeader(bool veryLargeScreen) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: GestureDetector( + onTap: veryLargeScreen + ? null + : () => setState(() => _extendRailway = !_extendRailway), + child: _extendRailway + ? Text( + 'FLUX', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 24, + color: context.accent, + ), + ) + : Icon(Icons.bolt, color: context.accent, size: 32), + ), + ); + } + Widget _buildBottomNavigationBar(int selectedIndex) { return BottomNavigationBar( currentIndex: selectedIndex, @@ -85,80 +303,6 @@ class _HomeScreenState extends State { ); } - // --- NAVIGATION RAIL (Desktop) --- - Widget _buildNavigationRail(bool veryLargeScreen) { - return MouseRegion( - onEnter: (_) => setState(() => _extendRailway = true), - onExit: (_) => setState(() => _extendRailway = false), - child: NavigationRail( - // Manteniamo 'extended' dinamico in base alla larghezza per un look Pro - extended: veryLargeScreen ? true : _extendRailway, - selectedIndex: _selectedIndex, - onDestinationSelected: (index) => - setState(() => _selectedIndex = index), - backgroundColor: context.background, - indicatorColor: context.accent.withValues(alpha: 0.2), - - // Header con il logo FLUX o l'icona bolt - leading: _buildRailHeader(veryLargeScreen), - - selectedIconTheme: IconThemeData(color: context.accent, size: 28), - unselectedIconTheme: IconThemeData( - color: context.secondaryText, - size: 24, - ), - - selectedLabelTextStyle: TextStyle( - color: context.accent, - fontWeight: FontWeight.bold, - ), - unselectedLabelTextStyle: TextStyle(color: context.secondaryText), - - destinations: const [ - NavigationRailDestination( - icon: Icon(Icons.dashboard_outlined), - selectedIcon: Icon(Icons.dashboard), - label: Text('Dashboard'), - ), - NavigationRailDestination( - icon: Icon(Icons.receipt_long_outlined), - selectedIcon: Icon(Icons.receipt_long), - label: Text('Servizi'), - ), - NavigationRailDestination( - icon: Icon(Icons.folder_shared_outlined), - selectedIcon: Icon(Icons.folder_shared), - label: Text( - 'Anagrafiche', - ), // Questo caricherà il MasterDataHubContent - ), - ], - ), - ); - } - - Widget _buildRailHeader(bool veryLargeScreen) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: GestureDetector( - onTap: veryLargeScreen - ? null - : () => setState(() => _extendRailway = !_extendRailway), - child: _extendRailway - ? Text( - 'FLUX', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 24, - color: context.accent, - ), - ) - : Icon(Icons.bolt, color: context.accent, size: 32), - ), - ); - } - - // Switch tra le sottopagine Widget _buildPageContent(int index, bool isLargeScreen) { return IndexedStack( index: index, @@ -167,12 +311,8 @@ class _HomeScreenState extends State { isLargeScreen: isLargeScreen, onTabRequested: (idx) => setState(() => _selectedIndex = 2), ), - - ServicesScreen(), - - // L'unico punto di ingresso per tutte le anagrafiche + const ServicesScreen(), MasterDataHubContent( - // Qui gestiamo la navigazione "interna" all'hub onOpenPage: (widget) { Navigator.push( context, diff --git a/lib/features/master_data/products/blocs/product_cubit.dart b/lib/features/master_data/products/blocs/product_cubit.dart index d469b75..b290a2a 100644 --- a/lib/features/master_data/products/blocs/product_cubit.dart +++ b/lib/features/master_data/products/blocs/product_cubit.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_bloc.dart'; @@ -10,9 +11,9 @@ part 'product_state.dart'; class ProductCubit extends Cubit { final ProductRepository _repository = GetIt.I(); - final SessionBloc _sessionBloc; + final SessionBloc _sessionBloc = GetIt.I(); - ProductCubit(this._sessionBloc) : super(const ProductState()); + ProductCubit() : super(const ProductState()); // Caricamento iniziale dei Brand Future loadBrands() async { @@ -102,4 +103,55 @@ class ProductCubit extends Cubit { ); } } + + Future searchModels(String query) async { + if (query.isEmpty) { + // Se cancella tutto, potresti voler ricaricare i modelli del brand selezionato + // o svuotare la lista. Scegliamo di svuotare per pulizia. + emit(state.copyWith(models: [])); + return; + } + + // Opzionale: emetti loading solo se vuoi mostrare una barretta nella UI + // emit(state.copyWith(status: ProductStatus.loading)); + + try { + final results = await _repository.searchModels(query); + emit(state.copyWith(status: ProductStatus.success, models: results)); + } catch (e) { + emit( + state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), + ); + } + } + + Future quickCreateProduct({ + required String brandName, + required String modelName, + }) async { + try { + await loadBrands(); + BrandModel? brand = state.brands.firstWhereOrNull( + (b) => b.name.toLowerCase() == brandName.toLowerCase(), + ); + // 1. Cerchiamo o creiamo il Brand + // (Usa una funzione upsert o una ricerca rapida nel repository) + brand ??= await _repository.upsertBrand( + BrandModel(name: brandName, companyId: _sessionBloc.state.company!.id), + ); + + // 2. Creiamo il Modello legato al Brand + final newModel = await _repository.upsertModel( + ModelModel(brandId: brand.id!, name: modelName), + ); + + // 3. Aggiorniamo lo stato locale così la lista modelli lo vede subito + emit(state.copyWith(models: [newModel, ...state.models])); + + return newModel; + } catch (e) { + emit(state.copyWith(errorMessage: "Errore creazione rapida: $e")); + return null; + } + } } diff --git a/lib/features/master_data/products/data/product_repository.dart b/lib/features/master_data/products/data/product_repository.dart index 874854f..d456ae3 100644 --- a/lib/features/master_data/products/data/product_repository.dart +++ b/lib/features/master_data/products/data/product_repository.dart @@ -4,14 +4,14 @@ import '../models/brand_model.dart'; import '../models/model_model.dart'; class ProductRepository { - final SupabaseClient _client = GetIt.I(); + final SupabaseClient _supabase = GetIt.I(); // --- BRAND --- /// Recupera tutti i brand dell'azienda Future> getBrands(String companyId) async { try { - final response = await _client + final response = await _supabase .from('brand') .select() .eq('company_id', companyId) @@ -27,7 +27,7 @@ class ProductRepository { /// Crea o aggiorna un brand Future upsertBrand(BrandModel brand) async { try { - final response = await _client + final response = await _supabase .from('brand') .upsert(brand.toJson()) .select() @@ -44,7 +44,7 @@ class ProductRepository { /// Recupera i modelli di un brand specifico Future> getModelsByBrand(String brandId) async { try { - final response = await _client + final response = await _supabase .from('model') .select() .eq('brand_id', brandId) @@ -61,7 +61,7 @@ class ProductRepository { /// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato! Future upsertModel(ModelModel model) async { try { - final response = await _client + final response = await _supabase .from('model') .upsert(model.toJson()) .select() @@ -78,9 +78,24 @@ class ProductRepository { /// Disattiva un brand o un modello (Soft Delete per non rompere le FK delle operazioni passate) Future toggleActiveStatus(String table, String id, bool status) async { try { - await _client.from(table).update({'is_active': status}).eq('id', id); + await _supabase.from(table).update({'is_active': status}).eq('id', id); } catch (e) { throw 'Errore durante la modifica dello stato'; } } + + Future> searchModels(String query) async { + try { + final response = await _supabase + .from('model') + .select() + .ilike('name_with_brand', '%$query%') // Cerca ovunque nel nome + .eq('is_active', true) + .limit(10); // Non esageriamo con i risultati + + return (response as List).map((m) => ModelModel.fromJson(m)).toList(); + } catch (e) { + throw 'Errore durante la ricerca: $e'; + } + } } diff --git a/lib/features/master_data/products/models/model_model.dart b/lib/features/master_data/products/models/model_model.dart index 31aa504..4859356 100644 --- a/lib/features/master_data/products/models/model_model.dart +++ b/lib/features/master_data/products/models/model_model.dart @@ -12,7 +12,7 @@ class ModelModel extends Equatable { const ModelModel({ this.id, required this.name, - required this.nameWithBrand, + this.nameWithBrand = '', required this.brandId, this.isActive = true, this.createdAt, diff --git a/lib/features/master_data/products/ui/quick_product_dialog.dart b/lib/features/master_data/products/ui/quick_product_dialog.dart new file mode 100644 index 0000000..aa92bae --- /dev/null +++ b/lib/features/master_data/products/ui/quick_product_dialog.dart @@ -0,0 +1,111 @@ +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/brand_model.dart'; + +class QuickProductDialog extends StatefulWidget { + final List existingBrands; + + const QuickProductDialog({super.key, required this.existingBrands}); + + @override + State createState() => _QuickProductDialogState(); +} + +class _QuickProductDialogState extends State { + final _modelCtrl = TextEditingController(); + String _selectedBrandName = ""; + bool _isLoading = false; + + Future _save() async { + final NavigatorState navigator = Navigator.of(context); + if (_selectedBrandName.isEmpty || _modelCtrl.text.isEmpty) return; + + setState(() => _isLoading = true); + + final newModel = await context.read().quickCreateProduct( + brandName: _selectedBrandName.trim(), + modelName: _modelCtrl.text.trim(), + ); + + setState(() => _isLoading = false); + + if (context.mounted) { + navigator.pop(newModel); // Restituiamo il modello creato + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Nuovo Dispositivo"), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // AUTOCOMPLETE PER IL BRAND LOCALE + Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + final query = textEditingValue.text.toLowerCase(); + // Filtriamo i brand che contengono la stringa cercata + return widget.existingBrands + .map((b) => b.name) + .where((name) => name.toLowerCase().contains(query)); + }, + onSelected: (String selection) { + _selectedBrandName = selection; + }, + fieldViewBuilder: + ( + context, + textEditingController, + focusNode, + onFieldSubmitted, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + autofocus: true, + decoration: const InputDecoration( + labelText: "Marca (es: Apple, Samsung)", + hintText: "Inizia a scrivere...", + ), + onChanged: (val) => _selectedBrandName = val, + ); + }, + ), + const SizedBox(height: 16), + TextField( + controller: _modelCtrl, + decoration: const InputDecoration( + labelText: "Modello (es: iPhone 15 Pro)", + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _save(), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Annulla"), + ), + ElevatedButton( + onPressed: _isLoading ? null : _save, + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text("Crea"), + ), + ], + ); + } +} diff --git a/lib/features/master_data/providers/blocs/provider_cubit.dart b/lib/features/master_data/providers/blocs/provider_cubit.dart index 6d1c3fd..0a7ee5b 100644 --- a/lib/features/master_data/providers/blocs/provider_cubit.dart +++ b/lib/features/master_data/providers/blocs/provider_cubit.dart @@ -7,15 +7,17 @@ import 'package:get_it/get_it.dart'; import '../models/provider_model.dart'; class ProvidersState extends Equatable { - final List allProviders; // Tutti i provider della company - final List - associatedIds; // ID dei provider attivi nello store selezionato + final List allProviders; + final List associatedIds; + // NUOVO CAMPO: Lista dei provider pronti per essere usati nel form pratiche + final List activeProviders; final bool isLoading; final String? errorMessage; const ProvidersState({ this.allProviders = const [], this.associatedIds = const [], + this.activeProviders = const [], // Inizializza this.isLoading = false, this.errorMessage, }); @@ -23,14 +25,18 @@ class ProvidersState extends Equatable { ProvidersState copyWith({ List? allProviders, List? associatedIds, + List? activeProviders, // Aggiungi qui bool? isLoading, String? errorMessage, }) { return ProvidersState( allProviders: allProviders ?? this.allProviders, associatedIds: associatedIds ?? this.associatedIds, + activeProviders: activeProviders ?? this.activeProviders, // Aggiungi qui isLoading: isLoading ?? this.isLoading, - errorMessage: errorMessage, + errorMessage: + errorMessage ?? + this.errorMessage, // Correzione bug: mancava "?? this.errorMessage" nel tuo originale ); } @@ -38,6 +44,7 @@ class ProvidersState extends Equatable { List get props => [ allProviders, associatedIds, + activeProviders, // Aggiungi qui isLoading, errorMessage, ]; @@ -45,9 +52,9 @@ class ProvidersState extends Equatable { class ProvidersCubit extends Cubit { final ProviderRepository _repository = GetIt.I(); - final SessionBloc _sessionBloc; + final SessionBloc _sessionBloc = GetIt.I(); - ProvidersCubit(this._sessionBloc) : super(const ProvidersState()); + ProvidersCubit() : super(const ProvidersState()); // Carica i provider della company e quelli associati a uno store specifico Future loadProviders(StoreModel? store) async { @@ -74,6 +81,23 @@ class ProvidersCubit extends Cubit { } } + Future loadActiveProvidersForStore(String storeId) async { + emit(state.copyWith(isLoading: true)); + try { + final activeList = await _repository.fetchActiveProvidersForStore( + storeId, + ); + emit(state.copyWith(activeProviders: activeList, isLoading: false)); + } catch (e) { + emit( + state.copyWith( + isLoading: false, + errorMessage: "Errore caricamento gestori: $e", + ), + ); + } + } + // Aggiunge o rimuove l'associazione con lo store Future toggleProviderAssociation({ required String providerId, diff --git a/lib/features/master_data/providers/models/provider_model.dart b/lib/features/master_data/providers/models/provider_model.dart index 0e2eddd..a4a9ede 100644 --- a/lib/features/master_data/providers/models/provider_model.dart +++ b/lib/features/master_data/providers/models/provider_model.dart @@ -9,6 +9,7 @@ class ProviderModel extends Equatable { final bool energia; final bool assicurazioni; final bool intrattenimento; + final bool finanziamenti; final bool altro; final bool isActive; final String companyId; @@ -22,6 +23,7 @@ class ProviderModel extends Equatable { required this.energia, required this.assicurazioni, required this.intrattenimento, + required this.finanziamenti, required this.altro, required this.isActive, required this.companyId, @@ -48,6 +50,7 @@ class ProviderModel extends Equatable { energia: map['energia'] ?? false, assicurazioni: map['assicurazioni'] ?? false, intrattenimento: map['intrattenimento'] ?? false, + finanziamenti: map['finanziamenti'] ?? false, altro: map['altro'] ?? false, isActive: map['is_active'] ?? true, companyId: map['company_id'], @@ -63,6 +66,7 @@ class ProviderModel extends Equatable { 'energia': energia, 'assicurazioni': assicurazioni, 'intrattenimento': intrattenimento, + 'finanziamenti': finanziamenti, 'altro': altro, 'is_active': isActive, 'company_id': companyId, @@ -84,6 +88,7 @@ class ProviderModel extends Equatable { energia, assicurazioni, intrattenimento, + finanziamenti, altro, isActive, companyId, @@ -98,6 +103,7 @@ class ProviderModel extends Equatable { bool? energia, bool? assicurazioni, bool? intrattenimento, + bool? finanziamenti, bool? altro, bool? isActive, String? companyId, @@ -111,6 +117,7 @@ class ProviderModel extends Equatable { energia: energia ?? this.energia, assicurazioni: assicurazioni ?? this.assicurazioni, intrattenimento: intrattenimento ?? this.intrattenimento, + finanziamenti: finanziamenti ?? this.finanziamenti, altro: altro ?? this.altro, isActive: isActive ?? this.isActive, companyId: companyId ?? this.companyId, 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 5a3e4b7..2afc48d 100644 --- a/lib/features/master_data/providers/ui/provider_form_sheet.dart +++ b/lib/features/master_data/providers/ui/provider_form_sheet.dart @@ -20,6 +20,7 @@ class _ProviderFormSheetState extends State { late bool _energia; late bool _assicurazioni; late bool _intrattenimento; + late bool _finanziamenti; late bool _altro; late bool _isActive; final List _tempSelectedStoreIds = @@ -38,6 +39,7 @@ class _ProviderFormSheetState extends State { _energia = p?.energia ?? false; _assicurazioni = p?.assicurazioni ?? false; _intrattenimento = p?.intrattenimento ?? false; + _finanziamenti = p?.finanziamenti ?? false; _altro = p?.altro ?? false; _isActive = p?.isActive ?? true; } @@ -61,6 +63,7 @@ class _ProviderFormSheetState extends State { energia: _energia, assicurazioni: _assicurazioni, intrattenimento: _intrattenimento, + finanziamenti: _finanziamenti, altro: _altro, isActive: _isActive, companyId: @@ -130,6 +133,11 @@ class _ProviderFormSheetState extends State { _intrattenimento, (v) => setState(() => _intrattenimento = v), ), + _buildSwitch( + "Finanziamenti", + _finanziamenti, + (v) => setState(() => _finanziamenti = v), + ), _buildSwitch( "Altro/Accessori", _altro, diff --git a/lib/features/master_data/staff/blocs/staff_cubit.dart b/lib/features/master_data/staff/blocs/staff_cubit.dart index c1bff15..64a14b9 100644 --- a/lib/features/master_data/staff/blocs/staff_cubit.dart +++ b/lib/features/master_data/staff/blocs/staff_cubit.dart @@ -10,9 +10,9 @@ part 'staff_state.dart'; class StaffCubit extends Cubit { final StaffRepository _repository = GetIt.I.get(); - final SessionBloc _sessionBloc; + final SessionBloc _sessionBloc = GetIt.I(); - StaffCubit(this._sessionBloc) : super(const StaffState()); + StaffCubit() : super(const StaffState()); // Carica tutto lo staff della compagnia Future loadAllStaff() async { diff --git a/lib/features/master_data/store/bloc/store_cubit.dart b/lib/features/master_data/store/bloc/store_cubit.dart index b36c87c..8d34f5d 100644 --- a/lib/features/master_data/store/bloc/store_cubit.dart +++ b/lib/features/master_data/store/bloc/store_cubit.dart @@ -13,9 +13,9 @@ part 'store_state.dart'; class StoreCubit extends Cubit { final StoreRepository _repository = GetIt.I(); final StaffRepository _staffRepository = GetIt.I(); - final SessionBloc _sessionBloc; + final SessionBloc _sessionBloc = GetIt.I(); - StoreCubit(this._sessionBloc) : super(const StoreState(stores: [])); + StoreCubit() : super(const StoreState(stores: [])); Future createStore(final StoreModel store) async { emit(state.copyWith(status: StoreStatus.loading)); diff --git a/lib/features/products/blocs/product_cubit.dart b/lib/features/products/blocs/product_cubit.dart deleted file mode 100644 index e566657..0000000 --- a/lib/features/products/blocs/product_cubit.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/blocs/session/session_bloc.dart'; -import 'package:flux/features/products/data/product_repository.dart'; -import 'package:flux/features/products/models/brand_model.dart'; -import 'package:flux/features/products/models/model_model.dart'; -import 'package:get_it/get_it.dart'; - -part 'product_state.dart'; - -class ProductCubit extends Cubit { - final ProductRepository _repository = GetIt.I(); - final SessionBloc _sessionBloc; - - ProductCubit(this._sessionBloc) : super(const ProductState()); - - // Caricamento iniziale dei Brand - Future loadBrands() async { - emit(state.copyWith(status: ProductStatus.loading)); - try { - final brands = await _repository.getBrands( - _sessionBloc.state.company!.id, - ); - emit(state.copyWith(status: ProductStatus.success, brands: brands)); - } catch (e) { - emit( - state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), - ); - } - } - - // Selezione Brand e caricamento Modelli - Future selectBrand(BrandModel? brand) async { - if (brand == null) { - emit(state.copyWith(selectedBrand: null, models: [])); - return; - } - - emit(state.copyWith(status: ProductStatus.loading, selectedBrand: brand)); - try { - final models = await _repository.getModelsByBrand(brand.id!); - emit(state.copyWith(status: ProductStatus.success, models: models)); - } catch (e) { - emit( - state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), - ); - } - } - - // Aggiungi/Modifica Brand - Future saveBrand(String name, {String? id}) async { - try { - final brand = BrandModel( - id: id, - name: name, - companyId: _sessionBloc.state.company!.id, - ); - final newBrand = await _repository.upsertBrand(brand); - await loadBrands(); // Ricarichiamo la lista aggiornata - selectBrand(newBrand); - } catch (e) { - emit( - state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), - ); - } - } - - // Aggiungi/Modifica Modello - Future saveModel(String name, {String? id}) async { - if (state.selectedBrand == null) return; - - try { - final model = ModelModel( - id: id, - name: name, - brandId: state.selectedBrand!.id!, - nameWithBrand: '', // Gestito dal trigger SQL - ); - await _repository.upsertModel(model); - await selectBrand( - state.selectedBrand, - ); // Ricarichiamo i modelli del brand - } catch (e) { - emit( - state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), - ); - } - } - - // Disattivazione (Soft Delete) - Future toggleStatus(String table, String id, bool currentStatus) async { - try { - await _repository.toggleActiveStatus(table, id, !currentStatus); - if (table == 'brand') { - await loadBrands(); - } else { - await selectBrand(state.selectedBrand); - } - } catch (e) { - emit( - state.copyWith(status: ProductStatus.error, errorMessage: e.toString()), - ); - } - } -} diff --git a/lib/features/products/blocs/product_state.dart b/lib/features/products/blocs/product_state.dart deleted file mode 100644 index 8d5ab94..0000000 --- a/lib/features/products/blocs/product_state.dart +++ /dev/null @@ -1,44 +0,0 @@ -part of 'product_cubit.dart'; - -enum ProductStatus { initial, loading, success, error } - -class ProductState extends Equatable { - final ProductStatus status; - final List brands; - final List models; - final BrandModel? selectedBrand; // Il brand attualmente selezionato - final String? errorMessage; - - const ProductState({ - this.status = ProductStatus.initial, - this.brands = const [], - this.models = const [], - this.selectedBrand, - this.errorMessage, - }); - - ProductState copyWith({ - ProductStatus? status, - List? brands, - List? models, - BrandModel? selectedBrand, - String? errorMessage, - }) { - return ProductState( - status: status ?? this.status, - brands: brands ?? this.brands, - models: models ?? this.models, - selectedBrand: selectedBrand ?? this.selectedBrand, - errorMessage: errorMessage ?? this.errorMessage, - ); - } - - @override - List get props => [ - status, - brands, - models, - selectedBrand, - errorMessage, - ]; -} diff --git a/lib/features/products/data/product_repository.dart b/lib/features/products/data/product_repository.dart deleted file mode 100644 index 874854f..0000000 --- a/lib/features/products/data/product_repository.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:get_it/get_it.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -import '../models/brand_model.dart'; -import '../models/model_model.dart'; - -class ProductRepository { - final SupabaseClient _client = GetIt.I(); - - // --- BRAND --- - - /// Recupera tutti i brand dell'azienda - Future> getBrands(String companyId) async { - try { - final response = await _client - .from('brand') - .select() - .eq('company_id', companyId) - .eq('is_active', true) - .order('name'); - - return (response as List).map((b) => BrandModel.fromJson(b)).toList(); - } catch (e) { - throw 'Errore nel recupero dei Brand'; - } - } - - /// Crea o aggiorna un brand - Future upsertBrand(BrandModel brand) async { - try { - final response = await _client - .from('brand') - .upsert(brand.toJson()) - .select() - .single(); - - return BrandModel.fromJson(response); - } catch (e) { - throw 'Errore nel salvataggio del Brand'; - } - } - - // --- MODEL --- - - /// Recupera i modelli di un brand specifico - Future> getModelsByBrand(String brandId) async { - try { - final response = await _client - .from('model') - .select() - .eq('brand_id', brandId) - .eq('is_active', true) - .order('name'); - - return (response as List).map((m) => ModelModel.fromJson(m)).toList(); - } catch (e) { - throw 'Errore nel recupero dei modelli'; - } - } - - /// Crea o aggiorna un modello - /// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato! - Future upsertModel(ModelModel model) async { - try { - final response = await _client - .from('model') - .upsert(model.toJson()) - .select() - .single(); - - return ModelModel.fromJson(response); - } catch (e) { - throw 'Errore nel salvataggio del modello'; - } - } - - // --- DELETE (LOGICA) --- - - /// Disattiva un brand o un modello (Soft Delete per non rompere le FK delle operazioni passate) - Future toggleActiveStatus(String table, String id, bool status) async { - try { - await _client.from(table).update({'is_active': status}).eq('id', id); - } catch (e) { - throw 'Errore durante la modifica dello stato'; - } - } -} diff --git a/lib/features/products/models/brand_model.dart b/lib/features/products/models/brand_model.dart deleted file mode 100644 index 72e02b1..0000000 --- a/lib/features/products/models/brand_model.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:flux/core/utils/string_extensions.dart'; - -class BrandModel extends Equatable { - final String? id; - final String name; - final String companyId; - final bool isActive; - final DateTime? createdAt; - - const BrandModel({ - this.id, - required this.name, - required this.companyId, - this.isActive = true, - this.createdAt, - }); - - factory BrandModel.fromJson(Map json) { - return BrandModel( - id: json['id'] as String, - name: (json['name'] as String).myFormat(), - companyId: json['company_id'] as String, - isActive: json['is_active'] as bool? ?? true, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : null, - ); - } - - Map toJson() { - return { - if (id != null) 'id': id, - 'name': name.toLowerCase().trim(), - 'company_id': companyId, - 'is_active': isActive, - }; - } - - BrandModel copyWith({ - String? id, - String? name, - String? companyId, - bool? isActive, - DateTime? createdAt, - }) { - return BrandModel( - id: id ?? this.id, - name: name ?? this.name, - companyId: companyId ?? this.companyId, - isActive: isActive ?? this.isActive, - createdAt: createdAt ?? this.createdAt, - ); - } - - @override - List get props => [id, name, companyId, isActive, createdAt]; -} diff --git a/lib/features/products/models/model_model.dart b/lib/features/products/models/model_model.dart deleted file mode 100644 index 31aa504..0000000 --- a/lib/features/products/models/model_model.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:flux/core/utils/string_extensions.dart'; - -class ModelModel extends Equatable { - final String? id; - final String name; - final String nameWithBrand; - final String brandId; - final bool isActive; - final DateTime? createdAt; - - const ModelModel({ - this.id, - required this.name, - required this.nameWithBrand, - required this.brandId, - this.isActive = true, - this.createdAt, - }); - - factory ModelModel.fromJson(Map json) { - return ModelModel( - id: json['id'] as String, - name: (json['name'] as String).myFormat(), - nameWithBrand: (json['name_with_brand'] as String).myFormat(), - brandId: json['brand_id'] as String, - isActive: json['is_active'] as bool? ?? true, - createdAt: json['created_at'] != null - ? DateTime.parse(json['created_at']) - : null, - ); - } - - Map toJson() { - return { - if (id != null) 'id': id, - 'name': name.toLowerCase().trim(), - 'brand_id': brandId, - 'is_active': isActive, - }; - } - - ModelModel copyWith({ - String? id, - String? name, - String? nameWithBrand, - String? brandId, - bool? isActive, - DateTime? createdAt, - }) { - return ModelModel( - id: id ?? this.id, - name: name ?? this.name, - nameWithBrand: nameWithBrand ?? this.nameWithBrand, - brandId: brandId ?? this.brandId, - isActive: isActive ?? this.isActive, - createdAt: createdAt ?? this.createdAt, - ); - } - - @override - List get props => [ - id, - name, - nameWithBrand, - brandId, - isActive, - createdAt, - ]; -} diff --git a/lib/features/products/ui/brand_selector.dart b/lib/features/products/ui/brand_selector.dart deleted file mode 100644 index 5743587..0000000 --- a/lib/features/products/ui/brand_selector.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/theme/theme.dart'; -import 'package:flux/features/products/blocs/product_cubit.dart'; -import 'package:flux/features/products/models/brand_model.dart'; -import 'package:flux/features/products/ui/product_dialogs.dart'; -import 'package:flux/features/products/ui/round_action_button.dart'; - -class BrandSelector extends StatelessWidget { - final ProductState state; - const BrandSelector(this.state, {super.key}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: context.background, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: context.accent.withValues(alpha: 0.1)), - ), - child: Row( - children: [ - Expanded( - child: DropdownButtonFormField( - initialValue: state.selectedBrand, - //value: state.selectedBrand, - decoration: const InputDecoration( - labelText: "Seleziona Brand", - prefixIcon: Icon(Icons.branding_watermark_outlined), - ), - items: state.brands.map((brand) { - return DropdownMenuItem(value: brand, child: Text(brand.name)); - }).toList(), - onChanged: (brand) => - context.read().selectBrand(brand), - ), - ), - const SizedBox(width: 16), - // Pulsanti rapidi Brand - RoundActionButton( - icon: Icons.add, - onTap: () => showBrandDialog(context), - tooltip: "Nuovo Brand", - ), - if (state.selectedBrand != null) ...[ - const SizedBox(width: 8), - RoundActionButton( - icon: Icons.edit_outlined, - onTap: () => showBrandDialog(context, brand: state.selectedBrand), - tooltip: "Modifica Brand", - ), - ], - ], - ), - ); - } -} diff --git a/lib/features/products/ui/models_list.dart b/lib/features/products/ui/models_list.dart deleted file mode 100644 index d398986..0000000 --- a/lib/features/products/ui/models_list.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/theme/theme.dart'; -import 'package:flux/features/products/blocs/product_cubit.dart'; -import 'package:flux/features/products/ui/product_dialogs.dart'; - -class ModelsList extends StatelessWidget { - final ProductState state; - const ModelsList(this.state, {super.key}); - - @override - Widget build(BuildContext context) { - if (state.selectedBrand == null) { - return const Center( - child: Text("Seleziona un brand per gestire i modelli"), - ); - } - - if (state.status == ProductStatus.loading && state.models.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Modelli di ${state.selectedBrand!.name}", - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ElevatedButton.icon( - onPressed: () => showModelDialog(context), - icon: const Icon(Icons.add), - label: const Text("NUOVO MODELLO"), - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: ListView.separated( - itemCount: state.models.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final model = state.models[index]; - return ListTile( - title: Text(model.name), - subtitle: Text( - model.nameWithBrand, - ), // Quello gestito dal trigger! - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () => showModelDialog(context, model: model), - ), - IconButton( - icon: Icon( - model.isActive - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - color: model.isActive ? context.accent : Colors.grey, - ), - onPressed: () => context - .read() - .toggleStatus('model', model.id!, model.isActive), - ), - ], - ), - ); - }, - ), - ), - ], - ); - } -} diff --git a/lib/features/products/ui/product_dialogs.dart b/lib/features/products/ui/product_dialogs.dart deleted file mode 100644 index d7ac56f..0000000 --- a/lib/features/products/ui/product_dialogs.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/features/products/blocs/product_cubit.dart'; -import 'package:flux/features/products/models/brand_model.dart'; -import 'package:flux/features/products/models/model_model.dart'; - -void showBrandDialog(BuildContext context, {BrandModel? brand}) { - final controller = TextEditingController(text: brand?.name); - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(brand == null ? "Nuovo Brand" : "Modifica Brand"), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration( - labelText: "Nome Brand", - hintText: "es. Apple, Samsung...", - ), - onSubmitted: (_) => _submitBrand(controller, context, brand), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text("Annulla"), - ), - ElevatedButton( - onPressed: () => _submitBrand(controller, context, brand), - child: const Text("Salva"), - ), - ], - ), - ); -} - -void _submitBrand( - TextEditingController controller, - BuildContext context, - BrandModel? brand, -) { - if (controller.text.trim().isNotEmpty) { - context.read().saveBrand(controller.text, id: brand?.id); - Navigator.pop(context); - } -} - -void showModelDialog(BuildContext context, {ModelModel? model}) { - final controller = TextEditingController(text: model?.name); - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(model == null ? "Nuovo Modello" : "Modifica Modello"), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration( - labelText: "Nome Modello", - hintText: "es. iPhone 15, Galaxy S24...", - ), - onSubmitted: (_) => _submitModel(controller, context, model), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text("Annulla"), - ), - ElevatedButton( - onPressed: () => _submitModel(controller, context, model), - child: const Text("Salva"), - ), - ], - ), - ); -} - -void _submitModel( - TextEditingController controller, - BuildContext context, - ModelModel? model, -) { - if (controller.text.isNotEmpty) { - context.read().saveModel(controller.text, id: model?.id); - Navigator.pop(context); - } -} - -void confirmToggle(BuildContext context, String title, VoidCallback onConfirm) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text("Sei sicuro?"), - content: Text("Stai per cambiare lo stato di: $title"), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text("Annulla"), - ), - TextButton( - onPressed: () { - onConfirm(); - Navigator.pop(context); - }, - child: const Text("Conferma", style: TextStyle(color: Colors.red)), - ), - ], - ), - ); -} diff --git a/lib/features/products/ui/products_screen.dart b/lib/features/products/ui/products_screen.dart deleted file mode 100644 index 03fa345..0000000 --- a/lib/features/products/ui/products_screen.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/theme/theme.dart'; -import 'package:flux/features/products/blocs/product_cubit.dart'; -import 'package:flux/features/products/ui/brand_selector.dart'; -import 'package:flux/features/products/ui/models_list.dart'; -import 'package:go_router/go_router.dart'; - -class ProductsScreen extends StatelessWidget { - const ProductsScreen({super.key}); - - @override - Widget build(BuildContext context) { - // Carichiamo i brand appena la pagina viene creata - context.read().loadBrands(); - - return Scaffold( - backgroundColor: context.background, - appBar: AppBar( - backgroundColor: context.background, - elevation: 0, - centerTitle: false, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), // Torna alla Dashboard - ), - title: Text( - "Anagrafica Prodotti", - style: TextStyle( - color: context.primaryText, - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - ), - body: BlocConsumer( - listener: (context, state) { - if (state.status == ProductStatus.error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.errorMessage ?? 'Errore')), - ); - } - }, - builder: (context, state) { - return Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Marche e Modelli", - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: context.accent, - ), - ), - const SizedBox(height: 24), - - // SEZIONE BRAND - BrandSelector(state), - - const SizedBox(height: 32), - - // SEZIONE MODELLI - Expanded(child: ModelsList(state)), - ], - ), - ); - }, - ), - ); - } -} diff --git a/lib/features/products/ui/round_action_button.dart b/lib/features/products/ui/round_action_button.dart deleted file mode 100644 index b174be0..0000000 --- a/lib/features/products/ui/round_action_button.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; - -class RoundActionButton extends StatelessWidget { - final IconData icon; - final VoidCallback onTap; - final String tooltip; - - const RoundActionButton({ - super.key, - required this.icon, - required this.onTap, - required this.tooltip, - }); - - @override - Widget build(BuildContext context) { - return IconButton.filledTonal( - onPressed: onTap, - icon: Icon(icon), - tooltip: tooltip, - ); - } -} diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart index 66cc1a4..db154bd 100644 --- a/lib/features/services/blocs/services_cubit.dart +++ b/lib/features/services/blocs/services_cubit.dart @@ -1,116 +1,323 @@ import 'package:equatable/equatable.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_bloc.dart'; +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'; - -class ServicesState extends Equatable { - final List allServices; - final bool isLoading; - final bool hasReachedMax; // Per lo scroll infinito - final String? errorMessage; - // Parametri di ricerca - final String query; - final DateTimeRange? dateRange; - - const ServicesState({ - this.allServices = const [], - this.isLoading = false, - this.hasReachedMax = false, - this.errorMessage, - this.query = '', - this.dateRange, - }); - ServicesState copyWith({ - List? allServices, - bool? isLoading, - String? errorMessage, - bool? hasReachedMax, - String? query, - DateTimeRange? dateRange, - }) { - return ServicesState( - allServices: allServices ?? this.allServices, - isLoading: isLoading ?? this.isLoading, - errorMessage: - errorMessage, // Se non lo passiamo, torna null (pulisce l'errore) - hasReachedMax: hasReachedMax ?? this.hasReachedMax, - query: query ?? this.query, - dateRange: dateRange ?? this.dateRange, - ); - } - - @override - List get props => [ - allServices, - isLoading, - hasReachedMax, - errorMessage, - query, - dateRange, - ]; -} +import 'package:collection/collection.dart'; +part 'services_state.dart'; class ServicesCubit extends Cubit { final ServicesRepository _repository = GetIt.I(); - final SessionBloc _sessionBloc; + final SessionBloc _sessionBloc = GetIt.I(); - ServicesCubit(this._sessionBloc) : super(const ServicesState()); + ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial)); + + // --- CARICAMENTO E PAGINAZIONE --- - // Carica tutto il pacchetto Future loadServices({bool refresh = false}) async { - // Se non è un refresh e abbiamo già dati, non disturbare Supabase - if (!refresh && state.allServices.isNotEmpty) return; - if (state.isLoading) return; + // Se stiamo già caricando, evitiamo chiamate doppie + if (state.status == ServicesStatus.loading) return; - // Se facciamo refresh, resettiamo tutto - final currentOffset = refresh ? 0 : state.allServices.length; + // Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo + if (!refresh && state.hasReachedMax) return; emit( state.copyWith( - isLoading: true, + 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 = _sessionBloc.state.company?.id; + + if (companyId == null) { + throw Exception("Company ID non trovato nella sessione"); + } + final newServices = await _repository.fetchServices( - companyId: _sessionBloc.state.company!.id, + 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( - isLoading: false, - allServices: List.from(state.allServices)..addAll(newServices), - hasReachedMax: - newServices.length < - 50, // Se ne arrivano meno di 50, siamo alla fine + status: ServicesStatus.ready, + allServices: refresh + ? newServices + : [...state.allServices, ...newServices], + hasReachedMax: reachedMax, ), ); } catch (e) { - emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + 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, dateRange: range)); - loadServices(refresh: true); // Applica i filtri e riparte da zero + emit( + state.copyWith( + query: query ?? state.query, + dateRange: range ?? state.dateRange, + ), + ); + loadServices(refresh: true); } - // Salva e ricarica - Future addService(ServiceModel service) async { - emit(state.copyWith(isLoading: 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: _sessionBloc.state.selectedStore?.id ?? '', + number: '', // Sarà compilato dall'utente + createdAt: DateTime.now(), + companyId: _sessionBloc.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}) async { + if (state.currentService == null) return; + + emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null)); try { - await _repository.saveFullService(service); - await loadServices(); // Ricarichiamo la lista aggiornata + // 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente + final serviceToSave = state.currentService!.copyWith(isBozza: isBozza); + + // 2. Salvataggio corazzato + await _repository.saveFullService(serviceToSave); + + // 3. Reset e ricaricamento + emit(state.copyWith(status: ServicesStatus.saved, currentService: null)); + await loadServices(refresh: true); } catch (e) { - emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + 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(), + url: '', + 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(ServiceFileModel file) async { + final currentService = state.currentService; + if (currentService == null || currentService.customerId == null) { + // Magari mostra un errore: non posso copiare al cliente se non c'è un cliente! + return; + } + + emit(state.copyWith(status: ServicesStatus.loading)); + + try { + // 1. Salviamo la pratica (Bozza o definitiva che sia) + // Questo assicura che il file sia stato caricato su Storage e censito su DB + await saveCurrentService(isBozza: currentService.isBozza); + + // 2. Recuperiamo il file "aggiornato" + // Dopo il saveCurrentService, il file che prima era "locale" ora ha un URL. + // Lo cerchiamo nella lista aggiornata per nome o estensione. + final savedFile = state.currentService!.files.firstWhere( + (f) => f.name == file.name && f.extension == file.extension, + orElse: () => file, + ); + + if (savedFile.url.isEmpty) { + throw Exception( + "Errore: URL del file non trovato dopo il salvataggio.", + ); + } + + // 3. Chiamiamo il repository per la copia fisica nel database del cliente + // Passiamo l'URL del file e l'ID del cliente + await _repository.copyFileToCustomer( + file: savedFile, + customerId: currentService.customerId!, + ); + + // 4. Feedback all'utente + // Potresti emettere un successo o mostrare un toast + emit(state.copyWith(status: ServicesStatus.success)); + } catch (e) { + emit( + state.copyWith( + status: ServicesStatus.failure, + errorMessage: "Errore durante la copia del file: $e", + ), + ); } } } diff --git a/lib/features/services/blocs/services_state.dart b/lib/features/services/blocs/services_state.dart new file mode 100644 index 0000000..00439fd --- /dev/null +++ b/lib/features/services/blocs/services_state.dart @@ -0,0 +1,54 @@ +part of 'services_cubit.dart'; + +enum ServicesStatus { initial, loading, ready, saving, saved, success, failure } + +class ServicesState extends Equatable { + final ServicesStatus status; + final List allServices; + final ServiceModel? currentService; // La bozza che stiamo editando + final String? errorMessage; + final String query; + final DateTimeRange? dateRange; + final bool hasReachedMax; + + const ServicesState({ + required this.status, + this.allServices = const [], + this.currentService, + this.errorMessage, + this.query = '', + this.dateRange, + this.hasReachedMax = false, + }); + + ServicesState copyWith({ + ServicesStatus? status, + List? allServices, + ServiceModel? currentService, + String? errorMessage, + String? query, + DateTimeRange? dateRange, + bool? hasReachedMax, + }) { + return ServicesState( + status: status ?? this.status, + allServices: allServices ?? this.allServices, + currentService: currentService ?? this.currentService, + errorMessage: errorMessage, + query: query ?? this.query, + dateRange: dateRange ?? this.dateRange, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, + ); + } + + @override + List get props => [ + status, + allServices, + currentService, + errorMessage, + query, + dateRange, + hasReachedMax, + ]; +} diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart index 7da3966..f81f417 100644 --- a/lib/features/services/data/services_repository.dart +++ b/lib/features/services/data/services_repository.dart @@ -1,9 +1,38 @@ import 'package:flutter/material.dart'; +import 'package:flux/core/blocs/session/session_bloc.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({ @@ -19,10 +48,11 @@ class ServicesRepository { .from('service') .select(''' *, - customer(name, surname), + customer(nome), energy_service(*), fin_service(*), - entertainment_service(*) + entertainment_service(*), + service_file(*) ''') .eq('company_id', companyId); @@ -36,7 +66,7 @@ class ServicesRepository { 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.name.ilike.%$searchTerm%,customer.surname.ilike.%$searchTerm%', + 'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.nome.ilike.%$searchTerm%', ); } @@ -55,8 +85,7 @@ class ServicesRepository { // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) --- Future saveFullService(ServiceModel service) async { try { - // 1. Inseriamo il record principale - // Se service.id è null, Supabase fa INSERT. Se c'è, fa UPDATE (grazie all'upsert o gestione manuale) + // 1. Upsert del record principale final serviceData = await _supabase .from('service') .upsert(service.toMap()) @@ -65,45 +94,103 @@ class ServicesRepository { final String newId = serviceData['id']; - // 2. Pulizia vecchi record figli (necessaria se è una MODIFICA) - // Se stiamo modificando, cancelliamo i vecchi per reinserire i nuovi (più semplice) + // 2. MODIFICA: Pulizia atomica dei figli + // Se stiamo modificando (id != null), resettiamo le tabelle collegate if (service.id != null) { - await _supabase.from('energy_service').delete().eq('service_id', newId); - await _supabase.from('fin_service').delete().eq('service_id', newId); - await _supabase - .from('entertainment_service') - .delete() - .eq('service_id', newId); + 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 EnergyServices + // 3. Inserimento dei moduli in parallelo per velocità + final List insertTasks = []; + if (service.energyServices.isNotEmpty) { - final List> toInsert = []; - for (var item in service.energyServices) { - toInsert.add(item.copyWith(serviceId: newId).toMap()); - } - await _supabase.from('energy_service').insert(toInsert); + insertTasks.add( + _supabase + .from('energy_service') + .insert( + service.energyServices + .map((item) => item.copyWith(serviceId: newId).toMap()) + .toList(), + ), + ); } - // 4. Inserimento FinServices if (service.finServices.isNotEmpty) { - final List> toInsert = []; - for (var item in service.finServices) { - toInsert.add(item.copyWith(serviceId: newId).toMap()); - } - await _supabase.from('fin_service').insert(toInsert); + insertTasks.add( + _supabase + .from('fin_service') + .insert( + service.finServices + .map((item) => item.copyWith(serviceId: newId).toMap()) + .toList(), + ), + ); } - // 5. Inserimento EntertainmentServices if (service.entertainmentServices.isNotEmpty) { - final List> toInsert = []; - for (var item in service.entertainmentServices) { - toInsert.add(item.copyWith(serviceId: newId).toMap()); + insertTasks.add( + _supabase + .from('entertainment_service') + .insert( + service.entertainmentServices + .map((item) => item.copyWith(serviceId: newId).toMap()) + .toList(), + ), + ); + } + + if (insertTasks.isNotEmpty) { + await Future.wait(insertTasks); + } + if (service.files.isNotEmpty) { + final List uploadTasks = []; + + for (var file in service.files) { + 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, url: storagePath); + + // Creiamo una funzione asincrona per caricare file e scrivere nel DB + Future uploadAndLink() async { + // Determiniamo il MIME type corretto in base all'estensione + + // 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, // Diciamo a Supabase esattamente cos'è! + upsert: + true, // Opzionale: sovrascrive se esiste già un file con lo stesso nome + ), + ); + + await _supabase.from('service_file').insert(fileToSave.toMap()); + } + + uploadTasks.add(uploadAndLink()); } - await _supabase.from('entertainment_service').insert(toInsert); + + // Eseguiamo tutti gli upload in parallelo per la massima velocità + await Future.wait(uploadTasks); } } catch (e) { - throw Exception('Errore durante il salvataggio: $e'); + // Qui potresti aggiungere una logica di "rollback manuale" se necessario + throw Exception('Errore durante il salvataggio corazzato: $e'); } } @@ -115,4 +202,50 @@ class ServicesRepository { 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 + } + } + + Future copyFileToCustomer({ + required ServiceFileModel file, + required String customerId, + }) async { + CustomerFileModel fileToCopy = CustomerFileModel( + customerId: customerId, + name: file.name, + url: file.url, + extension: file.extension, + fileSize: file.fileSize, + ); + await _customerRepository.saveCustomerFile(fileToCopy); + } } diff --git a/lib/features/services/models/service_file_model.dart b/lib/features/services/models/service_file_model.dart new file mode 100644 index 0000000..689577b --- /dev/null +++ b/lib/features/services/models/service_file_model.dart @@ -0,0 +1,98 @@ +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; + +class ServiceFileModel extends Equatable { + final String? id; + final DateTime? createdAt; + final String name; + final String extension; + final String url; + final String serviceId; + final int fileSize; + final Uint8List? localBytes; + + const ServiceFileModel({ + this.id, + this.createdAt, + required this.name, + required this.extension, + required this.url, + required this.serviceId, + required this.fileSize, + this.localBytes, + }); + + // Trasforma i byte in qualcosa di leggibile (KB, MB, GB) + String get sizeFormatted { + if (fileSize <= 0) return "0 B"; + const suffixes = ["B", "KB", "MB", "GB", "TB"]; + var i = (fileSize.toString().length - 1) ~/ 3; + if (i >= suffixes.length) i = suffixes.length - 1; + double num = fileSize / (1 << (i * 10)); + return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}"; + } + + bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf'; + + ServiceFileModel copyWith({ + String? id, + DateTime? createdAt, + String? name, + String? extension, + String? url, + String? serviceId, + int? fileSize, + Uint8List? localBytes, + }) { + return ServiceFileModel( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + name: name ?? this.name, + extension: extension ?? this.extension, + url: url ?? this.url, + serviceId: serviceId ?? this.serviceId, + fileSize: fileSize ?? this.fileSize, + localBytes: localBytes ?? this.localBytes, + ); + } + + factory ServiceFileModel.fromMap(Map map) { + return ServiceFileModel( + id: map['id'] as String, + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at']) + : null, + name: map['name'] ?? '', + extension: map['extension'] ?? '', + url: map['url'] ?? '', + serviceId: map['service_id']?.toString() ?? '', + fileSize: map['file_size'] is int + ? map['file_size'] + : int.tryParse(map['file_size']?.toString() ?? '0') ?? 0, + ); + } + + Map toMap() { + return { + if (id != null) 'id': id, + 'name': name, + 'extension': extension, + 'url': url, + 'service_id': serviceId, + 'file_size': fileSize, + }; + } + + @override + List get props => [ + id, + createdAt, + name, + extension, + url, + serviceId, + fileSize, + localBytes, + ]; +} diff --git a/lib/features/services/models/service_model.dart b/lib/features/services/models/service_model.dart index 5bbfc10..df58125 100644 --- a/lib/features/services/models/service_model.dart +++ b/lib/features/services/models/service_model.dart @@ -1,7 +1,9 @@ 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; @@ -14,6 +16,7 @@ class ServiceModel extends Equatable { final String note; final bool resultOk; final String? customerDisplayName; + final String companyId; // Telefonia final int al; @@ -27,6 +30,9 @@ class ServiceModel extends Equatable { final List finServices; final List entertainmentServices; + // ALLEGATI (Aggiunto) + final List files; + const ServiceModel({ this.id, this.createdAt, @@ -45,7 +51,9 @@ class ServiceModel extends Equatable { this.energyServices = const [], this.finServices = const [], this.entertainmentServices = const [], + this.files = const [], // <-- Aggiunto default vuoto this.customerDisplayName, + required this.companyId, }); ServiceModel copyWith({ @@ -66,7 +74,9 @@ class ServiceModel extends Equatable { List? energyServices, List? finServices, List? entertainmentServices, + List? files, // <-- Aggiunto String? customerDisplayName, + String? companyId, }) { return ServiceModel( id: id ?? this.id, @@ -87,7 +97,9 @@ class ServiceModel extends Equatable { finServices: finServices ?? this.finServices, entertainmentServices: entertainmentServices ?? this.entertainmentServices, + files: files ?? this.files, // <-- Aggiunto customerDisplayName: customerDisplayName ?? this.customerDisplayName, + companyId: companyId ?? this.companyId, ); } @@ -110,17 +122,21 @@ class ServiceModel extends Equatable { energyServices, finServices, entertainmentServices, + files, // <-- Aggiunto customerDisplayName, + companyId, ]; factory ServiceModel.fromMap(Map map) { return ServiceModel( - id: map['id'], - createdAt: DateTime.parse(map['created_at']), - storeId: map['store_id'], - employeeId: map['employee_id'], - customerId: map['customer_id'], - number: map['number'] ?? '', + 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, @@ -130,7 +146,7 @@ class ServiceModel extends Equatable { unica: map['unica'] ?? 0, telepass: map['telepass'] ?? 0, - // Mappaggio delle liste collegate (se incluse nella query) + // Estrazione sicura liste collegate energyServices: (map['energy_service'] as List?) ?.map((x) => EnergyServiceModel.fromMap(x)) @@ -146,9 +162,19 @@ class ServiceModel extends Equatable { ?.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']['name']} ${map['customer']['surname']}" - : "Cliente sconosciuto", + ? "${map['customer']['nome'] ?? ''}".myFormat() + : "Cliente non assegnato", + companyId: map['company_id'] as String, ); } @@ -167,6 +193,7 @@ class ServiceModel extends Equatable { '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_action_card.dart b/lib/features/services/ui/service_action_card.dart new file mode 100644 index 0000000..ef06dc7 --- /dev/null +++ b/lib/features/services/ui/service_action_card.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +class ServiceActionCard extends StatelessWidget { + final String title; + final IconData icon; + final VoidCallback onTap; + final Color color; + final int count; + const ServiceActionCard({ + super.key, + required this.title, + required this.icon, + required this.onTap, + required this.color, + this.count = 0, + }); + + @override + Widget build(BuildContext context) { + final bool isActive = count > 0; + + return Card( + elevation: isActive ? 4 : 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: isActive ? color : Colors.transparent, + width: 2, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + width: 110, // Dimensione fissa per farle stare in una Row/Wrap + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: isActive ? color.withValues(alpha: 0.1) : Colors.transparent, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: isActive ? color : Colors.grey.shade400, + size: 32, + ), + const SizedBox(height: 8), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + color: isActive ? color : Colors.grey.shade600, + fontSize: 12, + ), + ), + if (isActive) ...[ + const SizedBox(height: 4), + CircleAvatar( + radius: 10, + backgroundColor: color, + child: Text( + count.toString(), + style: const TextStyle(fontSize: 10, color: Colors.white), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/services/ui/service_form_screen.dart b/lib/features/services/ui/service_form_screen.dart deleted file mode 100644 index d63599c..0000000 --- a/lib/features/services/ui/service_form_screen.dart +++ /dev/null @@ -1,150 +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/energy_service_model.dart'; -import 'package:flux/features/services/models/service_model.dart'; - -class ServiceFormScreen extends StatefulWidget { - final ServiceModel? initialService; // Se nullo, è un nuovo inserimento - - const ServiceFormScreen({super.key, this.initialService}); - - @override - State createState() => _ServiceFormScreenState(); -} - -class _ServiceFormScreenState extends State { - late ServiceModel currentService; - - @override - void initState() { - super.initState(); - // Se passiamo un servizio esistente lo carichiamo, altrimenti ne creiamo uno "vuoto" - currentService = - widget.initialService ?? - ServiceModel( - storeId: 'ID_NEGOZIO_QUI', // Poi lo prenderai dal profilo utente - number: '', - energyServices: const [], - finServices: const [], - entertainmentServices: const [], - ); - } - - // Metodo generico per aggiungere un servizio energia - void _addEnergy() { - setState(() { - final newList = - List.from(currentService.energyServices)..add( - EnergyServiceModel( - type: EnergyType.luce, // Default - expiration: DateTime.now().add(const Duration(days: 365)), - providerId: '', // Lo sceglierà l'utente - ), - ); - currentService = currentService.copyWith(energyServices: newList); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text( - widget.initialService == null ? "Nuova Pratica" : "Modifica", - ), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // --- SEZIONE DATI GENERALI --- - TextField( - decoration: const InputDecoration(labelText: "Numero Pratica"), - onChanged: (v) => - currentService = currentService.copyWith(number: v), - ), - - const Divider(height: 32), - - // --- SEZIONE ENERGY --- - _SectionHeader( - title: "Energia (Luce/Gas)", - onAdd: _addEnergy, - icon: Icons.electric_bolt, - ), - ...currentService.energyServices.asMap().entries.map((entry) { - int idx = entry.key; - var item = entry.value; - return Card( - child: ListTile( - title: Text( - "${item.type.name.toUpperCase()} - Scadenza: ${item.expiration.year}", - ), - trailing: IconButton( - icon: const Icon(Icons.delete, color: Colors.red), - onPressed: () { - setState(() { - final newList = List.from( - currentService.energyServices, - )..removeAt(idx); - currentService = currentService.copyWith( - energyServices: newList, - ); - }); - }, - ), - ), - ); - }), - - const SizedBox(height: 40), - - // --- BOTTONE SALVA --- - ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(50), - ), - onPressed: () { - context.read().addService(currentService); - Navigator.pop(context); - }, - child: const Text("SALVA TUTTO"), - ), - ], - ), - ), - ); - } -} - -class _SectionHeader extends StatelessWidget { - final String title; - final VoidCallback onAdd; - final IconData icon; - - const _SectionHeader({ - required this.title, - required this.onAdd, - required this.icon, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Icon(icon, color: Colors.orange), - const SizedBox(width: 8), - Text( - title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const Spacer(), - IconButton( - onPressed: onAdd, - icon: const Icon(Icons.add_circle, color: Colors.green, size: 30), - ), - ], - ); - } -} diff --git a/lib/features/services/ui/service_form_screen/action_card.dart b/lib/features/services/ui/service_form_screen/action_card.dart new file mode 100644 index 0000000..51f0b42 --- /dev/null +++ b/lib/features/services/ui/service_form_screen/action_card.dart @@ -0,0 +1,85 @@ +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 new file mode 100644 index 0000000..f1e161f --- /dev/null +++ b/lib/features/services/ui/service_form_screen/attachment_section.dart @@ -0,0 +1,184 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/widgets/image_viewer_widget.dart'; +import 'package:flux/core/widgets/pdf_viewer_widget.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().addAttachments(result.files); + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final files = state.currentService?.files ?? []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "DOCUMENTI ALLEGATI", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + letterSpacing: 1.2, + ), + ), + OutlinedButton.icon( + icon: const Icon(Icons.attach_file), + label: const Text("Aggiungi File"), + onPressed: () => _pickFiles(context), + ), + ], + ), + const SizedBox(height: 12), + + if (files.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.shade300, + style: BorderStyle.solid, + ), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, + ), + child: const Text( + "Nessun documento allegato alla bozza.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: files.length, + itemBuilder: (context, index) { + final file = files[index]; + // Calcoliamo la dimensione in MB + final sizeMb = (file.fileSize / (1024 * 1024)) + .toStringAsFixed(2); + + // Scegliamo un'icona in base al tipo di file + final isPdf = file.extension.toLowerCase() == 'pdf'; + + return GestureDetector( + onTap: () => _handleSingleClick(context, file), + onDoubleTap: () => _handleDoubleClick(context, file), + child: Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.grey.shade300), + ), + child: ListTile( + leading: Icon( + isPdf ? Icons.picture_as_pdf : Icons.image, + color: isPdf ? Colors.red : Colors.blue, + size: 32, + ), + title: Text( + file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text("$sizeMb MB"), + trailing: IconButton( + icon: const Icon( + Icons.delete_outline, + color: Colors.red, + ), + onPressed: () => context + .read() + .removeAttachment(index), + ), + ), + ), + ); + }, + ), + ], + ); + }, + ); + } + + // --- LOGICA DI COPIA AL CLIENTE --- + void _handleSingleClick(BuildContext context, ServiceFileModel file) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Copia nei documenti Cliente"), + content: const Text( + "Vuoi copiare questo file 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(file); + }, + 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.url.isNotEmpty ? file.url : null, + bytes: file.localBytes, + ) + : ImageViewerWidget( + storagePath: file.url.isNotEmpty ? file.url : 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 new file mode 100644 index 0000000..dd1e752 --- /dev/null +++ b/lib/features/services/ui/service_form_screen/customer_section.dart @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..58da992 --- /dev/null +++ b/lib/features/services/ui/service_form_screen/energy_service_dialog.dart @@ -0,0 +1,417 @@ +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 new file mode 100644 index 0000000..9243a96 --- /dev/null +++ b/lib/features/services/ui/service_form_screen/entertainment_service_card.dart @@ -0,0 +1,393 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_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/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 new file mode 100644 index 0000000..b03f4d7 --- /dev/null +++ b/lib/features/services/ui/service_form_screen/finance_service_dialog.dart @@ -0,0 +1,479 @@ +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 new file mode 100644 index 0000000..e2330bf --- /dev/null +++ b/lib/features/services/ui/service_form_screen/general_info_section.dart @@ -0,0 +1,111 @@ +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 new file mode 100644 index 0000000..cbda2e7 --- /dev/null +++ b/lib/features/services/ui/service_form_screen/int_dialogs.dart @@ -0,0 +1,158 @@ +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 new file mode 100644 index 0000000..3068c00 --- /dev/null +++ b/lib/features/services/ui/service_form_screen/service_form_screen.dart @@ -0,0 +1,173 @@ +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); + } else if (state.status == ServicesStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Errore: ${state.errorMessage ?? ''}"), + backgroundColor: Colors.red, + ), + ); + } + }, + 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 new file mode 100644 index 0000000..28e282a --- /dev/null +++ b/lib/features/services/ui/service_form_screen/services_grid.dart @@ -0,0 +1,196 @@ +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/ui/services_screen.dart b/lib/features/services/ui/services_screen.dart index aea5315..dfb53f9 100644 --- a/lib/features/services/ui/services_screen.dart +++ b/lib/features/services/ui/services_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/models/service_model.dart'; +import 'package:flux/features/services/utils/service_actions.dart'; import 'package:go_router/go_router.dart'; // Importa i tuoi modelli e cubit @@ -20,6 +21,8 @@ class _ServicesScreenState extends State { super.initState(); // Agganciamo il listener per la paginazione (Scroll Infinito) _scrollController.addListener(_onScroll); + // Carichiamo i servizi iniziali + context.read().loadServices(); } void _onScroll() { @@ -60,7 +63,8 @@ class _ServicesScreenState extends State { body: BlocBuilder( builder: (context, state) { // 1. Stato di caricamento iniziale - if (state.isLoading && state.allServices.isEmpty) { + if (state.status == ServicesStatus.loading && + state.allServices.isEmpty) { return const Center(child: CircularProgressIndicator()); } @@ -111,7 +115,7 @@ class _ServicesScreenState extends State { }, ), floatingActionButton: FloatingActionButton( - onPressed: () => context.pushNamed('service-form'), // GoRouter + onPressed: () => startNewService(context), child: const Icon(Icons.add), ), ); @@ -171,7 +175,12 @@ class _ServicesScreenState extends State { ], ), trailing: const Icon(Icons.chevron_right), - onTap: () => context.pushNamed('service-form', extra: service), + onTap: () => context.pushNamed( + 'service-form', + extra: service, // <-- LA MAGIA È QUI: Passa l'oggetto intero! + // Teniamo anche il parametro URL per coerenza di routing + queryParameters: service.id != null ? {'serviceId': service.id!} : {}, + ), ), ); } diff --git a/lib/features/services/utils/service_actions.dart b/lib/features/services/utils/service_actions.dart new file mode 100644 index 0000000..4a51388 --- /dev/null +++ b/lib/features/services/utils/service_actions.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_bloc.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.selectedStore?.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/main.dart b/lib/main.dart index b8b8cb6..9b0ff8b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:flux/core/theme/bloc/theme_bloc.dart'; import 'package:flux/features/auth/bloc/auth_bloc.dart'; import 'package:flux/features/company/bloc/company_bloc.dart'; import 'package:flux/features/company/data/company_repository.dart'; -import 'package:flux/features/customers/blocs/customer_bloc.dart'; +import 'package:flux/features/customers/blocs/customer_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'; @@ -37,8 +37,16 @@ void main() async { BlocProvider( create: (context) => ThemeBloc()..add(LoadThemeEvent()), ), - BlocProvider( - create: (context) => SessionBloc()..add(AppStarted()), + BlocProvider(create: (_) => GetIt.I()), + BlocProvider(create: (_) => AuthBloc()), + BlocProvider(create: (_) => CompanyBloc()), + BlocProvider(create: (_) => StoreCubit()..loadStores()), + BlocProvider(create: (_) => CustomerCubit()), + BlocProvider(create: (_) => ProductCubit()), + BlocProvider(create: (_) => StaffCubit()..loadAllStaff()), + BlocProvider(create: (_) => ServicesCubit()), + BlocProvider( + create: (_) => ProvidersCubit()..loadProviders(null), ), ], child: const FluxApp(), @@ -65,6 +73,7 @@ Future setupLocator() async { getIt.registerLazySingleton(() => StaffRepository()); getIt.registerLazySingleton(() => ServicesRepository()); getIt.registerLazySingleton(() => ProviderRepository()); + getIt.registerSingleton(SessionBloc()..add(AppStarted())); } class FluxApp extends StatefulWidget { @@ -86,40 +95,51 @@ class _FluxAppState extends State { @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider(create: (_) => AuthBloc()), - BlocProvider(create: (_) => CompanyBloc()), - BlocProvider( - create: (_) => StoreCubit(context.read())..loadStores(), + return BlocBuilder( + builder: (context, state) { + if (state.status == SessionStatus.unknown) { + return _buildLoadingScreen(); + } + return BlocBuilder( + builder: (context, state) { + return MaterialApp.router( + title: 'FLUX Gestionale', + debugShowCheckedModeBanner: false, + theme: fluxLightTheme, + darkTheme: fluxDarkTheme, + themeMode: state.currentTheme.themeMode, + routerConfig: _router, // Usa l'istanza mantenuta nello stato + ); + }, + ); + }, + ); + } + + // Una semplice schermata di caricamento coerente con il brand + Widget _buildLoadingScreen() { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Qui puoi mettere il tuo logo + const Icon(Icons.bolt, size: 64, color: Colors.blue), + const SizedBox(height: 24), + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text( + "Inizializzazione sessione...", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ], + ), ), - BlocProvider(create: (_) => CustomerBloc()), - BlocProvider( - create: (context) => ProductCubit(context.read()), - ), - BlocProvider( - create: (_) => - StaffCubit(context.read())..loadAllStaff(), - ), - BlocProvider( - create: (_) => ServicesCubit(context.read()), - ), - BlocProvider( - create: (_) => - ProvidersCubit(context.read())..loadProviders(null), - ), - ], - child: BlocBuilder( - builder: (context, state) { - return MaterialApp.router( - title: 'FLUX Gestionale', - debugShowCheckedModeBanner: false, - theme: fluxLightTheme, - darkTheme: fluxDarkTheme, - themeMode: state.currentTheme.themeMode, - routerConfig: _router, // Usa l'istanza mantenuta nello stato - ); - }, ), ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9738716..2ba73bd 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,14 @@ import Foundation import app_links import file_picker +import pdfx import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index e59aba5..3c09862 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -90,7 +90,7 @@ packages: source: hosted version: "1.0.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -145,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" + extension: + dependency: transitive + description: + name: extension + sha256: be3a6b7f8adad2f6e2e8c63c895d19811fcf203e23466c6296267941d0ff4f24 + url: "https://pub.dev" + source: hosted + version: "0.6.0" fake_async: dependency: transitive description: @@ -177,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "11.0.2" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -312,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + internet_file: + dependency: "direct main" + description: + name: internet_file + sha256: c303ebf02caa853f072c49150557e76957622adacb18420008531c97a5ef5026 + url: "https://pub.dev" + source: hosted + version: "1.3.0" intl: dependency: "direct main" description: @@ -488,6 +512,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdfx: + dependency: "direct main" + description: + name: pdfx + sha256: "29db9b71d46bf2335e001f91693f2c3fbbf0760e4c2eb596bf4bafab211471c1" + url: "https://pub.dev" + source: hosted + version: "2.9.2" petitparser: dependency: transitive description: @@ -496,6 +528,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + photo_view: + dependency: transitive + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" platform: dependency: transitive description: @@ -685,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.2" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -709,6 +757,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_file: + dependency: transitive + description: + name: universal_file + sha256: d1a957fccaad2a32023b62fe435b273ee47aaf2eb804709795e4bf4afff50960 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" url_launcher: dependency: transitive description: @@ -773,6 +837,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7c35a07..d399268 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: ^3.11.3 dependencies: + collection: ^1.19.1 equatable: ^2.0.8 file_picker: ^11.0.2 flutter: @@ -17,7 +18,9 @@ dependencies: get_it: ^9.2.1 go_router: ^17.2.0 google_fonts: ^8.0.2 + internet_file: ^1.3.0 intl: ^0.20.2 + pdfx: ^2.9.2 shared_preferences: ^2.5.5 supabase_flutter: ^2.12.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 785a046..90e3d46 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + PdfxPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PdfxPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 8f8ee4f..24b817c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + pdfx url_launcher_windows )