feat-insert-service (#5)

Reviewed-on: http://catelliub.zapto.org:3000/brontomark/flux/pulls/5
Co-authored-by: mark-cachy <marco@catelli.it>
Co-committed-by: mark-cachy <marco@catelli.it>
This commit is contained in:
2026-04-20 16:52:20 +02:00
committed by brontomark
parent 667bbf6404
commit c3d4f3fac7
63 changed files with 4715 additions and 1371 deletions

88
ios/Podfile.lock Normal file
View File

@@ -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

View File

@@ -10,6 +10,8 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 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 */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -43,27 +45,44 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
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 = "<group>"; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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; }; 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 = "<group>"; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
0170592D7DFD7A1AE8221644 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
449A3D64DB8C9C60EBDF7DD1 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = { 97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
44490B82E7859424F77CB04B /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -78,6 +97,15 @@
path = RunnerTests; path = RunnerTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
6A991A28CCED9666CA172E00 /* Frameworks */ = {
isa = PBXGroup;
children = (
975E96D2C8BBF1CF6A3F5F40 /* Pods_Runner.framework */,
1A2C92D305DE434FC3C442B0 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -96,6 +124,8 @@
97C146F01CF9000F007C117D /* Runner */, 97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
F5D002C3092D87755D552D32 /* Pods */,
6A991A28CCED9666CA172E00 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -124,6 +154,20 @@
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
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 = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -131,8 +175,10 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = ( buildPhases = (
7385E42426A562D77ADB127F /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */, 331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */, 331C807F294A63A400263BE5 /* Resources */,
0170592D7DFD7A1AE8221644 /* Frameworks */,
); );
buildRules = ( buildRules = (
); );
@@ -148,12 +194,14 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
55692154E5E0FA98E80084D6 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
6F6F1B58AD2DC9B50492B34B /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@@ -241,6 +289,67 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; 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 */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@@ -382,6 +491,7 @@
}; };
331C8088294A63A400263BE5 /* Debug */ = { 331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 8B6E013555080C92974ED449 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@@ -399,6 +509,7 @@
}; };
331C8089294A63A400263BE5 /* Release */ = { 331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 35D61C73467480800D34D7BC /* Pods-RunnerTests.release.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@@ -414,6 +525,7 @@
}; };
331C808A294A63A400263BE5 /* Profile */ = { 331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 8D4A0EA2456F02E466FCB0E1 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;

View File

@@ -4,4 +4,7 @@
<FileRef <FileRef
location = "group:Runner.xcodeproj"> location = "group:Runner.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace> </Workspace>

View File

@@ -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/products/ui/products_screen.dart';
import 'package:flux/features/master_data/store/ui/create_store_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/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 'package:go_router/go_router.dart';
import 'dart:async'; import 'dart:async';
@@ -80,9 +80,15 @@ class AppRouter {
path: '/service-form', path: '/service-form',
name: 'service-form', name: 'service-form',
builder: (context, state) { builder: (context, state) {
// Recuperiamo il ServiceModel se passato come extra // Recuperiamo l'oggetto se passato tramite 'extra'
final service = state.extra as ServiceModel?; final existingService = state.extra as ServiceModel?;
return ServiceFormScreen(initialService: service); // Recuperiamo l'ID se presente nell'URL
final serviceId = state.uri.queryParameters['serviceId'];
return ServiceFormScreen(
serviceId: serviceId ?? existingService?.id,
existingService: existingService,
);
}, },
), ),
], ],

View File

@@ -19,4 +19,24 @@ extension MyStringExtensions on String? {
}) })
.join(' '); .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
}
} }

View File

@@ -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<String> _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<String>(
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();
},
),
),
),
);
}
}

View File

@@ -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<PdfViewerWidget> createState() => _PdfViewerWidgetState();
}
class _PdfViewerWidgetState extends State<PdfViewerWidget> {
late PdfControllerPinch _pdfController;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_initPdf();
}
Future<void> _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<SupabaseClient>()
.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),
);
}
}

View File

@@ -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<CustomerEvent, CustomerState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
CustomerBloc() : super(const CustomerState()) {
on<LoadCustomersRequested>(_onLoadCustomers);
on<CreateCustomerRequested>(_onCreateCustomer);
on<SearchCustomersRequested>(_onSearchCustomers);
on<UpdateCustomerRequested>(_onUpdateCustomer);
}
Future<void> _onLoadCustomers(
LoadCustomersRequested event,
Emitter<CustomerState> 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<void> _onCreateCustomer(
CreateCustomerRequested event,
Emitter<CustomerState> 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<CustomerModel>.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<void> _onUpdateCustomer(
UpdateCustomerRequested event,
Emitter<CustomerState> 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<CustomerModel>.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<void> _onSearchCustomers(
SearchCustomersRequested event,
Emitter<CustomerState> 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 (_) {}
}
}

View File

@@ -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<CustomerState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
// Variabile per gestire il debounce della ricerca
Timer? _searchDebounce;
CustomerCubit() : super(const CustomerState());
// --- LETTURA ---
Future<void> 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<void> 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<CustomerModel>.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<void> updateCustomer(CustomerModel customer) async {
emit(state.copyWith(status: CustomerStatus.loading));
try {
final updatedCustomer = await _repository.updateCustomer(customer);
final updatedList = List<CustomerModel>.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<CustomerModel?> 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<void> close() {
_searchDebounce?.cancel();
return super.close();
}
}

View File

@@ -1,34 +0,0 @@
part of 'customer_bloc.dart';
abstract class CustomerEvent extends Equatable {
const CustomerEvent();
@override
List<Object?> 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<Object?> get props => [customer];
}

View File

@@ -1,12 +1,11 @@
part of 'customer_bloc.dart'; part of 'customer_cubit.dart';
enum CustomerStatus { initial, loading, success, failure } enum CustomerStatus { initial, loading, success, failure }
class CustomerState extends Equatable { class CustomerState extends Equatable {
final CustomerStatus status; final CustomerStatus status;
final List<CustomerModel> customers; // Per la lista generale final List<CustomerModel> customers;
final CustomerModel? final CustomerModel? lastCreatedCustomer;
lastCreatedCustomer; // <--- Fondamentale per la Dialog "al volo"
final String? errorMessage; final String? errorMessage;
const CustomerState({ const CustomerState({

View File

@@ -1,37 +1,38 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart'; 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:flux/features/customers/models/customer_file_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
class CustomerRepository { class CustomerRepository {
final SupabaseClient _client = GetIt.I<SupabaseClient>(); final SupabaseClient _supabase = GetIt.I<SupabaseClient>();
final String companyId = GetIt.I.get<SessionBloc>().state.company!.id;
// Crea un nuovo cliente // Crea un nuovo cliente
Future<CustomerModel> createCustomer(CustomerModel customer) async { Future<CustomerModel> saveCustomer(CustomerModel customer) async {
try { try {
final response = await _client final response = await _supabase
.from('customer') .from('customer')
.insert(customer.toJson()) .upsert(customer.toJson())
.select() .select()
.single(); .single();
return CustomerModel.fromJson(response); return CustomerModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante la creazione del cliente: $e'; throw 'Errore durante il salvataggio del cliente: $e';
} }
} }
Future<CustomerModel> updateCustomer(CustomerModel customer) async { Future<CustomerModel> updateCustomer(CustomerModel customer) async {
try { try {
final response = await _client final response = await _supabase
.from('customer') .from('customer')
.update(customer.toJson()) .update(customer.toJson())
.eq('id', customer.id!) .eq('id', customer.id!)
.select() .select()
.single(); .single();
return CustomerModel.fromJson(response); return CustomerModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante la modifica del cliente: $e'; throw 'Errore durante la modifica del cliente: $e';
} }
@@ -40,14 +41,17 @@ class CustomerRepository {
// Recupera tutti i clienti dell'azienda // Recupera tutti i clienti dell'azienda
Future<List<CustomerModel>> getCustomers(String companyId) async { Future<List<CustomerModel>> getCustomers(String companyId) async {
try { try {
final response = await _client final response = await _supabase
.from('customer') .from('customer')
.select('*, customer_file(count)') .select('''
*,
customer_file(*)
''')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) .eq('is_active', true)
.order('nome'); .order('nome');
return (response as List).map((c) => CustomerModel.fromJson(c)).toList(); return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
} catch (e) { } catch (e) {
throw 'Errore nel recupero clienti'; throw 'Errore nel recupero clienti';
} }
@@ -59,14 +63,14 @@ class CustomerRepository {
String query, String query,
) async { ) async {
try { try {
final response = await _client final response = await _supabase
.from('customer') .from('customer')
.select() .select()
.eq('company_id', companyId) .eq('company_id', companyId)
.or('nome.ilike.%$query%,telefono.ilike.%$query%') .or('nome.ilike.%$query%,telefono.ilike.%$query%')
.limit(10); .limit(10);
return (response as List).map((c) => CustomerModel.fromJson(c)).toList(); return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
} catch (e) { } catch (e) {
return []; return [];
} }
@@ -75,13 +79,13 @@ class CustomerRepository {
/// Recupera i file di un cliente specifico /// Recupera i file di un cliente specifico
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async { Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async {
try { try {
final response = await _client final response = await _supabase
.from('customer_file') .from('customer_file')
.select() .select()
.eq('customer_id', customerId); .eq('customer_id', customerId);
return (response as List) return (response as List)
.map((f) => CustomerFileModel.fromJson(f)) .map((f) => CustomerFileModel.fromMap(f))
.toList(); .toList();
} catch (e) { } catch (e) {
throw 'Errore recupero file: $e'; throw 'Errore recupero file: $e';
@@ -89,8 +93,8 @@ class CustomerRepository {
} }
/// Salva il riferimento del file nel DB /// Salva il riferimento del file nel DB
Future<void> saveFileReference(CustomerFileModel file) async { Future<void> saveCustomerFile(CustomerFileModel file) async {
await _client.from('customer_file').insert(file.toJson()); await _supabase.from('customer_file').insert(file.toMap());
} }
/// Carica un file e salva il riferimento nel database /// Carica un file e salva il riferimento nel database
@@ -98,15 +102,24 @@ class CustomerRepository {
required String customerId, required String customerId,
required PlatformFile pickedFile, required PlatformFile pickedFile,
}) async { }) 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 { 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à // Usiamo bytes invece del path per massima compatibilità
if (pickedFile.bytes == null && pickedFile.path == null) { if (pickedFile.bytes == null && pickedFile.path == null) {
throw 'Impossibile leggere il contenuto del file'; 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 // Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
if (pickedFile.bytes != null) { if (pickedFile.bytes != null) {
await _client.storage await _supabase.storage
.from('documents') .from('documents')
.uploadBinary(path, pickedFile.bytes!); .uploadBinary(
} else { storagePath,
final file = File(pickedFile.path!); pickedFile.bytes!,
await _client.storage.from('documents').upload(path, file); fileOptions: FileOptions(contentType: mimeType, upsert: true),
);
} }
final String publicUrl = _client.storage final response = await _supabase
.from('documents')
.getPublicUrl(path);
final fileRecord = CustomerFileModel(
customerId: customerId,
name: fileName,
url: publicUrl,
extension: extension,
);
final response = await _client
.from('customer_file') .from('customer_file')
.insert(fileRecord.toJson()) .insert(fileToSave.toMap())
.select() .select()
.single(); .single();
return CustomerFileModel.fromJson(response); return CustomerFileModel.fromMap(response);
} catch (e) { } catch (e) {
throw 'Errore durante l\'upload: $e'; throw 'Errore durante l\'upload: $e';
} }
} }
Future<void> saveFileReference(CustomerFileModel file) async {
await _supabase.from('customer_file').upsert(file.toMap());
}
/// Aggiorna la lista degli URL nel database /// Aggiorna la lista degli URL nel database
Future<void> updateCustomerDocuments(int id, List<String> urls) async { Future<void> updateCustomerDocuments(int id, List<String> 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 /// Elimina un file dallo storage
Future<void> deleteDocument(String fullPath) async { Future<void> deleteDocument(String fullPath) async {
// Il path dovrebbe essere ricavato dall'URL // Il path dovrebbe essere ricavato dall'URL
final path = fullPath.split('documents/').last; final path = fullPath.split('documents/').last;
await _client.storage.from('documents').remove([path]); await _supabase.storage.from('documents').remove([path]);
} }
} }

View File

@@ -7,6 +7,7 @@ class CustomerFileModel extends Equatable {
final String url; final String url;
final String extension; final String extension;
final DateTime? createdAt; final DateTime? createdAt;
final int fileSize;
const CustomerFileModel({ const CustomerFileModel({
this.id, this.id,
@@ -15,31 +16,76 @@ class CustomerFileModel extends Equatable {
required this.url, required this.url,
required this.extension, required this.extension,
this.createdAt, this.createdAt,
required this.fileSize,
}); });
factory CustomerFileModel.fromJson(Map<String, dynamic> 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( return CustomerFileModel(
id: json['id'] as String, id: id ?? this.id,
customerId: json['customer_id'], customerId: customerId ?? this.customerId,
name: json['name'], name: name ?? this.name,
url: json['url'], url: url ?? this.url,
extension: json['extension'] ?? '', extension: extension ?? this.extension,
createdAt: json['created_at'] != null createdAt: createdAt ?? this.createdAt,
? DateTime.parse(json['created_at']) fileSize: fileSize ?? this.fileSize,
: null,
); );
} }
Map<String, dynamic> toJson() { factory CustomerFileModel.fromMap(Map<String, dynamic> 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<String, dynamic> toMap() {
return { return {
if (id != null) 'id': id, if (id != null) 'id': id,
'customer_id': customerId, 'customer_id': customerId,
'name': name, 'name': name,
'url': url, 'url': url,
'extension': extension, 'extension': extension,
'file_size': fileSize,
}; };
} }
@override @override
List<Object?> get props => [id, customerId, name, url, extension, createdAt]; List<Object?> get props => [
id,
customerId,
name,
url,
extension,
createdAt,
fileSize,
];
} }

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/core/utils/string_extensions.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
class CustomerModel extends Equatable { class CustomerModel extends Equatable {
final String? id; // Bigint in SQL final String? id; // Bigint in SQL
@@ -12,7 +13,7 @@ class CustomerModel extends Equatable {
final bool nonDisturbare; final bool nonDisturbare;
final String companyId; // UUID final String companyId; // UUID
final bool isActive; final bool isActive;
final int fileCount; final List<CustomerFileModel> files;
const CustomerModel({ const CustomerModel({
this.id, this.id,
@@ -25,7 +26,7 @@ class CustomerModel extends Equatable {
this.nonDisturbare = false, this.nonDisturbare = false,
required this.companyId, required this.companyId,
this.isActive = true, this.isActive = true,
this.fileCount = 0, this.files = const [],
}); });
@override @override
@@ -40,7 +41,7 @@ class CustomerModel extends Equatable {
nonDisturbare, nonDisturbare,
companyId, companyId,
isActive, isActive,
fileCount, files,
]; ];
CustomerModel copyWith({ CustomerModel copyWith({
@@ -54,7 +55,7 @@ class CustomerModel extends Equatable {
bool? nonDisturbare, bool? nonDisturbare,
String? companyId, String? companyId,
bool? isActive, bool? isActive,
int? fileCount, List<CustomerFileModel>? files,
}) { }) {
return CustomerModel( return CustomerModel(
id: id ?? this.id, id: id ?? this.id,
@@ -67,32 +68,31 @@ class CustomerModel extends Equatable {
nonDisturbare: nonDisturbare ?? this.nonDisturbare, nonDisturbare: nonDisturbare ?? this.nonDisturbare,
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
fileCount: fileCount ?? this.fileCount, files: files ?? this.files,
); );
} }
factory CustomerModel.fromJson(Map<String, dynamic> json) { factory CustomerModel.fromMap(Map<String, dynamic> map) {
int count = 0;
if (json['customer_file'] != null &&
(json['customer_file'] as List).isNotEmpty) {
count = json['customer_file'][0]['count'] ?? 0;
}
return CustomerModel( return CustomerModel(
id: json['id'] as String, id: map['id'] as String,
createdAt: json['created_at'] != null createdAt: map['created_at'] != null
? DateTime.parse(json['created_at']) ? DateTime.parse(map['created_at'])
: null, : null,
nome: (json['nome'] as String).myFormat(), nome: (map['nome'] as String).myFormat(),
telefono: json['telefono'], telefono: map['telefono'],
email: json['email'], email: map['email'],
note: json['note'] ?? '', note: map['note'] ?? '',
dataUltimoContatto: json['data_ultimo_contatto'] != null dataUltimoContatto: map['data_ultimo_contatto'] != null
? DateTime.parse(json['data_ultimo_contatto']) ? DateTime.parse(map['data_ultimo_contatto'])
: null, : null,
nonDisturbare: json['non_disturbare'] ?? false, nonDisturbare: map['non_disturbare'] ?? false,
companyId: json['company_id'] as String, companyId: map['company_id'] as String,
isActive: json['is_active'] ?? true, isActive: map['is_active'] ?? true,
fileCount: count, files:
(map['customer_file'] as List?)
?.map((x) => CustomerFileModel.fromMap(x))
.toList() ??
const [],
); );
} }

View File

@@ -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<CustomerSearchSheet> createState() => _CustomerSearchSheetState();
}
class _CustomerSearchSheetState extends State<CustomerSearchSheet> {
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
context.read<CustomerCubit>().loadCustomers();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged(String query) {
context.read<CustomerCubit>().searchCustomers(query);
}
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Trova Cliente",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
tooltip: "Chiudi",
),
],
),
const SizedBox(height: 16),
// --- BARRA DI RICERCA ---
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Cerca per nome, cognome o CF...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged("");
},
),
),
onChanged: _onSearchChanged,
),
const SizedBox(height: 16),
// --- TASTO NUOVO CLIENTE ---
SizedBox(
width: double.infinity,
child: IconButton(
icon: const Icon(Icons.person_add),
onPressed: () async {
final servicesCubit = context.read<ServicesCubit>();
// Apriamo la dialog passando la query attuale
final CustomerModel? nuovoCliente = await showDialog(
context: context,
builder: (context) => QuickCustomerDialog(
initialQuery: _searchController.text,
),
);
if (nuovoCliente != null) {
servicesCubit.updateField(
customerId: nuovoCliente.id,
customerDisplayName: nuovoCliente.nome,
);
setState(() {
_searchController.clear();
});
}
},
),
),
const SizedBox(height: 24),
// --- LISTA RISULTATI CON BLOC BUILDER ---
const Text(
"Risultati",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
),
const SizedBox(height: 8),
Expanded(
// AGGANCIO AL CUBIT REALE
child: BlocBuilder<CustomerCubit, CustomerState>(
builder: (context, state) {
// 1. Stato di caricamento
if (state.status == CustomerStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
// 2. Nessun risultato trovato
if (state.customers.isEmpty) {
return const Center(
child: Text(
"Nessun cliente trovato.\nProva a cambiare i termini di ricerca.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
);
}
// 3. Mostriamo la lista vera
return ListView.separated(
itemCount: state.customers.length,
separatorBuilder: (context, index) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final customer = state.customers[index];
// Assumo che il tuo CustomerModel abbia le proprietà name e surname.
// Adatta queste variabili al tuo modello reale!
final displayName = customer.nome.trim();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
// Mostra l'iniziale
child: Text(
displayName.isNotEmpty
? displayName[0].toUpperCase()
: "?",
),
),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(customer.email),
trailing: const Icon(
Icons.check_circle_outline,
color: Colors.grey,
),
onTap: () {
// Salviamo l'ID e il nome formattato nel form dei servizi
context.read<ServicesCubit>().updateField(
customerId: customer.id,
customerDisplayName: displayName,
);
// Chiudiamo la modale
Navigator.pop(context);
},
);
},
);
},
),
),
],
),
),
);
}
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/core/theme/theme.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/models/customer_model.dart';
import 'package:flux/features/customers/ui/customer_form.dart'; import 'package:flux/features/customers/ui/customer_form.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -26,16 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
void _loadInitialCustomers() { void _loadInitialCustomers() {
final companyId = context.read<SessionBloc>().state.company?.id; final companyId = context.read<SessionBloc>().state.company?.id;
if (companyId != null) { if (companyId != null) {
context.read<CustomerBloc>().add(LoadCustomersRequested(companyId)); context.read<CustomerCubit>().loadCustomers();
} }
} }
void _onSearch(String query) { void _onSearch(String query) {
final companyId = context.read<SessionBloc>().state.company?.id; final companyId = context.read<SessionBloc>().state.company?.id;
if (companyId != null) { if (companyId != null) {
context.read<CustomerBloc>().add( context.read<CustomerCubit>().searchCustomers(query);
SearchCustomersRequested(companyId, query),
);
} }
} }
@@ -57,16 +55,12 @@ class _CustomersContentState extends State<CustomersContent> {
if (customer == null) { if (customer == null) {
// CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create // CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create
context.read<CustomerBloc>().add( context.read<CustomerCubit>().createCustomer(
CreateCustomerRequested(
customerFromForm.copyWith(companyId: companyId), customerFromForm.copyWith(companyId: companyId),
),
); );
} else { } else {
// CASO MODIFICA: L'ID e il companyId sono già nel modello // CASO MODIFICA: L'ID e il companyId sono già nel modello
context.read<CustomerBloc>().add( context.read<CustomerCubit>().updateCustomer(customerFromForm);
UpdateCustomerRequested(customerFromForm),
);
} }
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
}, },
@@ -125,7 +119,7 @@ class _CustomersContentState extends State<CustomersContent> {
// LISTA CLIENTI // LISTA CLIENTI
Expanded( Expanded(
child: BlocBuilder<CustomerBloc, CustomerState>( child: BlocBuilder<CustomerCubit, CustomerState>(
builder: (context, state) { builder: (context, state) {
if (state.status == CustomerStatus.loading && if (state.status == CustomerStatus.loading &&
state.customers.isEmpty) { state.customers.isEmpty) {
@@ -235,11 +229,11 @@ class _CustomerTile extends StatelessWidget {
style: TextStyle(color: context.secondaryText), style: TextStyle(color: context.secondaryText),
), ),
], ],
if (customer.fileCount > 0) ...[ if (customer.files.isNotEmpty) ...[
Text(' - ', style: TextStyle(color: context.secondaryText)), Text(' - ', style: TextStyle(color: context.secondaryText)),
Icon(Icons.attach_file, size: 14, color: context.accent), Icon(Icons.attach_file, size: 14, color: context.accent),
Text( Text(
'${customer.fileCount} doc', '${customer.files.length} doc',
style: TextStyle( style: TextStyle(
color: context.accent, color: context.accent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@@ -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<QuickCustomerDialog> createState() => _QuickCustomerDialogState();
}
class _QuickCustomerDialogState extends State<QuickCustomerDialog> {
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<void> _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<CustomerCubit>().quickCreateCustomer(
name: _nameCtrl.text.trim(),
phone: _phoneCtrl.text.trim(),
// Aggiungi questi se li hai inseriti nel tuo CustomerCubit:
// email: _emailCtrl.text.trim(),
// note: _noteCtrl.text.trim(),
);
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"),
),
],
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/theme/theme.dart';
import 'package:flux/features/home/ui/dashboard_action_card.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'; import 'package:go_router/go_router.dart';
class DashboardAdaptiveGrid extends StatelessWidget { class DashboardAdaptiveGrid extends StatelessWidget {
@@ -36,7 +37,7 @@ class DashboardAdaptiveGrid extends StatelessWidget {
label: 'Nuova Op', label: 'Nuova Op',
icon: Icons.add_task, icon: Icons.add_task,
color: context.accent, color: context.accent,
onTap: () {}, onTap: () => startNewService(context),
), ),
DashboardActionCard( DashboardActionCard(
label: 'Clienti', label: 'Clienti',

View File

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/core/blocs/session/session_bloc.dart';
import 'package:flux/core/theme/theme.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/master_data/master_data_hub_content.dart';
import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/ui/services_screen.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 { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -21,8 +23,6 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Caricamento "silenzioso" all'avvio dell'app
// Usiamo WidgetsBinding per assicurarci che il contesto sia pronto
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ServicesCubit>().loadServices(); context.read<ServicesCubit>().loadServices();
}); });
@@ -34,15 +34,31 @@ class _HomeScreenState extends State<HomeScreen> {
builder: (context, state) { builder: (context, state) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Se lo schermo è più largo di 900px usiamo il layout Desktop
final bool isLargeScreen = constraints.maxWidth > 900; final bool isLargeScreen = constraints.maxWidth > 900;
final bool veryLargeScreen = constraints.maxWidth > 1200;
final bool isMenuExtended = veryLargeScreen ? true : _extendRailway;
return Scaffold( 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( body: Row(
children: [ children: [
// --- SIDEBAR (Desktop) --- // --- SIDEBAR (Desktop) ---
if (isLargeScreen) if (isLargeScreen) _buildDesktopSidebar(isMenuExtended),
_buildNavigationRail(constraints.maxWidth > 1200),
// --- CONTENUTO DINAMICO --- // --- CONTENUTO DINAMICO ---
Expanded( Expanded(
@@ -61,7 +77,209 @@ class _HomeScreenState extends State<HomeScreen> {
); );
} }
// --- 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<String>(
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<SessionBloc>().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<AuthBloc>().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) { Widget _buildBottomNavigationBar(int selectedIndex) {
return BottomNavigationBar( return BottomNavigationBar(
currentIndex: selectedIndex, currentIndex: selectedIndex,
@@ -85,80 +303,6 @@ class _HomeScreenState extends State<HomeScreen> {
); );
} }
// --- 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) { Widget _buildPageContent(int index, bool isLargeScreen) {
return IndexedStack( return IndexedStack(
index: index, index: index,
@@ -167,12 +311,8 @@ class _HomeScreenState extends State<HomeScreen> {
isLargeScreen: isLargeScreen, isLargeScreen: isLargeScreen,
onTabRequested: (idx) => setState(() => _selectedIndex = 2), onTabRequested: (idx) => setState(() => _selectedIndex = 2),
), ),
const ServicesScreen(),
ServicesScreen(),
// L'unico punto di ingresso per tutte le anagrafiche
MasterDataHubContent( MasterDataHubContent(
// Qui gestiamo la navigazione "interna" all'hub
onOpenPage: (widget) { onOpenPage: (widget) {
Navigator.push( Navigator.push(
context, context,

View File

@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/core/blocs/session/session_bloc.dart';
@@ -10,9 +11,9 @@ part 'product_state.dart';
class ProductCubit extends Cubit<ProductState> { class ProductCubit extends Cubit<ProductState> {
final ProductRepository _repository = GetIt.I<ProductRepository>(); final ProductRepository _repository = GetIt.I<ProductRepository>();
final SessionBloc _sessionBloc; final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
ProductCubit(this._sessionBloc) : super(const ProductState()); ProductCubit() : super(const ProductState());
// Caricamento iniziale dei Brand // Caricamento iniziale dei Brand
Future<void> loadBrands() async { Future<void> loadBrands() async {
@@ -102,4 +103,55 @@ class ProductCubit extends Cubit<ProductState> {
); );
} }
} }
Future<void> 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<ModelModel?> 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;
}
}
} }

View File

@@ -4,14 +4,14 @@ import '../models/brand_model.dart';
import '../models/model_model.dart'; import '../models/model_model.dart';
class ProductRepository { class ProductRepository {
final SupabaseClient _client = GetIt.I<SupabaseClient>(); final SupabaseClient _supabase = GetIt.I<SupabaseClient>();
// --- BRAND --- // --- BRAND ---
/// Recupera tutti i brand dell'azienda /// Recupera tutti i brand dell'azienda
Future<List<BrandModel>> getBrands(String companyId) async { Future<List<BrandModel>> getBrands(String companyId) async {
try { try {
final response = await _client final response = await _supabase
.from('brand') .from('brand')
.select() .select()
.eq('company_id', companyId) .eq('company_id', companyId)
@@ -27,7 +27,7 @@ class ProductRepository {
/// Crea o aggiorna un brand /// Crea o aggiorna un brand
Future<BrandModel> upsertBrand(BrandModel brand) async { Future<BrandModel> upsertBrand(BrandModel brand) async {
try { try {
final response = await _client final response = await _supabase
.from('brand') .from('brand')
.upsert(brand.toJson()) .upsert(brand.toJson())
.select() .select()
@@ -44,7 +44,7 @@ class ProductRepository {
/// Recupera i modelli di un brand specifico /// Recupera i modelli di un brand specifico
Future<List<ModelModel>> getModelsByBrand(String brandId) async { Future<List<ModelModel>> getModelsByBrand(String brandId) async {
try { try {
final response = await _client final response = await _supabase
.from('model') .from('model')
.select() .select()
.eq('brand_id', brandId) .eq('brand_id', brandId)
@@ -61,7 +61,7 @@ class ProductRepository {
/// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato! /// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato!
Future<ModelModel> upsertModel(ModelModel model) async { Future<ModelModel> upsertModel(ModelModel model) async {
try { try {
final response = await _client final response = await _supabase
.from('model') .from('model')
.upsert(model.toJson()) .upsert(model.toJson())
.select() .select()
@@ -78,9 +78,24 @@ class ProductRepository {
/// Disattiva un brand o un modello (Soft Delete per non rompere le FK delle operazioni passate) /// Disattiva un brand o un modello (Soft Delete per non rompere le FK delle operazioni passate)
Future<void> toggleActiveStatus(String table, String id, bool status) async { Future<void> toggleActiveStatus(String table, String id, bool status) async {
try { 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) { } catch (e) {
throw 'Errore durante la modifica dello stato'; throw 'Errore durante la modifica dello stato';
} }
} }
Future<List<ModelModel>> 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';
}
}
} }

View File

@@ -12,7 +12,7 @@ class ModelModel extends Equatable {
const ModelModel({ const ModelModel({
this.id, this.id,
required this.name, required this.name,
required this.nameWithBrand, this.nameWithBrand = '',
required this.brandId, required this.brandId,
this.isActive = true, this.isActive = true,
this.createdAt, this.createdAt,

View File

@@ -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<BrandModel> existingBrands;
const QuickProductDialog({super.key, required this.existingBrands});
@override
State<QuickProductDialog> createState() => _QuickProductDialogState();
}
class _QuickProductDialogState extends State<QuickProductDialog> {
final _modelCtrl = TextEditingController();
String _selectedBrandName = "";
bool _isLoading = false;
Future<void> _save() async {
final NavigatorState navigator = Navigator.of(context);
if (_selectedBrandName.isEmpty || _modelCtrl.text.isEmpty) return;
setState(() => _isLoading = true);
final newModel = await context.read<ProductCubit>().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<String>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<String>.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"),
),
],
);
}
}

View File

@@ -7,15 +7,17 @@ import 'package:get_it/get_it.dart';
import '../models/provider_model.dart'; import '../models/provider_model.dart';
class ProvidersState extends Equatable { class ProvidersState extends Equatable {
final List<ProviderModel> allProviders; // Tutti i provider della company final List<ProviderModel> allProviders;
final List<String> final List<String> associatedIds;
associatedIds; // ID dei provider attivi nello store selezionato // NUOVO CAMPO: Lista dei provider pronti per essere usati nel form pratiche
final List<ProviderModel> activeProviders;
final bool isLoading; final bool isLoading;
final String? errorMessage; final String? errorMessage;
const ProvidersState({ const ProvidersState({
this.allProviders = const [], this.allProviders = const [],
this.associatedIds = const [], this.associatedIds = const [],
this.activeProviders = const [], // Inizializza
this.isLoading = false, this.isLoading = false,
this.errorMessage, this.errorMessage,
}); });
@@ -23,14 +25,18 @@ class ProvidersState extends Equatable {
ProvidersState copyWith({ ProvidersState copyWith({
List<ProviderModel>? allProviders, List<ProviderModel>? allProviders,
List<String>? associatedIds, List<String>? associatedIds,
List<ProviderModel>? activeProviders, // Aggiungi qui
bool? isLoading, bool? isLoading,
String? errorMessage, String? errorMessage,
}) { }) {
return ProvidersState( return ProvidersState(
allProviders: allProviders ?? this.allProviders, allProviders: allProviders ?? this.allProviders,
associatedIds: associatedIds ?? this.associatedIds, associatedIds: associatedIds ?? this.associatedIds,
activeProviders: activeProviders ?? this.activeProviders, // Aggiungi qui
isLoading: isLoading ?? this.isLoading, 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<Object?> get props => [ List<Object?> get props => [
allProviders, allProviders,
associatedIds, associatedIds,
activeProviders, // Aggiungi qui
isLoading, isLoading,
errorMessage, errorMessage,
]; ];
@@ -45,9 +52,9 @@ class ProvidersState extends Equatable {
class ProvidersCubit extends Cubit<ProvidersState> { class ProvidersCubit extends Cubit<ProvidersState> {
final ProviderRepository _repository = GetIt.I<ProviderRepository>(); final ProviderRepository _repository = GetIt.I<ProviderRepository>();
final SessionBloc _sessionBloc; final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
ProvidersCubit(this._sessionBloc) : super(const ProvidersState()); ProvidersCubit() : super(const ProvidersState());
// Carica i provider della company e quelli associati a uno store specifico // Carica i provider della company e quelli associati a uno store specifico
Future<void> loadProviders(StoreModel? store) async { Future<void> loadProviders(StoreModel? store) async {
@@ -74,6 +81,23 @@ class ProvidersCubit extends Cubit<ProvidersState> {
} }
} }
Future<void> 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 // Aggiunge o rimuove l'associazione con lo store
Future<void> toggleProviderAssociation({ Future<void> toggleProviderAssociation({
required String providerId, required String providerId,

View File

@@ -9,6 +9,7 @@ class ProviderModel extends Equatable {
final bool energia; final bool energia;
final bool assicurazioni; final bool assicurazioni;
final bool intrattenimento; final bool intrattenimento;
final bool finanziamenti;
final bool altro; final bool altro;
final bool isActive; final bool isActive;
final String companyId; final String companyId;
@@ -22,6 +23,7 @@ class ProviderModel extends Equatable {
required this.energia, required this.energia,
required this.assicurazioni, required this.assicurazioni,
required this.intrattenimento, required this.intrattenimento,
required this.finanziamenti,
required this.altro, required this.altro,
required this.isActive, required this.isActive,
required this.companyId, required this.companyId,
@@ -48,6 +50,7 @@ class ProviderModel extends Equatable {
energia: map['energia'] ?? false, energia: map['energia'] ?? false,
assicurazioni: map['assicurazioni'] ?? false, assicurazioni: map['assicurazioni'] ?? false,
intrattenimento: map['intrattenimento'] ?? false, intrattenimento: map['intrattenimento'] ?? false,
finanziamenti: map['finanziamenti'] ?? false,
altro: map['altro'] ?? false, altro: map['altro'] ?? false,
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
companyId: map['company_id'], companyId: map['company_id'],
@@ -63,6 +66,7 @@ class ProviderModel extends Equatable {
'energia': energia, 'energia': energia,
'assicurazioni': assicurazioni, 'assicurazioni': assicurazioni,
'intrattenimento': intrattenimento, 'intrattenimento': intrattenimento,
'finanziamenti': finanziamenti,
'altro': altro, 'altro': altro,
'is_active': isActive, 'is_active': isActive,
'company_id': companyId, 'company_id': companyId,
@@ -84,6 +88,7 @@ class ProviderModel extends Equatable {
energia, energia,
assicurazioni, assicurazioni,
intrattenimento, intrattenimento,
finanziamenti,
altro, altro,
isActive, isActive,
companyId, companyId,
@@ -98,6 +103,7 @@ class ProviderModel extends Equatable {
bool? energia, bool? energia,
bool? assicurazioni, bool? assicurazioni,
bool? intrattenimento, bool? intrattenimento,
bool? finanziamenti,
bool? altro, bool? altro,
bool? isActive, bool? isActive,
String? companyId, String? companyId,
@@ -111,6 +117,7 @@ class ProviderModel extends Equatable {
energia: energia ?? this.energia, energia: energia ?? this.energia,
assicurazioni: assicurazioni ?? this.assicurazioni, assicurazioni: assicurazioni ?? this.assicurazioni,
intrattenimento: intrattenimento ?? this.intrattenimento, intrattenimento: intrattenimento ?? this.intrattenimento,
finanziamenti: finanziamenti ?? this.finanziamenti,
altro: altro ?? this.altro, altro: altro ?? this.altro,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,

View File

@@ -20,6 +20,7 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
late bool _energia; late bool _energia;
late bool _assicurazioni; late bool _assicurazioni;
late bool _intrattenimento; late bool _intrattenimento;
late bool _finanziamenti;
late bool _altro; late bool _altro;
late bool _isActive; late bool _isActive;
final List<String> _tempSelectedStoreIds = final List<String> _tempSelectedStoreIds =
@@ -38,6 +39,7 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
_energia = p?.energia ?? false; _energia = p?.energia ?? false;
_assicurazioni = p?.assicurazioni ?? false; _assicurazioni = p?.assicurazioni ?? false;
_intrattenimento = p?.intrattenimento ?? false; _intrattenimento = p?.intrattenimento ?? false;
_finanziamenti = p?.finanziamenti ?? false;
_altro = p?.altro ?? false; _altro = p?.altro ?? false;
_isActive = p?.isActive ?? true; _isActive = p?.isActive ?? true;
} }
@@ -61,6 +63,7 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
energia: _energia, energia: _energia,
assicurazioni: _assicurazioni, assicurazioni: _assicurazioni,
intrattenimento: _intrattenimento, intrattenimento: _intrattenimento,
finanziamenti: _finanziamenti,
altro: _altro, altro: _altro,
isActive: _isActive, isActive: _isActive,
companyId: companyId:
@@ -130,6 +133,11 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
_intrattenimento, _intrattenimento,
(v) => setState(() => _intrattenimento = v), (v) => setState(() => _intrattenimento = v),
), ),
_buildSwitch(
"Finanziamenti",
_finanziamenti,
(v) => setState(() => _finanziamenti = v),
),
_buildSwitch( _buildSwitch(
"Altro/Accessori", "Altro/Accessori",
_altro, _altro,

View File

@@ -10,9 +10,9 @@ part 'staff_state.dart';
class StaffCubit extends Cubit<StaffState> { class StaffCubit extends Cubit<StaffState> {
final StaffRepository _repository = GetIt.I.get<StaffRepository>(); final StaffRepository _repository = GetIt.I.get<StaffRepository>();
final SessionBloc _sessionBloc; final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
StaffCubit(this._sessionBloc) : super(const StaffState()); StaffCubit() : super(const StaffState());
// Carica tutto lo staff della compagnia // Carica tutto lo staff della compagnia
Future<void> loadAllStaff() async { Future<void> loadAllStaff() async {

View File

@@ -13,9 +13,9 @@ part 'store_state.dart';
class StoreCubit extends Cubit<StoreState> { class StoreCubit extends Cubit<StoreState> {
final StoreRepository _repository = GetIt.I<StoreRepository>(); final StoreRepository _repository = GetIt.I<StoreRepository>();
final StaffRepository _staffRepository = GetIt.I<StaffRepository>(); final StaffRepository _staffRepository = GetIt.I<StaffRepository>();
final SessionBloc _sessionBloc; final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
StoreCubit(this._sessionBloc) : super(const StoreState(stores: [])); StoreCubit() : super(const StoreState(stores: []));
Future<void> createStore(final StoreModel store) async { Future<void> createStore(final StoreModel store) async {
emit(state.copyWith(status: StoreStatus.loading)); emit(state.copyWith(status: StoreStatus.loading));

View File

@@ -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<ProductState> {
final ProductRepository _repository = GetIt.I<ProductRepository>();
final SessionBloc _sessionBloc;
ProductCubit(this._sessionBloc) : super(const ProductState());
// Caricamento iniziale dei Brand
Future<void> 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<void> 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<void> 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<void> 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<void> 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()),
);
}
}
}

View File

@@ -1,44 +0,0 @@
part of 'product_cubit.dart';
enum ProductStatus { initial, loading, success, error }
class ProductState extends Equatable {
final ProductStatus status;
final List<BrandModel> brands;
final List<ModelModel> 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<BrandModel>? brands,
List<ModelModel>? 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<Object?> get props => [
status,
brands,
models,
selectedBrand,
errorMessage,
];
}

View File

@@ -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<SupabaseClient>();
// --- BRAND ---
/// Recupera tutti i brand dell'azienda
Future<List<BrandModel>> 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<BrandModel> 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<List<ModelModel>> 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<ModelModel> 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<void> 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';
}
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<Object?> get props => [id, name, companyId, isActive, createdAt];
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<Object?> get props => [
id,
name,
nameWithBrand,
brandId,
isActive,
createdAt,
];
}

View File

@@ -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<BrandModel>(
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<ProductCubit>().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",
),
],
],
),
);
}
}

View File

@@ -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<ProductCubit>()
.toggleStatus('model', model.id!, model.isActive),
),
],
),
);
},
),
),
],
);
}
}

View File

@@ -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<ProductCubit>().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<ProductCubit>().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)),
),
],
),
);
}

View File

@@ -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<ProductCubit>().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<ProductCubit, ProductState>(
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)),
],
),
);
},
),
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -1,116 +1,323 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_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/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:flux/features/services/models/service_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:collection/collection.dart';
class ServicesState extends Equatable { part 'services_state.dart';
final List<ServiceModel> 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<ServiceModel>? 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<Object?> get props => [
allServices,
isLoading,
hasReachedMax,
errorMessage,
query,
dateRange,
];
}
class ServicesCubit extends Cubit<ServicesState> { class ServicesCubit extends Cubit<ServicesState> {
final ServicesRepository _repository = GetIt.I<ServicesRepository>(); final ServicesRepository _repository = GetIt.I<ServicesRepository>();
final SessionBloc _sessionBloc; final SessionBloc _sessionBloc = GetIt.I<SessionBloc>();
ServicesCubit(this._sessionBloc) : super(const ServicesState()); ServicesCubit() : super(const ServicesState(status: ServicesStatus.initial));
// --- CARICAMENTO E PAGINAZIONE ---
// Carica tutto il pacchetto
Future<void> loadServices({bool refresh = false}) async { Future<void> loadServices({bool refresh = false}) async {
// Se non è un refresh e abbiamo già dati, non disturbare Supabase // Se stiamo già caricando, evitiamo chiamate doppie
if (!refresh && state.allServices.isNotEmpty) return; if (state.status == ServicesStatus.loading) return;
if (state.isLoading) return;
// Se facciamo refresh, resettiamo tutto // Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo
final currentOffset = refresh ? 0 : state.allServices.length; if (!refresh && state.hasReachedMax) return;
emit( emit(
state.copyWith( 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, allServices: refresh ? [] : state.allServices,
hasReachedMax: refresh ? false : state.hasReachedMax, hasReachedMax: refresh ? false : state.hasReachedMax,
), ),
); );
try { 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( final newServices = await _repository.fetchServices(
companyId: _sessionBloc.state.company!.id, companyId: companyId,
offset: currentOffset, offset: currentOffset,
limit: 50,
searchTerm: state.query, searchTerm: state.query,
dateRange: state.dateRange, 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( emit(
state.copyWith( state.copyWith(
isLoading: false, status: ServicesStatus.ready,
allServices: List.from(state.allServices)..addAll(newServices), allServices: refresh
hasReachedMax: ? newServices
newServices.length < : [...state.allServices, ...newServices],
50, // Se ne arrivano meno di 50, siamo alla fine hasReachedMax: reachedMax,
), ),
); );
} catch (e) { } 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}) { void updateFilters({String? query, DateTimeRange? range}) {
emit(state.copyWith(query: query, dateRange: range)); emit(
loadServices(refresh: true); // Applica i filtri e riparte da zero state.copyWith(
query: query ?? state.query,
dateRange: range ?? state.dateRange,
),
);
loadServices(refresh: true);
} }
// Salva e ricarica /// Pulisce tutti i filtri
Future<void> addService(ServiceModel service) async { void clearFilters() {
emit(state.copyWith(isLoading: true)); 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<EnergyServiceModel> energyList) {
emit(
state.copyWith(
currentService: state.currentService?.copyWith(
energyServices: energyList,
),
),
);
}
void updateFinServices(List<FinServiceModel> finList) {
emit(
state.copyWith(
currentService: state.currentService?.copyWith(finServices: finList),
),
);
}
void updateEntertainmentServices(List<EntertainmentServiceModel> entList) {
emit(
state.copyWith(
currentService: state.currentService?.copyWith(
entertainmentServices: entList,
),
),
);
}
// --- PERSISTENZA ---
Future<void> saveCurrentService({required bool isBozza}) async {
if (state.currentService == null) return;
emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null));
try { try {
await _repository.saveFullService(service); // 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente
await loadServices(); // Ricarichiamo la lista aggiornata 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) { } 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<PlatformFile> files) {
final newAttachments = files.map((file) {
return ServiceFileModel(
id: null, // Meglio null se non è su DB
serviceId: state.currentService?.id ?? '',
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
url: '',
fileSize: file.size,
localBytes: file.bytes,
createdAt: DateTime.now(),
);
}).toList();
// Creiamo una nuova lista pulita
final List<ServiceFileModel> updatedList = [
...(state.currentService?.files ?? []),
...newAttachments,
];
// Emettiamo lo stato assicurandoci che il ServiceModel venga clonato
if (state.currentService != null) {
emit(
state.copyWith(
currentService: state.currentService!.copyWith(files: updatedList),
),
);
}
}
void removeAttachment(int index) {
if (state.currentService == null) return;
final updatedList = List<ServiceFileModel>.from(
state.currentService!.files,
);
updatedList.removeAt(index);
emit(
state.copyWith(
currentService: state.currentService?.copyWith(files: updatedList),
),
);
}
void saveAndCopyFileToCustomer(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",
),
);
} }
} }
} }

View File

@@ -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<ServiceModel> 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<ServiceModel>? 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<Object?> get props => [
status,
allServices,
currentService,
errorMessage,
query,
dateRange,
hasReachedMax,
];
}

View File

@@ -1,9 +1,38 @@
import 'package:flutter/material.dart'; 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 'package:supabase_flutter/supabase_flutter.dart';
import '../models/service_model.dart'; import '../models/service_model.dart';
class ServicesRepository { class ServicesRepository {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
final companyId = GetIt.I.get<SessionBloc>().state.company!.id;
final CustomerRepository _customerRepository = GetIt.I<CustomerRepository>();
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
Future<ServiceModel> fetchServiceById(String id) async {
try {
final response = await _supabase
.from('service')
.select('''
*,
customer(nome),
energy_service(*),
fin_service(*),
entertainment_service(*),
service_file(*)
''')
.eq('id', id)
.single();
return ServiceModel.fromMap(response);
} catch (e) {
throw Exception('Errore nel caricamento del servizio: $e');
}
}
// --- RECUPERO PAGINATO CON FILTRI E JOIN --- // --- RECUPERO PAGINATO CON FILTRI E JOIN ---
Future<List<ServiceModel>> fetchServices({ Future<List<ServiceModel>> fetchServices({
@@ -19,10 +48,11 @@ class ServicesRepository {
.from('service') .from('service')
.select(''' .select('''
*, *,
customer(name, surname), customer(nome),
energy_service(*), energy_service(*),
fin_service(*), fin_service(*),
entertainment_service(*) entertainment_service(*),
service_file(*)
''') ''')
.eq('company_id', companyId); .eq('company_id', companyId);
@@ -36,7 +66,7 @@ class ServicesRepository {
if (searchTerm != null && searchTerm.isNotEmpty) { if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata // Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or( 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) --- // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
Future<void> saveFullService(ServiceModel service) async { Future<void> saveFullService(ServiceModel service) async {
try { try {
// 1. Inseriamo il record principale // 1. Upsert del record principale
// Se service.id è null, Supabase fa INSERT. Se c'è, fa UPDATE (grazie all'upsert o gestione manuale)
final serviceData = await _supabase final serviceData = await _supabase
.from('service') .from('service')
.upsert(service.toMap()) .upsert(service.toMap())
@@ -65,45 +94,103 @@ class ServicesRepository {
final String newId = serviceData['id']; final String newId = serviceData['id'];
// 2. Pulizia vecchi record figli (necessaria se è una MODIFICA) // 2. MODIFICA: Pulizia atomica dei figli
// Se stiamo modificando, cancelliamo i vecchi per reinserire i nuovi (più semplice) // Se stiamo modificando (id != null), resettiamo le tabelle collegate
if (service.id != null) { if (service.id != null) {
await _supabase.from('energy_service').delete().eq('service_id', newId); await Future.wait([
await _supabase.from('fin_service').delete().eq('service_id', newId); _supabase.from('energy_service').delete().eq('service_id', newId),
await _supabase _supabase.from('fin_service').delete().eq('service_id', newId),
_supabase
.from('entertainment_service') .from('entertainment_service')
.delete() .delete()
.eq('service_id', newId); .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<Future> insertTasks = [];
if (service.energyServices.isNotEmpty) { if (service.energyServices.isNotEmpty) {
final List<Map<String, dynamic>> toInsert = []; insertTasks.add(
for (var item in service.energyServices) { _supabase
toInsert.add(item.copyWith(serviceId: newId).toMap()); .from('energy_service')
} .insert(
await _supabase.from('energy_service').insert(toInsert); service.energyServices
.map((item) => item.copyWith(serviceId: newId).toMap())
.toList(),
),
);
} }
// 4. Inserimento FinServices
if (service.finServices.isNotEmpty) { if (service.finServices.isNotEmpty) {
final List<Map<String, dynamic>> toInsert = []; insertTasks.add(
for (var item in service.finServices) { _supabase
toInsert.add(item.copyWith(serviceId: newId).toMap()); .from('fin_service')
} .insert(
await _supabase.from('fin_service').insert(toInsert); service.finServices
.map((item) => item.copyWith(serviceId: newId).toMap())
.toList(),
),
);
} }
// 5. Inserimento EntertainmentServices
if (service.entertainmentServices.isNotEmpty) { if (service.entertainmentServices.isNotEmpty) {
final List<Map<String, dynamic>> toInsert = []; insertTasks.add(
for (var item in service.entertainmentServices) { _supabase
toInsert.add(item.copyWith(serviceId: newId).toMap()); .from('entertainment_service')
.insert(
service.entertainmentServices
.map((item) => item.copyWith(serviceId: newId).toMap())
.toList(),
),
);
} }
await _supabase.from('entertainment_service').insert(toInsert);
if (insertTasks.isNotEmpty) {
await Future.wait(insertTasks);
}
if (service.files.isNotEmpty) {
final List<Future> 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<void> uploadAndLink() async {
// Determiniamo il MIME type corretto in base all'estensione
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
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());
}
// Eseguiamo tutti gli upload in parallelo per la massima velocità
await Future.wait(uploadTasks);
} }
} catch (e) { } 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'); throw Exception('Errore durante l\'eliminazione: $e');
} }
} }
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
Future<List<String>> fetchTopEntertainmentTypes(String companyId) async {
try {
// Cerchiamo i tipi più frequenti associati ai servizi di questa company
// Nota: dobbiamo passare attraverso la tabella 'service' per filtrare per company_id
final response = await _supabase
.from('entertainment_service')
.select('type, service!inner(store!inner(company_id))')
.eq('service.store.company_id', companyId)
.limit(100); // Prendiamo un campione
// Logica rapida per contare le occorrenze e prendere i primi 5
final Map<String, int> counts = {};
for (var item in (response as List)) {
final type = item['type'] as String;
counts[type] = (counts[type] ?? 0) + 1;
}
var sortedKeys = counts.keys.toList()
..sort((a, b) => counts[b]!.compareTo(counts[a]!));
return sortedKeys.take(5).toList();
} catch (e) {
return [
"Netflix",
"DAZN",
"Disney+",
"Sky",
]; // Fallback se non c'è ancora storia
}
}
Future<void> 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);
}
} }

View File

@@ -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<String, dynamic> 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<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'name': name,
'extension': extension,
'url': url,
'service_id': serviceId,
'file_size': fileSize,
};
}
@override
List<Object?> get props => [
id,
createdAt,
name,
extension,
url,
serviceId,
fileSize,
localBytes,
];
}

View File

@@ -1,7 +1,9 @@
import 'package:equatable/equatable.dart'; 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/energy_service_model.dart';
import 'package:flux/features/services/models/entertainment_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/fin_service_model.dart';
import 'package:flux/features/services/models/service_file_model.dart'; // <-- Aggiunto Import
class ServiceModel extends Equatable { class ServiceModel extends Equatable {
final String? id; final String? id;
@@ -14,6 +16,7 @@ class ServiceModel extends Equatable {
final String note; final String note;
final bool resultOk; final bool resultOk;
final String? customerDisplayName; final String? customerDisplayName;
final String companyId;
// Telefonia // Telefonia
final int al; final int al;
@@ -27,6 +30,9 @@ class ServiceModel extends Equatable {
final List<FinServiceModel> finServices; final List<FinServiceModel> finServices;
final List<EntertainmentServiceModel> entertainmentServices; final List<EntertainmentServiceModel> entertainmentServices;
// ALLEGATI (Aggiunto)
final List<ServiceFileModel> files;
const ServiceModel({ const ServiceModel({
this.id, this.id,
this.createdAt, this.createdAt,
@@ -45,7 +51,9 @@ class ServiceModel extends Equatable {
this.energyServices = const [], this.energyServices = const [],
this.finServices = const [], this.finServices = const [],
this.entertainmentServices = const [], this.entertainmentServices = const [],
this.files = const [], // <-- Aggiunto default vuoto
this.customerDisplayName, this.customerDisplayName,
required this.companyId,
}); });
ServiceModel copyWith({ ServiceModel copyWith({
@@ -66,7 +74,9 @@ class ServiceModel extends Equatable {
List<EnergyServiceModel>? energyServices, List<EnergyServiceModel>? energyServices,
List<FinServiceModel>? finServices, List<FinServiceModel>? finServices,
List<EntertainmentServiceModel>? entertainmentServices, List<EntertainmentServiceModel>? entertainmentServices,
List<ServiceFileModel>? files, // <-- Aggiunto
String? customerDisplayName, String? customerDisplayName,
String? companyId,
}) { }) {
return ServiceModel( return ServiceModel(
id: id ?? this.id, id: id ?? this.id,
@@ -87,7 +97,9 @@ class ServiceModel extends Equatable {
finServices: finServices ?? this.finServices, finServices: finServices ?? this.finServices,
entertainmentServices: entertainmentServices:
entertainmentServices ?? this.entertainmentServices, entertainmentServices ?? this.entertainmentServices,
files: files ?? this.files, // <-- Aggiunto
customerDisplayName: customerDisplayName ?? this.customerDisplayName, customerDisplayName: customerDisplayName ?? this.customerDisplayName,
companyId: companyId ?? this.companyId,
); );
} }
@@ -110,17 +122,21 @@ class ServiceModel extends Equatable {
energyServices, energyServices,
finServices, finServices,
entertainmentServices, entertainmentServices,
files, // <-- Aggiunto
customerDisplayName, customerDisplayName,
companyId,
]; ];
factory ServiceModel.fromMap(Map<String, dynamic> map) { factory ServiceModel.fromMap(Map<String, dynamic> map) {
return ServiceModel( return ServiceModel(
id: map['id'], id: map['id'].toString(),
createdAt: DateTime.parse(map['created_at']), createdAt: map['created_at'] != null
storeId: map['store_id'], ? DateTime.parse(map['created_at'])
employeeId: map['employee_id'], : DateTime.now(),
customerId: map['customer_id'], storeId: map['store_id'] ?? '',
number: map['number'] ?? '', employeeId: map['employee_id']?.toString(),
customerId: map['customer_id']?.toString(),
number: map['number']?.toString() ?? '',
isBozza: map['bozza'] ?? true, isBozza: map['bozza'] ?? true,
note: map['note'] ?? '', note: map['note'] ?? '',
resultOk: map['result_ok'] ?? true, resultOk: map['result_ok'] ?? true,
@@ -130,7 +146,7 @@ class ServiceModel extends Equatable {
unica: map['unica'] ?? 0, unica: map['unica'] ?? 0,
telepass: map['telepass'] ?? 0, telepass: map['telepass'] ?? 0,
// Mappaggio delle liste collegate (se incluse nella query) // Estrazione sicura liste collegate
energyServices: energyServices:
(map['energy_service'] as List?) (map['energy_service'] as List?)
?.map((x) => EnergyServiceModel.fromMap(x)) ?.map((x) => EnergyServiceModel.fromMap(x))
@@ -146,9 +162,19 @@ class ServiceModel extends Equatable {
?.map((x) => EntertainmentServiceModel.fromMap(x)) ?.map((x) => EntertainmentServiceModel.fromMap(x))
.toList() ?? .toList() ??
const [], 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 customerDisplayName: map['customer'] != null
? "${map['customer']['name']} ${map['customer']['surname']}" ? "${map['customer']['nome'] ?? ''}".myFormat()
: "Cliente sconosciuto", : "Cliente non assegnato",
companyId: map['company_id'] as String,
); );
} }
@@ -167,6 +193,7 @@ class ServiceModel extends Equatable {
'nip': nip, 'nip': nip,
'unica': unica, 'unica': unica,
'telepass': telepass, 'telepass': telepass,
'company_id': companyId,
// Le liste non le mettiamo qui perché vanno in tabelle diverse! // Le liste non le mettiamo qui perché vanno in tabelle diverse!
}; };
} }

View File

@@ -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),
),
),
],
],
),
),
),
);
}
}

View File

@@ -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<ServiceFormScreen> createState() => _ServiceFormScreenState();
}
class _ServiceFormScreenState extends State<ServiceFormScreen> {
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<EnergyServiceModel>.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<EnergyServiceModel>.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<ServicesCubit>().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),
),
],
);
}
}

View File

@@ -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,
),
),
),
],
],
),
),
);
}
}

View File

@@ -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<void> _pickFiles(BuildContext context) async {
// Usiamo withData: true fondamentale per avere i bytes e caricare su Supabase Storage
FilePickerResult? result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
withData: true,
);
if (result != null && context.mounted) {
context.read<ServicesCubit>().addAttachments(result.files);
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ServicesCubit, ServicesState>(
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<ServicesCubit>()
.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<ServicesCubit>().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,
),
),
),
),
);
}
}

View File

@@ -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"),
),
],
),
],
),
),
);
}
}

View File

@@ -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<EnergyServiceModel> initialServices;
final String
currentStoreId; // Ci serve per sapere per quale negozio caricare i gestori
const EnergyServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
});
@override
State<EnergyServiceDialog> createState() => _EnergyServiceDialogState();
}
class _EnergyServiceDialogState extends State<EnergyServiceDialog> {
// Lista temporanea per non "sporcare" il cubit finché non si preme Conferma
late List<EnergyServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Al caricamento della modale, chiediamo al Cubit di recuperare i gestori veri!
context.read<ProvidersCubit>().loadActiveProvidersForStore(
widget.currentStoreId,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(Icons.bolt, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(_isAddingNew ? "Nuovo Contratto" : "Servizi Energia"),
],
),
content: AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: SizedBox(
width: double.maxFinite,
// Cambia vista in base al flag
child: _isAddingNew
? _EnergyForm(
onSave: (newService) {
setState(() {
_tempList.add(newService);
_isAddingNew = false; // Torna alla lista
});
},
onCancel: () {
setState(() => _isAddingNew = false);
},
)
: _EnergyList(
services: _tempList,
onDelete: (index) {
setState(() => _tempList.removeAt(index));
},
onAddTap: () {
setState(() => _isAddingNew = true); // Passa al form
},
activeProviders: [
// Passiamo i provider attivi filtrati per tipo Energia
...context
.read<ProvidersCubit>()
.state
.activeProviders
.where((p) => p.energia == true),
],
),
),
),
actions: [
if (!_isAddingNew) ...[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, _tempList),
child: const Text("Conferma Tutti"),
),
],
],
);
}
}
// ==========================================
// VISTA 1: LA LISTA DEI CONTRATTI
// ==========================================
class _EnergyList extends StatelessWidget {
final List<EnergyServiceModel> services;
final List<ProviderModel>
activeProviders; // <--- NUOVO: La lista vera dal Cubit
final Function(int) onDelete;
final VoidCallback onAddTap;
const _EnergyList({
required this.services,
required this.activeProviders, // <--- Richiesto
required this.onDelete,
required this.onAddTap,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (services.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Text(
"Nessun contratto energia inserito.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: services.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final s = services[index];
final isLuce = s.type == EnergyType.luce;
// LA MAGIA: Troviamo il nome partendo dall'ID salvato nel servizio
final providerIndex = activeProviders.indexWhere(
(p) => p.id == s.providerId,
);
final providerName = providerIndex >= 0
? (activeProviders[providerIndex].nome)
: 'Gestore Rimosso/Sconosciuto';
// Formattazione data pulita (es. 04/09/2025)
final day = s.expiration.day.toString().padLeft(2, '0');
final month = s.expiration.month.toString().padLeft(2, '0');
final formattedDate = "$day/$month/${s.expiration.year}";
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: isLuce
? Colors.orange.shade100
: Colors.blue.shade100,
child: Icon(
isLuce
? Icons.lightbulb_outline
: Icons.local_fire_department,
color: isLuce ? Colors.orange : Colors.blue,
),
),
title: Text(
providerName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text("Scadenza: $formattedDate"),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => onDelete(index),
),
);
},
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi Contratto"),
),
],
);
}
}
// ==========================================
// VISTA 2: IL FORM DI INSERIMENTO
// ==========================================
class _EnergyForm extends StatefulWidget {
final Function(EnergyServiceModel) onSave;
final VoidCallback onCancel;
const _EnergyForm({required this.onSave, required this.onCancel});
@override
State<_EnergyForm> createState() => _EnergyFormState();
}
class _EnergyFormState extends State<_EnergyForm> {
EnergyType _selectedType = EnergyType.luce;
String? _selectedProviderId;
DateTime? _selectedExpiration;
int? _selectedMonthsPreset;
void _applyPreset(int? months) {
if (months == null) return;
setState(() {
_selectedMonthsPreset = months;
// Calcoliamo la data: oggi + X mesi
final now = DateTime.now();
_selectedExpiration = DateTime(now.year, now.month + months, now.day);
});
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now().add(
const Duration(days: 365),
), // Default 1 anno
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
);
if (picked != null) {
setState(() => _selectedExpiration = picked);
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. Tipo (Luce o Gas) - Segmented Button stile M3
SegmentedButton<EnergyType>(
segments: const [
ButtonSegment(
value: EnergyType.luce,
label: Text("Luce"),
icon: Icon(Icons.lightbulb_outline),
),
ButtonSegment(
value: EnergyType.gas,
label: Text("Gas"),
icon: Icon(Icons.local_fire_department),
),
],
selected: {_selectedType},
onSelectionChanged: (Set<EnergyType> newSelection) {
setState(() => _selectedType = newSelection.first);
},
),
const SizedBox(height: 20),
// 2. SCADENZA INTELLIGENTE (La parte PRO)
const Text(
"Scadenza Contratto",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 8),
SegmentedButton<int?>(
showSelectedIcon: false, // Per un look più pulito
segments: const [
ButtonSegment(value: 12, label: Text("12m")),
ButtonSegment(value: 24, label: Text("24m")),
ButtonSegment(value: 36, label: Text("36m")),
ButtonSegment(
value: null,
label: Icon(Icons.calendar_month, size: 20),
),
],
selected: {_selectedMonthsPreset},
onSelectionChanged: (Set<int?> newSelection) {
final val = newSelection.first;
if (val == null) {
_pickDate(); // Se clicca l'icona calendario, apre il picker
} else {
_applyPreset(val); // Altrimenti applica 12, 24 o 36
}
},
),
const SizedBox(height: 12),
// Visualizzazione della data calcolata (o scelta)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _selectedExpiration != null
? Theme.of(context).colorScheme.primary
: Colors.transparent,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event,
size: 18,
color: _selectedExpiration != null
? Theme.of(context).colorScheme.primary
: Colors.grey,
),
const SizedBox(width: 8),
Text(
_selectedExpiration != null
? "Scade il: ${_selectedExpiration!.day.toString().padLeft(2, '0')}/${_selectedExpiration!.month.toString().padLeft(2, '0')}/${_selectedExpiration!.year}"
: "Seleziona una scadenza",
style: TextStyle(
fontWeight: FontWeight.bold,
color: _selectedExpiration != null
? Theme.of(context).colorScheme.onSurface
: Colors.grey,
),
),
],
),
),
const SizedBox(height: 20),
// 2. Provider Dropdown
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: LinearProgressIndicator(),
); // Mostra una barretta di caricamento
}
if (state.activeProviders.isEmpty) {
return const Text(
"Nessun gestore associato a questo negozio.",
style: TextStyle(color: Colors.red),
);
}
// Filtra solo i provider di tipo Energia (Se hai una categoria nel modello)
// Se non hai una categoria nel ProviderModel, puoi rimuovere il .where
final energyProviders = state.activeProviders;
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: "Gestore / Provider",
border: OutlineInputBorder(),
),
initialValue: _selectedProviderId,
items: energyProviders.map((p) {
return DropdownMenuItem(value: p.id, child: Text(p.nome));
}).toList(),
onChanged: (val) => setState(() => _selectedProviderId = val),
);
},
),
const SizedBox(height: 16),
// 3. Scadenza (DatePicker integrato in un TextField)
TextFormField(
readOnly: true,
onTap: _pickDate,
decoration: InputDecoration(
labelText: "Data Scadenza",
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.calendar_month),
),
// Mostra la data se selezionata, altrimenti vuoto
controller: TextEditingController(
text: _selectedExpiration != null
? "${_selectedExpiration!.day}/${_selectedExpiration!.month}/${_selectedExpiration!.year}"
: "",
),
),
const SizedBox(height: 24),
// 4. Pulsanti Interni al Form
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text("Indietro"),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed:
(_selectedProviderId == null || _selectedExpiration == null)
? null // Disabilitato se mancano dati obbligatori
: () {
final newService = EnergyServiceModel(
type: _selectedType,
expiration: _selectedExpiration!,
providerId: _selectedProviderId!,
);
widget.onSave(newService);
},
child: const Text("Salva Contratto"),
),
],
),
],
);
}
}

View File

@@ -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<EntertainmentServiceModel> initialServices;
final String currentStoreId;
const EntertainmentServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
});
@override
State<EntertainmentServiceDialog> createState() =>
_EntertainmentServiceDialogState();
}
class _EntertainmentServiceDialogState
extends State<EntertainmentServiceDialog> {
late List<EntertainmentServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Carichiamo i provider attivi per lo store corrente
context.read<ProvidersCubit>().loadActiveProvidersForStore(
widget.currentStoreId,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(
Icons.movie_filter_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(_isAddingNew ? "Nuovo Servizio" : "Servizi Intrattenimento"),
],
),
content: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: _isAddingNew
? _EntertainmentForm(
// Il form che abbiamo creato prima
onSave: (newService) => setState(() {
_tempList.add(newService);
_isAddingNew = false;
}),
onCancel: () => setState(() => _isAddingNew = false),
)
: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
// Passiamo allProviders per garantire la visione dello storico
return _EntertainmentList(
services: _tempList,
allProviders: state.allProviders,
onDelete: (index) =>
setState(() => _tempList.removeAt(index)),
onAddTap: () => setState(() => _isAddingNew = true),
);
},
),
),
),
actions: !_isAddingNew
? [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, _tempList),
child: const Text("Conferma Tutti"),
),
]
: null, // I pulsanti del form sono interni al form stesso
);
}
}
class _EntertainmentList extends StatelessWidget {
final List<EntertainmentServiceModel> services;
final List<ProviderModel> allProviders;
final Function(int) onDelete;
final VoidCallback onAddTap;
const _EntertainmentList({
required this.services,
required this.allProviders,
required this.onDelete,
required this.onAddTap,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (services.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Text(
"Nessun servizio intrattenimento.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: services.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final s = services[index];
final providerName = allProviders
.firstWhere(
(p) => p.id == s.providerId,
orElse: () => ProviderModel(
id: '',
nome: 'Fornitore Storico',
companyId: '',
isActive: false,
energia: false,
telefoniaFissa: false,
telefoniaMobile: false,
assicurazioni: false,
finanziamenti: false,
altro: false,
intrattenimento: false,
),
)
.nome;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Colors.purple.shade100,
child: const Icon(
Icons.movie_creation_outlined,
color: Colors.purple,
),
),
title: Text(
"${s.type}$providerName",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
s.constrained
? "Vincolo fino al: ${s.constrainExpiration.day}/${s.constrainExpiration.month}/${s.constrainExpiration.year}"
: "Senza vincoli",
style: TextStyle(
color: s.constrained
? Colors.red.shade700
: Colors.green.shade700,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => onDelete(index),
),
);
},
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi Servizio"),
),
],
);
}
}
// ---ENTERTAINMENT FORM (MODALE)---
class _EntertainmentForm extends StatefulWidget {
final Function(EntertainmentServiceModel) onSave;
final VoidCallback onCancel;
const _EntertainmentForm({required this.onSave, required this.onCancel});
@override
State<_EntertainmentForm> createState() => _EntertainmentFormState();
}
class _EntertainmentFormState extends State<_EntertainmentForm> {
String? _selectedProviderId;
final TextEditingController _typeController = TextEditingController();
bool _isConstrained = false;
DateTime _expirationDate = DateTime.now().add(
const Duration(days: 365),
); // Default 12 mesi
// Preset rapidi per il vincolo (es: 12, 24 mesi)
int? _selectedPresetMonths;
void _applyPreset(int months) {
setState(() {
_selectedPresetMonths = months;
_isConstrained = true;
final now = DateTime.now();
_expirationDate = DateTime(now.year, now.month + months, now.day);
});
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _expirationDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
);
if (picked != null) {
setState(() {
_expirationDate = picked;
_selectedPresetMonths = null;
_isConstrained = true;
});
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. GESTORE (Filtro intrattenimento)
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
final filtered = state.activeProviders
.where((p) => p.intrattenimento)
.toList();
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: "Fornitore (es: Sky, TIM)",
border: OutlineInputBorder(),
),
items: filtered
.map(
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
)
.toList(),
onChanged: (val) => setState(() => _selectedProviderId = val),
);
},
),
const SizedBox(height: 16),
// 2. TIPO SERVIZIO (TextField con suggerimenti rapidi sotto)
TextFormField(
controller: _typeController,
decoration: const InputDecoration(
labelText: "Servizio",
hintText: "es: Netflix, DAZN, Disney+",
border: OutlineInputBorder(),
),
onChanged: (val) => setState(() {}),
),
const SizedBox(height: 8),
// Suggerimenti rapidi (Chip)
FutureBuilder<List<String>>(
future: GetIt.I<ServicesRepository>().fetchTopEntertainmentTypes(
GetIt.I<SessionBloc>().state.company!.id,
),
builder: (context, snapshot) {
final suggestions = snapshot.data ?? ["Netflix", "DAZN", "Sky"];
return Wrap(
spacing: 8,
children: suggestions.map((s) {
return ActionChip(
label: Text(s, style: const TextStyle(fontSize: 12)),
onPressed: () => setState(() => _typeController.text = s),
);
}).toList(),
);
},
),
const SizedBox(height: 16),
// 3. VINCOLO CONTRATTUALE
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Vincolo di permanenza",
style: TextStyle(fontWeight: FontWeight.bold),
),
Switch(
value: _isConstrained,
onChanged: (val) => setState(() {
_isConstrained = val;
if (!val) _selectedPresetMonths = null;
}),
),
],
),
if (_isConstrained) ...[
const SizedBox(height: 8),
SegmentedButton<int?>(
segments: const [
ButtonSegment(value: 12, label: Text("12m")),
ButtonSegment(value: 24, label: Text("24m")),
ButtonSegment(
value: null,
label: Icon(Icons.calendar_month, size: 20),
),
],
selected: {_selectedPresetMonths},
onSelectionChanged: (val) {
if (val.first == null) {
_pickDate();
} else {
_applyPreset(val.first!);
}
},
),
const SizedBox(height: 12),
// Box data scadenza vincolo
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.event_busy, size: 18, color: Colors.redAccent),
const SizedBox(width: 8),
Text(
"Scadenza vincolo: ${_expirationDate.day.toString().padLeft(2, '0')}/${_expirationDate.month.toString().padLeft(2, '0')}/${_expirationDate.year}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
],
const SizedBox(height: 24),
// PULSANTI
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text("Annulla"),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed:
(_selectedProviderId == null || _typeController.text.isEmpty)
? null
: () => widget.onSave(
EntertainmentServiceModel(
providerId: _selectedProviderId!,
type: _typeController.text,
constrained: _isConstrained,
constrainExpiration: _expirationDate,
),
),
child: const Text("Aggiungi"),
),
],
),
],
);
}
}

View File

@@ -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<FinServiceModel> initialServices;
final String currentStoreId;
final ProductCubit productCubit;
const FinanceServiceDialog({
super.key,
required this.initialServices,
required this.currentStoreId,
required this.productCubit,
});
@override
State<FinanceServiceDialog> createState() => _FinanceServiceDialogState();
}
class _FinanceServiceDialogState extends State<FinanceServiceDialog> {
late List<FinServiceModel> _tempList;
bool _isAddingNew = false;
@override
void initState() {
super.initState();
_tempList = List.from(widget.initialServices);
// Carichiamo i dati necessari dai Cubit
context.read<ProvidersCubit>().loadActiveProvidersForStore(
widget.currentStoreId,
);
context.read<ProductCubit>().loadBrands();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: widget.productCubit,
child: AlertDialog(
title: Row(
children: [
Icon(
Icons.payments_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(_isAddingNew ? "Dettagli Finanziamento" : "Finanziamenti"),
],
),
content: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: _isAddingNew
? _FinanceForm(
onSave: (newFin) => setState(() {
_tempList.add(newFin);
_isAddingNew = false;
}),
onCancel: () => setState(() => _isAddingNew = false),
)
: BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, provState) {
return BlocBuilder<ProductCubit, ProductState>(
builder: (context, prodState) {
return _FinanceList(
services: _tempList,
allProviders:
provState.allProviders, // Per vedere lo storico
allModels: prodState.models,
onDelete: (index) =>
setState(() => _tempList.removeAt(index)),
onAddTap: () => setState(() => _isAddingNew = true),
);
},
);
},
),
),
),
actions: !_isAddingNew
? [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, _tempList),
child: const Text("Conferma"),
),
]
: null,
),
);
}
}
// ===========================================================================
// VISTA LISTA (STORICA)
// ===========================================================================
class _FinanceList extends StatelessWidget {
final List<FinServiceModel> services;
final List<ProviderModel> allProviders;
final List<ModelModel> allModels;
final Function(int) onDelete;
final VoidCallback onAddTap;
const _FinanceList({
required this.services,
required this.allProviders,
required this.allModels,
required this.onDelete,
required this.onAddTap,
});
@override
Widget build(BuildContext context) {
if (services.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Text(
"Nessun finanziamento inserito.",
style: TextStyle(color: Colors.grey),
),
),
OutlinedButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi primo"),
),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: services.length,
separatorBuilder: (_, _) => const Divider(),
itemBuilder: (context, index) {
final s = services[index];
// Cerchiamo il nome del provider in TUTTI quelli caricati (storico)
final providerName = allProviders
.firstWhere(
(p) => p.id == s.providerId,
orElse: () => ProviderModel(
id: '',
nome: 'Operatore Storico',
companyId: '',
isActive: false,
energia: false,
telefoniaFissa: false,
telefoniaMobile: false,
assicurazioni: false,
altro: false,
intrattenimento: false,
finanziamenti: false,
),
)
.nome;
// Cerchiamo il nome del modello
final modelName = allModels
.firstWhere(
(m) => m.id == s.modelId,
orElse: () => ModelModel(
id: '',
name: 'Prodotto',
nameWithBrand: 'Prodotto Storico',
brandId: '',
),
)
.nameWithBrand;
final dateStr =
"${s.expiration.day.toString().padLeft(2, '0')}/${s.expiration.month.toString().padLeft(2, '0')}/${s.expiration.year}";
return ListTile(
title: Text(
modelName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text("$providerName • Scade: $dateStr"),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => onDelete(index),
),
);
},
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: onAddTap,
icon: const Icon(Icons.add),
label: const Text("Aggiungi altro"),
),
],
);
}
}
// ===========================================================================
// FORM CON OMNI-SEARCH
// ===========================================================================
class _FinanceForm extends StatefulWidget {
final Function(FinServiceModel) onSave;
final VoidCallback onCancel;
const _FinanceForm({required this.onSave, required this.onCancel});
@override
State<_FinanceForm> createState() => _FinanceFormState();
}
class _FinanceFormState extends State<_FinanceForm> {
String? _selectedProviderId;
ModelModel? _selectedModel;
int _selectedMonths = 30; // Default richiesto
Timer? _debounce;
final TextEditingController _searchController = TextEditingController();
late DateTime _selectedExpirationDate;
@override
void initState() {
super.initState();
final now = DateTime.now();
_selectedExpirationDate = DateTime(
now.year,
now.month + _selectedMonths,
now.day,
); // Inizialmente 30 mesi dalla data attuale
}
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
context.read<ProductCubit>().searchModels(query);
});
}
// Funzione per aggiornare la data quando si clicca sui segmenti 24, 30, 48
void _updateExpirationByMonths(int months) {
setState(() {
_selectedMonths = months;
final now = DateTime.now();
// Calcolo preciso: aggiungiamo i mesi alla data attuale
_selectedExpirationDate = DateTime(now.year, now.month + months, now.day);
});
}
// Funzione per il picker manuale
Future<void> _selectManualDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedExpirationDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(
const Duration(days: 365 * 10),
), // Fino a 10 anni
);
if (picked != null && picked != _selectedExpirationDate) {
setState(() {
_selectedExpirationDate = picked;
_selectedMonths = 0; // Resettiamo i segmenti perché è una data custom
});
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. SCELTA ISTITUTO (Solo attivi)
BlocBuilder<ProvidersCubit, ProvidersState>(
builder: (context, state) {
final finProviders = state.activeProviders
.where((p) => p.finanziamenti)
.toList(); // Già filtrati dal caricamento della dialog
return DropdownButtonFormField<String>(
initialValue: _selectedProviderId,
decoration: const InputDecoration(
labelText: "Gestore",
border: OutlineInputBorder(),
),
items: finProviders
.map(
(p) => DropdownMenuItem(value: p.id, child: Text(p.nome)),
)
.toList(),
onChanged: (val) => setState(() => _selectedProviderId = val),
);
},
),
const SizedBox(height: 16),
// 2. RICERCA MODELLO
if (_selectedModel == null) ...[
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Cerca modello (es: iPhone...)",
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => _showQuickCreate(context),
),
),
onChanged: (val) {
_onSearchChanged(val);
},
),
const SizedBox(height: 8),
_buildSearchSuggestions(),
] else
Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: ListTile(
leading: const Icon(Icons.phone_android),
title: Text(
_selectedModel!.nameWithBrand,
style: const TextStyle(fontWeight: FontWeight.bold),
),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() => _selectedModel = null),
),
),
),
const SizedBox(height: 16),
// 3. DURATA PRESET
const Text(
"Durata Rate",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 24, label: Text("24m")),
ButtonSegment(value: 30, label: Text("30m")),
ButtonSegment(value: 48, label: Text("48m")),
],
selected: {_selectedMonths},
onSelectionChanged: (val) => _updateExpirationByMonths(val.first),
),
const SizedBox(height: 16),
// RIEPILOGO DATA E PICKER MANUALE (Stile Energia)
const Text(
"Scadenza Finanziamento",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 4),
InkWell(
onTap: _selectManualDate,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(
Icons.calendar_today,
size: 18,
color: Colors.blue,
),
const SizedBox(width: 12),
Text(
"${_selectedExpirationDate.day.toString().padLeft(2, '0')}/${_selectedExpirationDate.month.toString().padLeft(2, '0')}/${_selectedExpirationDate.year}",
style: const TextStyle(fontSize: 16),
),
],
),
const Icon(Icons.edit, size: 18, color: Colors.grey),
],
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text("Indietro"),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: (_selectedProviderId == null || _selectedModel == null)
? null
: () {
final now = DateTime.now();
widget.onSave(
FinServiceModel(
providerId: _selectedProviderId!,
modelId: _selectedModel!.id!,
expiration: DateTime(
now.year,
now.month + _selectedMonths,
now.day,
),
),
);
},
child: const Text("Salva"),
),
],
),
],
);
}
Widget _buildSearchSuggestions() {
return BlocBuilder<ProductCubit, ProductState>(
builder: (context, state) {
final query = _searchController.text.toLowerCase();
if (query.isEmpty) return const SizedBox.shrink();
final filtered = state.models
.where((m) => m.nameWithBrand.toLowerCase().contains(query))
.take(3)
.toList();
return Column(
children: filtered
.map(
(m) => ListTile(
title: Text(m.nameWithBrand),
onTap: () => setState(() => _selectedModel = m),
dense: true,
),
)
.toList(),
);
},
);
}
void _showQuickCreate(BuildContext context) {
// Implementazione rapida dialog creazione Brand/Modello come discusso prima
}
}

View File

@@ -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<ServicesCubit>().updateField(number: val);
},
),
const SizedBox(height: 16),
// I due Switch affiancati (Bozza e A buon fine)
Row(
children: [
Expanded(
child: SwitchListTile(
title: const Text("Bozza"),
subtitle: const Text(
"Pratica in lavorazione",
style: TextStyle(fontSize: 12),
),
value: service.isBozza,
activeThumbColor: Colors.orange,
contentPadding: EdgeInsets.zero,
onChanged: (val) {
context.read<ServicesCubit>().updateField(isBozza: val);
},
),
),
const SizedBox(width: 16),
Expanded(
child: SwitchListTile(
title: const Text("A buon fine"),
subtitle: const Text(
"Esito positivo",
style: TextStyle(fontSize: 12),
),
value: service.resultOk,
activeThumbColor: Colors.green,
contentPadding: EdgeInsets.zero,
onChanged: (val) {
context.read<ServicesCubit>().updateField(resultOk: val);
},
),
),
],
),
const SizedBox(height: 16),
// Campo Note
TextFormField(
initialValue: service.note,
maxLines: 4,
minLines: 2,
decoration: const InputDecoration(
labelText: "Note Operazione",
hintText:
"Scrivi qui eventuali dettagli o richieste del cliente...",
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
onChanged: (val) {
context.read<ServicesCubit>().updateField(note: val);
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,158 @@
import 'dart:async'; // Necessario per il Timer
import 'package:flutter/material.dart';
Future<void> updateCountDialog(
BuildContext context,
String title,
int currentValue,
Function(int) onSave,
) async {
int tempValue =
currentValue; // Variabile locale per gestire il conteggio nella dialog
final result = await showDialog<int>(
context: context,
builder: (context) => AlertDialog(
title: Text("Imposta $title"),
content: QuickCounter(
initialValue: tempValue,
onChanged: (val) => tempValue =
val, // Aggiorna il valore locale quando il counter cambia
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, tempValue),
child: const Text("Conferma"),
),
],
),
);
if (result != null) {
onSave(result);
}
}
// --- Widget Interno Specifico per il Counter Veloce ---
class QuickCounter extends StatefulWidget {
final int initialValue;
final ValueChanged<int>
onChanged; // Callback per notificare il padre dei cambiamenti
const QuickCounter({
super.key,
required this.initialValue,
required this.onChanged,
});
@override
State<QuickCounter> createState() => _QuickCounterState();
}
class _QuickCounterState extends State<QuickCounter> {
late int _value;
Timer? _longPressTimer; // Il timer per l'auto-incremento
@override
void initState() {
super.initState();
_value = widget.initialValue;
}
@override
void dispose() {
_longPressTimer
?.cancel(); // IMPORTANTE: Annulla sempre il timer alla distruzione
super.dispose();
}
// Logica comune per incremento/decremento singolo o rapido
void _update(int delta) {
setState(() {
_value += delta;
if (_value < 0) _value = 0; // Impedisci numeri negativi
});
widget.onChanged(_value); // Notifica il padre
}
// Gestione dell'inizio della pressione prolungata
void _startLongPress(int delta) {
_update(delta); // Esegui subito il primo aggiornamento al tocco iniziale
_longPressTimer = Timer.periodic(const Duration(milliseconds: 100), (
timer,
) {
_update(delta); // Aggiorna velocemente finché la pressione continua
});
}
// Gestione della fine della pressione prolungata
void _stopLongPress() {
_longPressTimer?.cancel();
}
@override
Widget build(BuildContext context) {
final canDecrement = _value > 0;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// --- Pulsante MENO ---
GestureDetector(
onLongPressStart: canDecrement ? (_) => _startLongPress(-1) : null,
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: () => _stopLongPress(),
onTap: canDecrement ? () => _update(-1) : null,
child: Opacity(
// Visivamente disabilitato se < 0
opacity: canDecrement ? 1.0 : 0.4,
child: const ActionButton(icon: Icons.remove, color: Colors.red),
),
),
// --- Valore Centrale ---
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
_value.toString(),
style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
),
),
// --- Pulsante PIU' ---
GestureDetector(
onLongPressStart: (_) => _startLongPress(1),
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: () => _stopLongPress(),
onTap: () => _update(1),
child: const ActionButton(icon: Icons.add, color: Colors.green),
),
],
);
}
}
// Piccolo widget di utilità per l'aspetto del pulsante
class ActionButton extends StatelessWidget {
final IconData icon;
final Color color;
const ActionButton({super.key, required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
border: Border.all(color: color, width: 2),
),
child: Icon(icon, color: color, size: 30),
);
}
}

View File

@@ -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<ServiceFormScreen> createState() => _ServiceFormScreenState();
}
class _ServiceFormScreenState extends State<ServiceFormScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// Diamo in pasto al Cubit tutto quello che abbiamo!
context.read<ServicesCubit>().initServiceForm(
existingService: widget.existingService,
serviceId: widget.serviceId,
);
});
}
void _performSave(BuildContext context, {required bool isBozza}) {
FocusScope.of(context).unfocus();
context.read<ServicesCubit>().saveCurrentService(isBozza: isBozza);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<ServicesCubit, ServicesState>(
listener: (context, state) {
if (state.status == ServicesStatus.saved) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Pratica salvata con successo!"),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
} 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),
),
),
],
);
}
}

View File

@@ -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<ServicesCubit>().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<ServicesCubit>().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<ServicesCubit>().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<ServicesCubit>().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<ServicesCubit>().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<List<EnergyServiceModel>>(
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<ServicesCubit>().updateEnergyServices(
result,
);
}
},
),
ActionCard(
label: "Finanziam.",
count: service.finServices.length,
icon: Icons.euro_symbol,
color: Colors.teal,
onTap: () async {
final result = await showDialog<List<FinServiceModel>>(
context: context,
builder: (context) => FinanceServiceDialog(
productCubit: context.read<ProductCubit>(),
currentStoreId: service.storeId,
initialServices:
service.finServices, // Passiamo la lista attuale
),
);
if (result != null && context.mounted) {
context.read<ServicesCubit>().updateFinServices(result);
}
},
),
ActionCard(
label: "Intratten.",
count: service.entertainmentServices.length,
icon: Icons.movie_filter_outlined,
color: Colors.purple,
onTap: () async {
final result =
await showDialog<List<EntertainmentServiceModel>>(
context: context,
builder: (context) => EntertainmentServiceDialog(
initialServices: service.entertainmentServices,
currentStoreId: service.storeId,
),
);
if (result != null && context.mounted) {
context
.read<ServicesCubit>()
.updateEntertainmentServices(result);
}
},
),
],
),
),
],
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/blocs/services_cubit.dart';
import 'package:flux/features/services/models/service_model.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'; import 'package:go_router/go_router.dart';
// Importa i tuoi modelli e cubit // Importa i tuoi modelli e cubit
@@ -20,6 +21,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
super.initState(); super.initState();
// Agganciamo il listener per la paginazione (Scroll Infinito) // Agganciamo il listener per la paginazione (Scroll Infinito)
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
// Carichiamo i servizi iniziali
context.read<ServicesCubit>().loadServices();
} }
void _onScroll() { void _onScroll() {
@@ -60,7 +63,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
body: BlocBuilder<ServicesCubit, ServicesState>( body: BlocBuilder<ServicesCubit, ServicesState>(
builder: (context, state) { builder: (context, state) {
// 1. Stato di caricamento iniziale // 1. Stato di caricamento iniziale
if (state.isLoading && state.allServices.isEmpty) { if (state.status == ServicesStatus.loading &&
state.allServices.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -111,7 +115,7 @@ class _ServicesScreenState extends State<ServicesScreen> {
}, },
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => context.pushNamed('service-form'), // GoRouter onPressed: () => startNewService(context),
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
); );
@@ -171,7 +175,12 @@ class _ServicesScreenState extends State<ServicesScreen> {
], ],
), ),
trailing: const Icon(Icons.chevron_right), 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!} : {},
),
), ),
); );
} }

View File

@@ -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<SessionBloc>().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<StoreCubit, StoreState>(
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<ServicesCubit>().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),
],
),
);
},
);
},
);
}

View File

@@ -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/auth/bloc/auth_bloc.dart';
import 'package:flux/features/company/bloc/company_bloc.dart'; import 'package:flux/features/company/bloc/company_bloc.dart';
import 'package:flux/features/company/data/company_repository.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/customers/data/customer_repository.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/master_data/products/data/product_repository.dart'; import 'package:flux/features/master_data/products/data/product_repository.dart';
@@ -37,8 +37,16 @@ void main() async {
BlocProvider<ThemeBloc>( BlocProvider<ThemeBloc>(
create: (context) => ThemeBloc()..add(LoadThemeEvent()), create: (context) => ThemeBloc()..add(LoadThemeEvent()),
), ),
BlocProvider<SessionBloc>( BlocProvider<SessionBloc>(create: (_) => GetIt.I<SessionBloc>()),
create: (context) => SessionBloc()..add(AppStarted()), BlocProvider<AuthBloc>(create: (_) => AuthBloc()),
BlocProvider<CompanyBloc>(create: (_) => CompanyBloc()),
BlocProvider<StoreCubit>(create: (_) => StoreCubit()..loadStores()),
BlocProvider<CustomerCubit>(create: (_) => CustomerCubit()),
BlocProvider<ProductCubit>(create: (_) => ProductCubit()),
BlocProvider<StaffCubit>(create: (_) => StaffCubit()..loadAllStaff()),
BlocProvider<ServicesCubit>(create: (_) => ServicesCubit()),
BlocProvider<ProvidersCubit>(
create: (_) => ProvidersCubit()..loadProviders(null),
), ),
], ],
child: const FluxApp(), child: const FluxApp(),
@@ -65,6 +73,7 @@ Future<void> setupLocator() async {
getIt.registerLazySingleton<StaffRepository>(() => StaffRepository()); getIt.registerLazySingleton<StaffRepository>(() => StaffRepository());
getIt.registerLazySingleton<ServicesRepository>(() => ServicesRepository()); getIt.registerLazySingleton<ServicesRepository>(() => ServicesRepository());
getIt.registerLazySingleton<ProviderRepository>(() => ProviderRepository()); getIt.registerLazySingleton<ProviderRepository>(() => ProviderRepository());
getIt.registerSingleton<SessionBloc>(SessionBloc()..add(AppStarted()));
} }
class FluxApp extends StatefulWidget { class FluxApp extends StatefulWidget {
@@ -86,30 +95,12 @@ class _FluxAppState extends State<FluxApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return BlocBuilder<SessionBloc, SessionState>(
providers: [ builder: (context, state) {
BlocProvider<AuthBloc>(create: (_) => AuthBloc()), if (state.status == SessionStatus.unknown) {
BlocProvider<CompanyBloc>(create: (_) => CompanyBloc()), return _buildLoadingScreen();
BlocProvider<StoreCubit>( }
create: (_) => StoreCubit(context.read<SessionBloc>())..loadStores(), return BlocBuilder<ThemeBloc, ThemeState>(
),
BlocProvider<CustomerBloc>(create: (_) => CustomerBloc()),
BlocProvider<ProductCubit>(
create: (context) => ProductCubit(context.read<SessionBloc>()),
),
BlocProvider<StaffCubit>(
create: (_) =>
StaffCubit(context.read<SessionBloc>())..loadAllStaff(),
),
BlocProvider<ServicesCubit>(
create: (_) => ServicesCubit(context.read<SessionBloc>()),
),
BlocProvider<ProvidersCubit>(
create: (_) =>
ProvidersCubit(context.read<SessionBloc>())..loadProviders(null),
),
],
child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, state) { builder: (context, state) {
return MaterialApp.router( return MaterialApp.router(
title: 'FLUX Gestionale', title: 'FLUX Gestionale',
@@ -120,6 +111,35 @@ class _FluxAppState extends State<FluxApp> {
routerConfig: _router, // Usa l'istanza mantenuta nello stato 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,
),
),
],
),
),
), ),
); );
} }

View File

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

View File

@@ -90,7 +90,7 @@ packages:
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
collection: collection:
dependency: transitive dependency: "direct main"
description: description:
name: collection name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
@@ -145,6 +145,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.8" version: "2.0.8"
extension:
dependency: transitive
description:
name: extension
sha256: be3a6b7f8adad2f6e2e8c63c895d19811fcf203e23466c6296267941d0ff4f24
url: "https://pub.dev"
source: hosted
version: "0.6.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -177,6 +185,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.0.2" version: "11.0.2"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -312,6 +328,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" 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: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -488,6 +512,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdfx:
dependency: "direct main"
description:
name: pdfx
sha256: "29db9b71d46bf2335e001f91693f2c3fbbf0760e4c2eb596bf4bafab211471c1"
url: "https://pub.dev"
source: hosted
version: "2.9.2"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -496,6 +528,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.2" 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: platform:
dependency: transitive dependency: transitive
description: description:
@@ -685,6 +725,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.2" 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: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -709,6 +757,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: url_launcher:
dependency: transitive dependency: transitive
description: description:
@@ -773,6 +837,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_graphics: vector_graphics:
dependency: transitive dependency: transitive
description: description:

View File

@@ -7,6 +7,7 @@ environment:
sdk: ^3.11.3 sdk: ^3.11.3
dependencies: dependencies:
collection: ^1.19.1
equatable: ^2.0.8 equatable: ^2.0.8
file_picker: ^11.0.2 file_picker: ^11.0.2
flutter: flutter:
@@ -17,7 +18,9 @@ dependencies:
get_it: ^9.2.1 get_it: ^9.2.1
go_router: ^17.2.0 go_router: ^17.2.0
google_fonts: ^8.0.2 google_fonts: ^8.0.2
internet_file: ^1.3.0
intl: ^0.20.2 intl: ^0.20.2
pdfx: ^2.9.2
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
supabase_flutter: ^2.12.2 supabase_flutter: ^2.12.2

View File

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

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
app_links app_links
pdfx
url_launcher_windows url_launcher_windows
) )