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:
88
ios/Podfile.lock
Normal file
88
ios/Podfile.lock
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
lib/core/widgets/image_viewer_widget.dart
Normal file
61
lib/core/widgets/image_viewer_widget.dart
Normal 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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
lib/core/widgets/pdf_viewer_widget.dart
Normal file
97
lib/core/widgets/pdf_viewer_widget.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
160
lib/features/customers/blocs/customer_cubit.dart
Normal file
160
lib/features/customers/blocs/customer_cubit.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
202
lib/features/customers/ui/customer_search_sheet.dart
Normal file
202
lib/features/customers/ui/customer_search_sheet.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
117
lib/features/customers/ui/quick_customer_dialog.dart
Normal file
117
lib/features/customers/ui/quick_customer_dialog.dart
Normal 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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
111
lib/features/master_data/products/ui/quick_product_dialog.dart
Normal file
111
lib/features/master_data/products/ui/quick_product_dialog.dart
Normal 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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
lib/features/services/blocs/services_state.dart
Normal file
54
lib/features/services/blocs/services_state.dart
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
.from('entertainment_service')
|
_supabase
|
||||||
.delete()
|
.from('entertainment_service')
|
||||||
.eq('service_id', newId);
|
.delete()
|
||||||
|
.eq('service_id', newId),
|
||||||
|
// Aggiungi qui eventuali altre tabelle pivot o file
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Inserimento EnergyServices
|
// 3. Inserimento dei moduli in parallelo per velocità
|
||||||
|
final List<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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
await _supabase.from('entertainment_service').insert(toInsert);
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
98
lib/features/services/models/service_file_model.dart
Normal file
98
lib/features/services/models/service_file_model.dart
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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!
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
76
lib/features/services/ui/service_action_card.dart
Normal file
76
lib/features/services/ui/service_action_card.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
lib/features/services/ui/service_form_screen/int_dialogs.dart
Normal file
158
lib/features/services/ui/service_form_screen/int_dialogs.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
lib/features/services/ui/service_form_screen/services_grid.dart
Normal file
196
lib/features/services/ui/service_form_screen/services_grid.dart
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!} : {},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
lib/features/services/utils/service_actions.dart
Normal file
82
lib/features/services/utils/service_actions.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,40 +95,51 @@ 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>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return MaterialApp.router(
|
||||||
|
title: 'FLUX Gestionale',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: fluxLightTheme,
|
||||||
|
darkTheme: fluxDarkTheme,
|
||||||
|
themeMode: state.currentTheme.themeMode,
|
||||||
|
routerConfig: _router, // Usa l'istanza mantenuta nello stato
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Una semplice schermata di caricamento coerente con il brand
|
||||||
|
Widget _buildLoadingScreen() {
|
||||||
|
return MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Qui puoi mettere il tuo logo
|
||||||
|
const Icon(Icons.bolt, size: 64, color: Colors.blue),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
"Inizializzazione sessione...",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
BlocProvider<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) {
|
|
||||||
return MaterialApp.router(
|
|
||||||
title: 'FLUX Gestionale',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
theme: fluxLightTheme,
|
|
||||||
darkTheme: fluxDarkTheme,
|
|
||||||
themeMode: state.currentTheme.themeMode,
|
|
||||||
routerConfig: _router, // Usa l'istanza mantenuta nello stato
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
74
pubspec.lock
74
pubspec.lock
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user