feat-insert-service #5
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 */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
44490B82E7859424F77CB04B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 975E96D2C8BBF1CF6A3F5F40 /* Pods_Runner.framework */; };
|
||||
449A3D64DB8C9C60EBDF7DD1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A2C92D305DE434FC3C442B0 /* Pods_RunnerTests.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
@@ -43,27 +45,44 @@
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
0170592D7DFD7A1AE8221644 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
449A3D64DB8C9C60EBDF7DD1 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
44490B82E7859424F77CB04B /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -78,6 +97,15 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6A991A28CCED9666CA172E00 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
975E96D2C8BBF1CF6A3F5F40 /* Pods_Runner.framework */,
|
||||
1A2C92D305DE434FC3C442B0 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -96,6 +124,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
F5D002C3092D87755D552D32 /* Pods */,
|
||||
6A991A28CCED9666CA172E00 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -124,6 +154,20 @@
|
||||
path = Runner;
|
||||
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 */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -131,8 +175,10 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
7385E42426A562D77ADB127F /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
0170592D7DFD7A1AE8221644 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -148,12 +194,14 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
55692154E5E0FA98E80084D6 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
6F6F1B58AD2DC9B50492B34B /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -241,6 +289,67 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
55692154E5E0FA98E80084D6 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
6F6F1B58AD2DC9B50492B34B /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
7385E42426A562D77ADB127F /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -382,6 +491,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 8B6E013555080C92974ED449 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -399,6 +509,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 35D61C73467480800D34D7BC /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -414,6 +525,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 8D4A0EA2456F02E466FCB0E1 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
||||
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</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/store/ui/create_store_screen.dart';
|
||||
import 'package:flux/features/services/models/service_model.dart';
|
||||
import 'package:flux/features/services/ui/service_form_screen.dart';
|
||||
import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:async';
|
||||
|
||||
@@ -80,9 +80,15 @@ class AppRouter {
|
||||
path: '/service-form',
|
||||
name: 'service-form',
|
||||
builder: (context, state) {
|
||||
// Recuperiamo il ServiceModel se passato come extra
|
||||
final service = state.extra as ServiceModel?;
|
||||
return ServiceFormScreen(initialService: service);
|
||||
// Recuperiamo l'oggetto se passato tramite 'extra'
|
||||
final existingService = state.extra as ServiceModel?;
|
||||
// Recuperiamo l'ID se presente nell'URL
|
||||
final serviceId = state.uri.queryParameters['serviceId'];
|
||||
|
||||
return ServiceFormScreen(
|
||||
serviceId: serviceId ?? existingService?.id,
|
||||
existingService: existingService,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -19,4 +19,24 @@ extension MyStringExtensions on String? {
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
String fileExtension() {
|
||||
if (this == null || this!.trim().isEmpty) return '';
|
||||
|
||||
final parts = this!.split('.');
|
||||
if (parts.length < 2) return ''; // Nessuna estensione trovata
|
||||
|
||||
return parts.last.toLowerCase();
|
||||
}
|
||||
|
||||
String fileNameWithoutExtension() {
|
||||
if (this == null || this!.trim().isEmpty) return '';
|
||||
this!.replaceAll(RegExp(r'[^a-zA-Z0-9\.\-]'), '_');
|
||||
final parts = this!.split('.');
|
||||
if (parts.length < 2) return this!; // Nessuna estensione trovata
|
||||
|
||||
return parts
|
||||
.sublist(0, parts.length - 1)
|
||||
.join('.'); // Ritorna tutto tranne l'ultima parte
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
class CustomerState extends Equatable {
|
||||
final CustomerStatus status;
|
||||
final List<CustomerModel> customers; // Per la lista generale
|
||||
final CustomerModel?
|
||||
lastCreatedCustomer; // <--- Fondamentale per la Dialog "al volo"
|
||||
final List<CustomerModel> customers;
|
||||
final CustomerModel? lastCreatedCustomer;
|
||||
final String? errorMessage;
|
||||
|
||||
const CustomerState({
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flux/core/blocs/session/session_bloc.dart';
|
||||
import 'package:flux/core/utils/string_extensions.dart';
|
||||
import 'package:flux/features/customers/models/customer_file_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/customer_model.dart';
|
||||
|
||||
class CustomerRepository {
|
||||
final SupabaseClient _client = GetIt.I<SupabaseClient>();
|
||||
final SupabaseClient _supabase = GetIt.I<SupabaseClient>();
|
||||
final String companyId = GetIt.I.get<SessionBloc>().state.company!.id;
|
||||
|
||||
// Crea un nuovo cliente
|
||||
Future<CustomerModel> createCustomer(CustomerModel customer) async {
|
||||
Future<CustomerModel> saveCustomer(CustomerModel customer) async {
|
||||
try {
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.insert(customer.toJson())
|
||||
.upsert(customer.toJson())
|
||||
.select()
|
||||
.single();
|
||||
return CustomerModel.fromJson(response);
|
||||
return CustomerModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante la creazione del cliente: $e';
|
||||
throw 'Errore durante il salvataggio del cliente: $e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<CustomerModel> updateCustomer(CustomerModel customer) async {
|
||||
try {
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.update(customer.toJson())
|
||||
.eq('id', customer.id!)
|
||||
.select()
|
||||
.single();
|
||||
return CustomerModel.fromJson(response);
|
||||
return CustomerModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante la modifica del cliente: $e';
|
||||
}
|
||||
@@ -40,14 +41,17 @@ class CustomerRepository {
|
||||
// Recupera tutti i clienti dell'azienda
|
||||
Future<List<CustomerModel>> getCustomers(String companyId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.select('*, customer_file(count)')
|
||||
.select('''
|
||||
*,
|
||||
customer_file(*)
|
||||
''')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('nome');
|
||||
|
||||
return (response as List).map((c) => CustomerModel.fromJson(c)).toList();
|
||||
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
|
||||
} catch (e) {
|
||||
throw 'Errore nel recupero clienti';
|
||||
}
|
||||
@@ -59,14 +63,14 @@ class CustomerRepository {
|
||||
String query,
|
||||
) async {
|
||||
try {
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('customer')
|
||||
.select()
|
||||
.eq('company_id', companyId)
|
||||
.or('nome.ilike.%$query%,telefono.ilike.%$query%')
|
||||
.limit(10);
|
||||
|
||||
return (response as List).map((c) => CustomerModel.fromJson(c)).toList();
|
||||
return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
@@ -75,13 +79,13 @@ class CustomerRepository {
|
||||
/// Recupera i file di un cliente specifico
|
||||
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('customer_file')
|
||||
.select()
|
||||
.eq('customer_id', customerId);
|
||||
|
||||
return (response as List)
|
||||
.map((f) => CustomerFileModel.fromJson(f))
|
||||
.map((f) => CustomerFileModel.fromMap(f))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw 'Errore recupero file: $e';
|
||||
@@ -89,8 +93,8 @@ class CustomerRepository {
|
||||
}
|
||||
|
||||
/// Salva il riferimento del file nel DB
|
||||
Future<void> saveFileReference(CustomerFileModel file) async {
|
||||
await _client.from('customer_file').insert(file.toJson());
|
||||
Future<void> saveCustomerFile(CustomerFileModel file) async {
|
||||
await _supabase.from('customer_file').insert(file.toMap());
|
||||
}
|
||||
|
||||
/// Carica un file e salva il riferimento nel database
|
||||
@@ -98,15 +102,24 @@ class CustomerRepository {
|
||||
required String customerId,
|
||||
required PlatformFile pickedFile,
|
||||
}) async {
|
||||
final cleanFileName = pickedFile.name.replaceAll(
|
||||
RegExp(r'[^a-zA-Z0-9\.\-]'),
|
||||
'_',
|
||||
);
|
||||
final storagePath =
|
||||
'$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
|
||||
final int fileSize = pickedFile.size;
|
||||
final fileToSave = CustomerFileModel(
|
||||
customerId: customerId,
|
||||
name: cleanFileName.fileNameWithoutExtension(),
|
||||
extension: cleanFileName.fileExtension(),
|
||||
url: storagePath,
|
||||
fileSize: fileSize,
|
||||
);
|
||||
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
|
||||
? 'application/pdf'
|
||||
: 'image/${fileToSave.extension}';
|
||||
try {
|
||||
final user = _client.auth.currentUser;
|
||||
if (user == null) throw 'Utente non autenticato';
|
||||
|
||||
final fileName = pickedFile.name;
|
||||
final extension = pickedFile.extension ?? '';
|
||||
final path =
|
||||
'${user.id}/$customerId/${DateTime.now().millisecondsSinceEpoch}_$fileName';
|
||||
|
||||
// Usiamo bytes invece del path per massima compatibilità
|
||||
if (pickedFile.bytes == null && pickedFile.path == null) {
|
||||
throw 'Impossibile leggere il contenuto del file';
|
||||
@@ -114,46 +127,43 @@ class CustomerRepository {
|
||||
|
||||
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
|
||||
if (pickedFile.bytes != null) {
|
||||
await _client.storage
|
||||
await _supabase.storage
|
||||
.from('documents')
|
||||
.uploadBinary(path, pickedFile.bytes!);
|
||||
} else {
|
||||
final file = File(pickedFile.path!);
|
||||
await _client.storage.from('documents').upload(path, file);
|
||||
.uploadBinary(
|
||||
storagePath,
|
||||
pickedFile.bytes!,
|
||||
fileOptions: FileOptions(contentType: mimeType, upsert: true),
|
||||
);
|
||||
}
|
||||
|
||||
final String publicUrl = _client.storage
|
||||
.from('documents')
|
||||
.getPublicUrl(path);
|
||||
|
||||
final fileRecord = CustomerFileModel(
|
||||
customerId: customerId,
|
||||
name: fileName,
|
||||
url: publicUrl,
|
||||
extension: extension,
|
||||
);
|
||||
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('customer_file')
|
||||
.insert(fileRecord.toJson())
|
||||
.insert(fileToSave.toMap())
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return CustomerFileModel.fromJson(response);
|
||||
return CustomerFileModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante l\'upload: $e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveFileReference(CustomerFileModel file) async {
|
||||
await _supabase.from('customer_file').upsert(file.toMap());
|
||||
}
|
||||
|
||||
/// Aggiorna la lista degli URL nel database
|
||||
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
|
||||
Future<void> deleteDocument(String fullPath) async {
|
||||
// Il path dovrebbe essere ricavato dall'URL
|
||||
final path = fullPath.split('documents/').last;
|
||||
await _client.storage.from('documents').remove([path]);
|
||||
await _supabase.storage.from('documents').remove([path]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class CustomerFileModel extends Equatable {
|
||||
final String url;
|
||||
final String extension;
|
||||
final DateTime? createdAt;
|
||||
final int fileSize;
|
||||
|
||||
const CustomerFileModel({
|
||||
this.id,
|
||||
@@ -15,31 +16,76 @@ class CustomerFileModel extends Equatable {
|
||||
required this.url,
|
||||
required this.extension,
|
||||
this.createdAt,
|
||||
required this.fileSize,
|
||||
});
|
||||
|
||||
factory CustomerFileModel.fromJson(Map<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(
|
||||
id: json['id'] as String,
|
||||
customerId: json['customer_id'],
|
||||
name: json['name'],
|
||||
url: json['url'],
|
||||
extension: json['extension'] ?? '',
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
: null,
|
||||
id: id ?? this.id,
|
||||
customerId: customerId ?? this.customerId,
|
||||
name: name ?? this.name,
|
||||
url: url ?? this.url,
|
||||
extension: extension ?? this.extension,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
fileSize: fileSize ?? this.fileSize,
|
||||
);
|
||||
}
|
||||
|
||||
Map<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 {
|
||||
if (id != null) 'id': id,
|
||||
'customer_id': customerId,
|
||||
'name': name,
|
||||
'url': url,
|
||||
'extension': extension,
|
||||
'file_size': fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
@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:flux/core/utils/string_extensions.dart';
|
||||
import 'package:flux/features/customers/models/customer_file_model.dart';
|
||||
|
||||
class CustomerModel extends Equatable {
|
||||
final String? id; // Bigint in SQL
|
||||
@@ -12,7 +13,7 @@ class CustomerModel extends Equatable {
|
||||
final bool nonDisturbare;
|
||||
final String companyId; // UUID
|
||||
final bool isActive;
|
||||
final int fileCount;
|
||||
final List<CustomerFileModel> files;
|
||||
|
||||
const CustomerModel({
|
||||
this.id,
|
||||
@@ -25,7 +26,7 @@ class CustomerModel extends Equatable {
|
||||
this.nonDisturbare = false,
|
||||
required this.companyId,
|
||||
this.isActive = true,
|
||||
this.fileCount = 0,
|
||||
this.files = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -40,7 +41,7 @@ class CustomerModel extends Equatable {
|
||||
nonDisturbare,
|
||||
companyId,
|
||||
isActive,
|
||||
fileCount,
|
||||
files,
|
||||
];
|
||||
|
||||
CustomerModel copyWith({
|
||||
@@ -54,7 +55,7 @@ class CustomerModel extends Equatable {
|
||||
bool? nonDisturbare,
|
||||
String? companyId,
|
||||
bool? isActive,
|
||||
int? fileCount,
|
||||
List<CustomerFileModel>? files,
|
||||
}) {
|
||||
return CustomerModel(
|
||||
id: id ?? this.id,
|
||||
@@ -67,32 +68,31 @@ class CustomerModel extends Equatable {
|
||||
nonDisturbare: nonDisturbare ?? this.nonDisturbare,
|
||||
companyId: companyId ?? this.companyId,
|
||||
isActive: isActive ?? this.isActive,
|
||||
fileCount: fileCount ?? this.fileCount,
|
||||
files: files ?? this.files,
|
||||
);
|
||||
}
|
||||
|
||||
factory CustomerModel.fromJson(Map<String, dynamic> json) {
|
||||
int count = 0;
|
||||
if (json['customer_file'] != null &&
|
||||
(json['customer_file'] as List).isNotEmpty) {
|
||||
count = json['customer_file'][0]['count'] ?? 0;
|
||||
}
|
||||
factory CustomerModel.fromMap(Map<String, dynamic> map) {
|
||||
return CustomerModel(
|
||||
id: json['id'] as String,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
id: map['id'] as String,
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
nome: (json['nome'] as String).myFormat(),
|
||||
telefono: json['telefono'],
|
||||
email: json['email'],
|
||||
note: json['note'] ?? '',
|
||||
dataUltimoContatto: json['data_ultimo_contatto'] != null
|
||||
? DateTime.parse(json['data_ultimo_contatto'])
|
||||
nome: (map['nome'] as String).myFormat(),
|
||||
telefono: map['telefono'],
|
||||
email: map['email'],
|
||||
note: map['note'] ?? '',
|
||||
dataUltimoContatto: map['data_ultimo_contatto'] != null
|
||||
? DateTime.parse(map['data_ultimo_contatto'])
|
||||
: null,
|
||||
nonDisturbare: json['non_disturbare'] ?? false,
|
||||
companyId: json['company_id'] as String,
|
||||
isActive: json['is_active'] ?? true,
|
||||
fileCount: count,
|
||||
nonDisturbare: map['non_disturbare'] ?? false,
|
||||
companyId: map['company_id'] as String,
|
||||
isActive: map['is_active'] ?? true,
|
||||
files:
|
||||
(map['customer_file'] as List?)
|
||||
?.map((x) => CustomerFileModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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:flux/core/blocs/session/session_bloc.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_bloc.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_cubit.dart';
|
||||
import 'package:flux/features/customers/models/customer_model.dart';
|
||||
import 'package:flux/features/customers/ui/customer_form.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -26,16 +26,14 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
void _loadInitialCustomers() {
|
||||
final companyId = context.read<SessionBloc>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomerBloc>().add(LoadCustomersRequested(companyId));
|
||||
context.read<CustomerCubit>().loadCustomers();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearch(String query) {
|
||||
final companyId = context.read<SessionBloc>().state.company?.id;
|
||||
if (companyId != null) {
|
||||
context.read<CustomerBloc>().add(
|
||||
SearchCustomersRequested(companyId, query),
|
||||
);
|
||||
context.read<CustomerCubit>().searchCustomers(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +55,12 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
|
||||
if (customer == null) {
|
||||
// CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create
|
||||
context.read<CustomerBloc>().add(
|
||||
CreateCustomerRequested(
|
||||
customerFromForm.copyWith(companyId: companyId),
|
||||
),
|
||||
context.read<CustomerCubit>().createCustomer(
|
||||
customerFromForm.copyWith(companyId: companyId),
|
||||
);
|
||||
} else {
|
||||
// CASO MODIFICA: L'ID e il companyId sono già nel modello
|
||||
context.read<CustomerBloc>().add(
|
||||
UpdateCustomerRequested(customerFromForm),
|
||||
);
|
||||
context.read<CustomerCubit>().updateCustomer(customerFromForm);
|
||||
}
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
@@ -125,7 +119,7 @@ class _CustomersContentState extends State<CustomersContent> {
|
||||
|
||||
// LISTA CLIENTI
|
||||
Expanded(
|
||||
child: BlocBuilder<CustomerBloc, CustomerState>(
|
||||
child: BlocBuilder<CustomerCubit, CustomerState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == CustomerStatus.loading &&
|
||||
state.customers.isEmpty) {
|
||||
@@ -235,11 +229,11 @@ class _CustomerTile extends StatelessWidget {
|
||||
style: TextStyle(color: context.secondaryText),
|
||||
),
|
||||
],
|
||||
if (customer.fileCount > 0) ...[
|
||||
if (customer.files.isNotEmpty) ...[
|
||||
Text(' - ', style: TextStyle(color: context.secondaryText)),
|
||||
Icon(Icons.attach_file, size: 14, color: context.accent),
|
||||
Text(
|
||||
'${customer.fileCount} doc',
|
||||
'${customer.files.length} doc',
|
||||
style: TextStyle(
|
||||
color: context.accent,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
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:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/home/ui/dashboard_action_card.dart';
|
||||
import 'package:flux/features/services/utils/service_actions.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class DashboardAdaptiveGrid extends StatelessWidget {
|
||||
@@ -36,7 +37,7 @@ class DashboardAdaptiveGrid extends StatelessWidget {
|
||||
label: 'Nuova Op',
|
||||
icon: Icons.add_task,
|
||||
color: context.accent,
|
||||
onTap: () {},
|
||||
onTap: () => startNewService(context),
|
||||
),
|
||||
DashboardActionCard(
|
||||
label: 'Clienti',
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_bloc.dart';
|
||||
import 'package:flux/core/theme/theme.dart';
|
||||
import 'package:flux/features/auth/bloc/auth_bloc.dart';
|
||||
import 'package:flux/features/master_data/master_data_hub_content.dart';
|
||||
import 'package:flux/features/services/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/services/ui/services_screen.dart';
|
||||
import 'dashboard_content.dart'; // Importiamo il contenuto della dashboard
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'dashboard_content.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -21,8 +23,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Caricamento "silenzioso" all'avvio dell'app
|
||||
// Usiamo WidgetsBinding per assicurarci che il contesto sia pronto
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<ServicesCubit>().loadServices();
|
||||
});
|
||||
@@ -34,15 +34,31 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
builder: (context, state) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Se lo schermo è più largo di 900px usiamo il layout Desktop
|
||||
final bool isLargeScreen = constraints.maxWidth > 900;
|
||||
final bool veryLargeScreen = constraints.maxWidth > 1200;
|
||||
final bool isMenuExtended = veryLargeScreen ? true : _extendRailway;
|
||||
|
||||
return Scaffold(
|
||||
// --- APPBAR (Solo Mobile) ---
|
||||
appBar: isLargeScreen
|
||||
? null
|
||||
: AppBar(
|
||||
title: const Text(
|
||||
'FLUX Gestionale',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: _buildUserMenu(context, isExtended: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
// --- SIDEBAR (Desktop) ---
|
||||
if (isLargeScreen)
|
||||
_buildNavigationRail(constraints.maxWidth > 1200),
|
||||
if (isLargeScreen) _buildDesktopSidebar(isMenuExtended),
|
||||
|
||||
// --- CONTENUTO DINAMICO ---
|
||||
Expanded(
|
||||
@@ -61,7 +77,209 @@ class _HomeScreenState extends State<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) {
|
||||
return BottomNavigationBar(
|
||||
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) {
|
||||
return IndexedStack(
|
||||
index: index,
|
||||
@@ -167,12 +311,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
isLargeScreen: isLargeScreen,
|
||||
onTabRequested: (idx) => setState(() => _selectedIndex = 2),
|
||||
),
|
||||
|
||||
ServicesScreen(),
|
||||
|
||||
// L'unico punto di ingresso per tutte le anagrafiche
|
||||
const ServicesScreen(),
|
||||
MasterDataHubContent(
|
||||
// Qui gestiamo la navigazione "interna" all'hub
|
||||
onOpenPage: (widget) {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_bloc.dart';
|
||||
@@ -10,9 +11,9 @@ part 'product_state.dart';
|
||||
|
||||
class ProductCubit extends Cubit<ProductState> {
|
||||
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
|
||||
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';
|
||||
|
||||
class ProductRepository {
|
||||
final SupabaseClient _client = GetIt.I<SupabaseClient>();
|
||||
final SupabaseClient _supabase = GetIt.I<SupabaseClient>();
|
||||
|
||||
// --- BRAND ---
|
||||
|
||||
/// Recupera tutti i brand dell'azienda
|
||||
Future<List<BrandModel>> getBrands(String companyId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('brand')
|
||||
.select()
|
||||
.eq('company_id', companyId)
|
||||
@@ -27,7 +27,7 @@ class ProductRepository {
|
||||
/// Crea o aggiorna un brand
|
||||
Future<BrandModel> upsertBrand(BrandModel brand) async {
|
||||
try {
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('brand')
|
||||
.upsert(brand.toJson())
|
||||
.select()
|
||||
@@ -44,7 +44,7 @@ class ProductRepository {
|
||||
/// Recupera i modelli di un brand specifico
|
||||
Future<List<ModelModel>> getModelsByBrand(String brandId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('model')
|
||||
.select()
|
||||
.eq('brand_id', brandId)
|
||||
@@ -61,7 +61,7 @@ class ProductRepository {
|
||||
/// NOTA: name_with_brand verrà gestito dal trigger SQL che hai lanciato!
|
||||
Future<ModelModel> upsertModel(ModelModel model) async {
|
||||
try {
|
||||
final response = await _client
|
||||
final response = await _supabase
|
||||
.from('model')
|
||||
.upsert(model.toJson())
|
||||
.select()
|
||||
@@ -78,9 +78,24 @@ class ProductRepository {
|
||||
/// Disattiva un brand o un modello (Soft Delete per non rompere le FK delle operazioni passate)
|
||||
Future<void> toggleActiveStatus(String table, String id, bool status) async {
|
||||
try {
|
||||
await _client.from(table).update({'is_active': status}).eq('id', id);
|
||||
await _supabase.from(table).update({'is_active': status}).eq('id', id);
|
||||
} catch (e) {
|
||||
throw 'Errore durante la modifica dello stato';
|
||||
}
|
||||
}
|
||||
|
||||
Future<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({
|
||||
this.id,
|
||||
required this.name,
|
||||
required this.nameWithBrand,
|
||||
this.nameWithBrand = '',
|
||||
required this.brandId,
|
||||
this.isActive = true,
|
||||
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';
|
||||
|
||||
class ProvidersState extends Equatable {
|
||||
final List<ProviderModel> allProviders; // Tutti i provider della company
|
||||
final List<String>
|
||||
associatedIds; // ID dei provider attivi nello store selezionato
|
||||
final List<ProviderModel> allProviders;
|
||||
final List<String> associatedIds;
|
||||
// NUOVO CAMPO: Lista dei provider pronti per essere usati nel form pratiche
|
||||
final List<ProviderModel> activeProviders;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
|
||||
const ProvidersState({
|
||||
this.allProviders = const [],
|
||||
this.associatedIds = const [],
|
||||
this.activeProviders = const [], // Inizializza
|
||||
this.isLoading = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
@@ -23,14 +25,18 @@ class ProvidersState extends Equatable {
|
||||
ProvidersState copyWith({
|
||||
List<ProviderModel>? allProviders,
|
||||
List<String>? associatedIds,
|
||||
List<ProviderModel>? activeProviders, // Aggiungi qui
|
||||
bool? isLoading,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return ProvidersState(
|
||||
allProviders: allProviders ?? this.allProviders,
|
||||
associatedIds: associatedIds ?? this.associatedIds,
|
||||
activeProviders: activeProviders ?? this.activeProviders, // Aggiungi qui
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage,
|
||||
errorMessage:
|
||||
errorMessage ??
|
||||
this.errorMessage, // Correzione bug: mancava "?? this.errorMessage" nel tuo originale
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +44,7 @@ class ProvidersState extends Equatable {
|
||||
List<Object?> get props => [
|
||||
allProviders,
|
||||
associatedIds,
|
||||
activeProviders, // Aggiungi qui
|
||||
isLoading,
|
||||
errorMessage,
|
||||
];
|
||||
@@ -45,9 +52,9 @@ class ProvidersState extends Equatable {
|
||||
|
||||
class ProvidersCubit extends Cubit<ProvidersState> {
|
||||
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
|
||||
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
|
||||
Future<void> toggleProviderAssociation({
|
||||
required String providerId,
|
||||
|
||||
@@ -9,6 +9,7 @@ class ProviderModel extends Equatable {
|
||||
final bool energia;
|
||||
final bool assicurazioni;
|
||||
final bool intrattenimento;
|
||||
final bool finanziamenti;
|
||||
final bool altro;
|
||||
final bool isActive;
|
||||
final String companyId;
|
||||
@@ -22,6 +23,7 @@ class ProviderModel extends Equatable {
|
||||
required this.energia,
|
||||
required this.assicurazioni,
|
||||
required this.intrattenimento,
|
||||
required this.finanziamenti,
|
||||
required this.altro,
|
||||
required this.isActive,
|
||||
required this.companyId,
|
||||
@@ -48,6 +50,7 @@ class ProviderModel extends Equatable {
|
||||
energia: map['energia'] ?? false,
|
||||
assicurazioni: map['assicurazioni'] ?? false,
|
||||
intrattenimento: map['intrattenimento'] ?? false,
|
||||
finanziamenti: map['finanziamenti'] ?? false,
|
||||
altro: map['altro'] ?? false,
|
||||
isActive: map['is_active'] ?? true,
|
||||
companyId: map['company_id'],
|
||||
@@ -63,6 +66,7 @@ class ProviderModel extends Equatable {
|
||||
'energia': energia,
|
||||
'assicurazioni': assicurazioni,
|
||||
'intrattenimento': intrattenimento,
|
||||
'finanziamenti': finanziamenti,
|
||||
'altro': altro,
|
||||
'is_active': isActive,
|
||||
'company_id': companyId,
|
||||
@@ -84,6 +88,7 @@ class ProviderModel extends Equatable {
|
||||
energia,
|
||||
assicurazioni,
|
||||
intrattenimento,
|
||||
finanziamenti,
|
||||
altro,
|
||||
isActive,
|
||||
companyId,
|
||||
@@ -98,6 +103,7 @@ class ProviderModel extends Equatable {
|
||||
bool? energia,
|
||||
bool? assicurazioni,
|
||||
bool? intrattenimento,
|
||||
bool? finanziamenti,
|
||||
bool? altro,
|
||||
bool? isActive,
|
||||
String? companyId,
|
||||
@@ -111,6 +117,7 @@ class ProviderModel extends Equatable {
|
||||
energia: energia ?? this.energia,
|
||||
assicurazioni: assicurazioni ?? this.assicurazioni,
|
||||
intrattenimento: intrattenimento ?? this.intrattenimento,
|
||||
finanziamenti: finanziamenti ?? this.finanziamenti,
|
||||
altro: altro ?? this.altro,
|
||||
isActive: isActive ?? this.isActive,
|
||||
companyId: companyId ?? this.companyId,
|
||||
|
||||
@@ -20,6 +20,7 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
||||
late bool _energia;
|
||||
late bool _assicurazioni;
|
||||
late bool _intrattenimento;
|
||||
late bool _finanziamenti;
|
||||
late bool _altro;
|
||||
late bool _isActive;
|
||||
final List<String> _tempSelectedStoreIds =
|
||||
@@ -38,6 +39,7 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
||||
_energia = p?.energia ?? false;
|
||||
_assicurazioni = p?.assicurazioni ?? false;
|
||||
_intrattenimento = p?.intrattenimento ?? false;
|
||||
_finanziamenti = p?.finanziamenti ?? false;
|
||||
_altro = p?.altro ?? false;
|
||||
_isActive = p?.isActive ?? true;
|
||||
}
|
||||
@@ -61,6 +63,7 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
||||
energia: _energia,
|
||||
assicurazioni: _assicurazioni,
|
||||
intrattenimento: _intrattenimento,
|
||||
finanziamenti: _finanziamenti,
|
||||
altro: _altro,
|
||||
isActive: _isActive,
|
||||
companyId:
|
||||
@@ -130,6 +133,11 @@ class _ProviderFormSheetState extends State<ProviderFormSheet> {
|
||||
_intrattenimento,
|
||||
(v) => setState(() => _intrattenimento = v),
|
||||
),
|
||||
_buildSwitch(
|
||||
"Finanziamenti",
|
||||
_finanziamenti,
|
||||
(v) => setState(() => _finanziamenti = v),
|
||||
),
|
||||
_buildSwitch(
|
||||
"Altro/Accessori",
|
||||
_altro,
|
||||
|
||||
@@ -10,9 +10,9 @@ part 'staff_state.dart';
|
||||
|
||||
class StaffCubit extends Cubit<StaffState> {
|
||||
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
|
||||
Future<void> loadAllStaff() async {
|
||||
|
||||
@@ -13,9 +13,9 @@ part 'store_state.dart';
|
||||
class StoreCubit extends Cubit<StoreState> {
|
||||
final StoreRepository _repository = GetIt.I<StoreRepository>();
|
||||
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 {
|
||||
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:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_bloc.dart';
|
||||
import 'package:flux/core/utils/string_extensions.dart';
|
||||
import 'package:flux/features/services/data/services_repository.dart';
|
||||
import 'package:flux/features/services/models/energy_service_model.dart';
|
||||
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
||||
import 'package:flux/features/services/models/fin_service_model.dart';
|
||||
import 'package:flux/features/services/models/service_file_model.dart';
|
||||
import 'package:flux/features/services/models/service_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class ServicesState extends Equatable {
|
||||
final List<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,
|
||||
];
|
||||
}
|
||||
import 'package:collection/collection.dart';
|
||||
part 'services_state.dart';
|
||||
|
||||
class ServicesCubit extends Cubit<ServicesState> {
|
||||
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 {
|
||||
// Se non è un refresh e abbiamo già dati, non disturbare Supabase
|
||||
if (!refresh && state.allServices.isNotEmpty) return;
|
||||
if (state.isLoading) return;
|
||||
// Se stiamo già caricando, evitiamo chiamate doppie
|
||||
if (state.status == ServicesStatus.loading) return;
|
||||
|
||||
// Se facciamo refresh, resettiamo tutto
|
||||
final currentOffset = refresh ? 0 : state.allServices.length;
|
||||
// Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo
|
||||
if (!refresh && state.hasReachedMax) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: true,
|
||||
status: ServicesStatus.loading,
|
||||
errorMessage: null,
|
||||
// Se è un refresh, svuotiamo la lista attuale per mostrare lo shimmer/loading
|
||||
allServices: refresh ? [] : state.allServices,
|
||||
hasReachedMax: refresh ? false : state.hasReachedMax,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final currentOffset = refresh ? 0 : state.allServices.length;
|
||||
final companyId = _sessionBloc.state.company?.id;
|
||||
|
||||
if (companyId == null) {
|
||||
throw Exception("Company ID non trovato nella sessione");
|
||||
}
|
||||
|
||||
final newServices = await _repository.fetchServices(
|
||||
companyId: _sessionBloc.state.company!.id,
|
||||
companyId: companyId,
|
||||
offset: currentOffset,
|
||||
limit: 50,
|
||||
searchTerm: state.query,
|
||||
dateRange: state.dateRange,
|
||||
);
|
||||
|
||||
// Se ricevi meno record del limite, significa che non ce ne sono altri sul DB
|
||||
final bool reachedMax = newServices.length < 50;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
allServices: List.from(state.allServices)..addAll(newServices),
|
||||
hasReachedMax:
|
||||
newServices.length <
|
||||
50, // Se ne arrivano meno di 50, siamo alla fine
|
||||
status: ServicesStatus.ready,
|
||||
allServices: refresh
|
||||
? newServices
|
||||
: [...state.allServices, ...newServices],
|
||||
hasReachedMax: reachedMax,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage: "Errore nel caricamento servizi: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GESTIONE FILTRI ---
|
||||
|
||||
/// Aggiorna i parametri di ricerca e ricarica da zero
|
||||
void updateFilters({String? query, DateTimeRange? range}) {
|
||||
emit(state.copyWith(query: query, dateRange: range));
|
||||
loadServices(refresh: true); // Applica i filtri e riparte da zero
|
||||
emit(
|
||||
state.copyWith(
|
||||
query: query ?? state.query,
|
||||
dateRange: range ?? state.dateRange,
|
||||
),
|
||||
);
|
||||
loadServices(refresh: true);
|
||||
}
|
||||
|
||||
// Salva e ricarica
|
||||
Future<void> addService(ServiceModel service) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
/// Pulisce tutti i filtri
|
||||
void clearFilters() {
|
||||
emit(state.copyWith(query: '', dateRange: null));
|
||||
loadServices(refresh: true);
|
||||
}
|
||||
|
||||
// --- GESTIONE BOZZA (DRAFT) ---
|
||||
|
||||
/// Inizializza un nuovo servizio o ne carica uno esistente per la modifica
|
||||
void initServiceForm({
|
||||
ServiceModel? existingService,
|
||||
String? serviceId,
|
||||
}) async {
|
||||
if (existingService != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: existingService,
|
||||
status: ServicesStatus.ready,
|
||||
),
|
||||
);
|
||||
} else if (serviceId != null) {
|
||||
ServiceModel? serviceModel = state.allServices.firstWhereOrNull(
|
||||
(s) => s.id == serviceId,
|
||||
);
|
||||
serviceModel ??= await _repository.fetchServiceById(serviceId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: serviceModel,
|
||||
status: ServicesStatus.ready,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Crea un template vuoto con lo store di default (se disponibile)
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentService: ServiceModel(
|
||||
storeId: _sessionBloc.state.selectedStore?.id ?? '',
|
||||
number: '', // Sarà compilato dall'utente
|
||||
createdAt: DateTime.now(),
|
||||
companyId: _sessionBloc.state.company!.id,
|
||||
),
|
||||
status: ServicesStatus.ready,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Metodo generico per aggiornare i campi base (AL, MNP, Note, ecc.)
|
||||
void updateField({
|
||||
int? al,
|
||||
int? mnp,
|
||||
int? nip,
|
||||
int? unica,
|
||||
int? telepass,
|
||||
String? note,
|
||||
String? number,
|
||||
bool? isBozza,
|
||||
bool? resultOk,
|
||||
String? customerId,
|
||||
String? customerDisplayName,
|
||||
}) {
|
||||
if (state.currentService == null) return;
|
||||
|
||||
final updated = state.currentService!.copyWith(
|
||||
al: al,
|
||||
mnp: mnp,
|
||||
nip: nip,
|
||||
unica: unica,
|
||||
telepass: telepass,
|
||||
note: note,
|
||||
number: number,
|
||||
isBozza: isBozza,
|
||||
resultOk: resultOk,
|
||||
customerId: customerId,
|
||||
customerDisplayName: customerDisplayName,
|
||||
);
|
||||
|
||||
emit(state.copyWith(currentService: updated));
|
||||
}
|
||||
|
||||
// --- GESTIONE MODULI COMPLESSI ---
|
||||
|
||||
void updateEnergyServices(List<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 {
|
||||
await _repository.saveFullService(service);
|
||||
await loadServices(); // Ricarichiamo la lista aggiornata
|
||||
// 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente
|
||||
final serviceToSave = state.currentService!.copyWith(isBozza: isBozza);
|
||||
|
||||
// 2. Salvataggio corazzato
|
||||
await _repository.saveFullService(serviceToSave);
|
||||
|
||||
// 3. Reset e ricaricamento
|
||||
emit(state.copyWith(status: ServicesStatus.saved, currentService: null));
|
||||
await loadServices(refresh: true);
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: ServicesStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GESTIONE ALLEGATI LOCALI ---
|
||||
|
||||
void addAttachments(List<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:flux/core/blocs/session/session_bloc.dart';
|
||||
import 'package:flux/features/customers/data/customer_repository.dart';
|
||||
import 'package:flux/features/customers/models/customer_file_model.dart';
|
||||
import 'package:flux/features/services/models/service_file_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/service_model.dart';
|
||||
|
||||
class ServicesRepository {
|
||||
final _supabase = Supabase.instance.client;
|
||||
final companyId = GetIt.I.get<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 ---
|
||||
Future<List<ServiceModel>> fetchServices({
|
||||
@@ -19,10 +48,11 @@ class ServicesRepository {
|
||||
.from('service')
|
||||
.select('''
|
||||
*,
|
||||
customer(name, surname),
|
||||
customer(nome),
|
||||
energy_service(*),
|
||||
fin_service(*),
|
||||
entertainment_service(*)
|
||||
entertainment_service(*),
|
||||
service_file(*)
|
||||
''')
|
||||
.eq('company_id', companyId);
|
||||
|
||||
@@ -36,7 +66,7 @@ class ServicesRepository {
|
||||
if (searchTerm != null && searchTerm.isNotEmpty) {
|
||||
// Filtra sui campi della tabella principale O su quelli della tabella joinata
|
||||
query = query.or(
|
||||
'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%,customer.surname.ilike.%$searchTerm%',
|
||||
'number.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.nome.ilike.%$searchTerm%',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,8 +85,7 @@ class ServicesRepository {
|
||||
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
||||
Future<void> saveFullService(ServiceModel service) async {
|
||||
try {
|
||||
// 1. Inseriamo il record principale
|
||||
// Se service.id è null, Supabase fa INSERT. Se c'è, fa UPDATE (grazie all'upsert o gestione manuale)
|
||||
// 1. Upsert del record principale
|
||||
final serviceData = await _supabase
|
||||
.from('service')
|
||||
.upsert(service.toMap())
|
||||
@@ -65,45 +94,103 @@ class ServicesRepository {
|
||||
|
||||
final String newId = serviceData['id'];
|
||||
|
||||
// 2. Pulizia vecchi record figli (necessaria se è una MODIFICA)
|
||||
// Se stiamo modificando, cancelliamo i vecchi per reinserire i nuovi (più semplice)
|
||||
// 2. MODIFICA: Pulizia atomica dei figli
|
||||
// Se stiamo modificando (id != null), resettiamo le tabelle collegate
|
||||
if (service.id != null) {
|
||||
await _supabase.from('energy_service').delete().eq('service_id', newId);
|
||||
await _supabase.from('fin_service').delete().eq('service_id', newId);
|
||||
await _supabase
|
||||
.from('entertainment_service')
|
||||
.delete()
|
||||
.eq('service_id', newId);
|
||||
await Future.wait([
|
||||
_supabase.from('energy_service').delete().eq('service_id', newId),
|
||||
_supabase.from('fin_service').delete().eq('service_id', newId),
|
||||
_supabase
|
||||
.from('entertainment_service')
|
||||
.delete()
|
||||
.eq('service_id', newId),
|
||||
// Aggiungi qui eventuali altre tabelle pivot o file
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Inserimento EnergyServices
|
||||
// 3. Inserimento dei moduli in parallelo per velocità
|
||||
final List<Future> insertTasks = [];
|
||||
|
||||
if (service.energyServices.isNotEmpty) {
|
||||
final List<Map<String, dynamic>> toInsert = [];
|
||||
for (var item in service.energyServices) {
|
||||
toInsert.add(item.copyWith(serviceId: newId).toMap());
|
||||
}
|
||||
await _supabase.from('energy_service').insert(toInsert);
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('energy_service')
|
||||
.insert(
|
||||
service.energyServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Inserimento FinServices
|
||||
if (service.finServices.isNotEmpty) {
|
||||
final List<Map<String, dynamic>> toInsert = [];
|
||||
for (var item in service.finServices) {
|
||||
toInsert.add(item.copyWith(serviceId: newId).toMap());
|
||||
}
|
||||
await _supabase.from('fin_service').insert(toInsert);
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('fin_service')
|
||||
.insert(
|
||||
service.finServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Inserimento EntertainmentServices
|
||||
if (service.entertainmentServices.isNotEmpty) {
|
||||
final List<Map<String, dynamic>> toInsert = [];
|
||||
for (var item in service.entertainmentServices) {
|
||||
toInsert.add(item.copyWith(serviceId: newId).toMap());
|
||||
insertTasks.add(
|
||||
_supabase
|
||||
.from('entertainment_service')
|
||||
.insert(
|
||||
service.entertainmentServices
|
||||
.map((item) => item.copyWith(serviceId: newId).toMap())
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (insertTasks.isNotEmpty) {
|
||||
await Future.wait(insertTasks);
|
||||
}
|
||||
if (service.files.isNotEmpty) {
|
||||
final List<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) {
|
||||
throw Exception('Errore durante il salvataggio: $e');
|
||||
// Qui potresti aggiungere una logica di "rollback manuale" se necessario
|
||||
throw Exception('Errore durante il salvataggio corazzato: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,4 +202,50 @@ class ServicesRepository {
|
||||
throw Exception('Errore durante l\'eliminazione: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
|
||||
Future<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:flux/core/utils/string_extensions.dart';
|
||||
import 'package:flux/features/services/models/energy_service_model.dart';
|
||||
import 'package:flux/features/services/models/entertainment_service_model.dart';
|
||||
import 'package:flux/features/services/models/fin_service_model.dart';
|
||||
import 'package:flux/features/services/models/service_file_model.dart'; // <-- Aggiunto Import
|
||||
|
||||
class ServiceModel extends Equatable {
|
||||
final String? id;
|
||||
@@ -14,6 +16,7 @@ class ServiceModel extends Equatable {
|
||||
final String note;
|
||||
final bool resultOk;
|
||||
final String? customerDisplayName;
|
||||
final String companyId;
|
||||
|
||||
// Telefonia
|
||||
final int al;
|
||||
@@ -27,6 +30,9 @@ class ServiceModel extends Equatable {
|
||||
final List<FinServiceModel> finServices;
|
||||
final List<EntertainmentServiceModel> entertainmentServices;
|
||||
|
||||
// ALLEGATI (Aggiunto)
|
||||
final List<ServiceFileModel> files;
|
||||
|
||||
const ServiceModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
@@ -45,7 +51,9 @@ class ServiceModel extends Equatable {
|
||||
this.energyServices = const [],
|
||||
this.finServices = const [],
|
||||
this.entertainmentServices = const [],
|
||||
this.files = const [], // <-- Aggiunto default vuoto
|
||||
this.customerDisplayName,
|
||||
required this.companyId,
|
||||
});
|
||||
|
||||
ServiceModel copyWith({
|
||||
@@ -66,7 +74,9 @@ class ServiceModel extends Equatable {
|
||||
List<EnergyServiceModel>? energyServices,
|
||||
List<FinServiceModel>? finServices,
|
||||
List<EntertainmentServiceModel>? entertainmentServices,
|
||||
List<ServiceFileModel>? files, // <-- Aggiunto
|
||||
String? customerDisplayName,
|
||||
String? companyId,
|
||||
}) {
|
||||
return ServiceModel(
|
||||
id: id ?? this.id,
|
||||
@@ -87,7 +97,9 @@ class ServiceModel extends Equatable {
|
||||
finServices: finServices ?? this.finServices,
|
||||
entertainmentServices:
|
||||
entertainmentServices ?? this.entertainmentServices,
|
||||
files: files ?? this.files, // <-- Aggiunto
|
||||
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
|
||||
companyId: companyId ?? this.companyId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,17 +122,21 @@ class ServiceModel extends Equatable {
|
||||
energyServices,
|
||||
finServices,
|
||||
entertainmentServices,
|
||||
files, // <-- Aggiunto
|
||||
customerDisplayName,
|
||||
companyId,
|
||||
];
|
||||
|
||||
factory ServiceModel.fromMap(Map<String, dynamic> map) {
|
||||
return ServiceModel(
|
||||
id: map['id'],
|
||||
createdAt: DateTime.parse(map['created_at']),
|
||||
storeId: map['store_id'],
|
||||
employeeId: map['employee_id'],
|
||||
customerId: map['customer_id'],
|
||||
number: map['number'] ?? '',
|
||||
id: map['id'].toString(),
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: DateTime.now(),
|
||||
storeId: map['store_id'] ?? '',
|
||||
employeeId: map['employee_id']?.toString(),
|
||||
customerId: map['customer_id']?.toString(),
|
||||
number: map['number']?.toString() ?? '',
|
||||
isBozza: map['bozza'] ?? true,
|
||||
note: map['note'] ?? '',
|
||||
resultOk: map['result_ok'] ?? true,
|
||||
@@ -130,7 +146,7 @@ class ServiceModel extends Equatable {
|
||||
unica: map['unica'] ?? 0,
|
||||
telepass: map['telepass'] ?? 0,
|
||||
|
||||
// Mappaggio delle liste collegate (se incluse nella query)
|
||||
// Estrazione sicura liste collegate
|
||||
energyServices:
|
||||
(map['energy_service'] as List?)
|
||||
?.map((x) => EnergyServiceModel.fromMap(x))
|
||||
@@ -146,9 +162,19 @@ class ServiceModel extends Equatable {
|
||||
?.map((x) => EntertainmentServiceModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
|
||||
// I FILE! (Assicurati che la foreign key su Supabase usi esattamente questo nome)
|
||||
files:
|
||||
(map['service_file'] as List?)
|
||||
?.map((x) => ServiceFileModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
|
||||
// Display name del cliente con fallback
|
||||
customerDisplayName: map['customer'] != null
|
||||
? "${map['customer']['name']} ${map['customer']['surname']}"
|
||||
: "Cliente sconosciuto",
|
||||
? "${map['customer']['nome'] ?? ''}".myFormat()
|
||||
: "Cliente non assegnato",
|
||||
companyId: map['company_id'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +193,7 @@ class ServiceModel extends Equatable {
|
||||
'nip': nip,
|
||||
'unica': unica,
|
||||
'telepass': telepass,
|
||||
'company_id': companyId,
|
||||
// Le liste non le mettiamo qui perché vanno in tabelle diverse!
|
||||
};
|
||||
}
|
||||
|
||||
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:flux/features/services/blocs/services_cubit.dart';
|
||||
import 'package:flux/features/services/models/service_model.dart';
|
||||
import 'package:flux/features/services/utils/service_actions.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
// Importa i tuoi modelli e cubit
|
||||
|
||||
@@ -20,6 +21,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
super.initState();
|
||||
// Agganciamo il listener per la paginazione (Scroll Infinito)
|
||||
_scrollController.addListener(_onScroll);
|
||||
// Carichiamo i servizi iniziali
|
||||
context.read<ServicesCubit>().loadServices();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
@@ -60,7 +63,8 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
body: BlocBuilder<ServicesCubit, ServicesState>(
|
||||
builder: (context, state) {
|
||||
// 1. Stato di caricamento iniziale
|
||||
if (state.isLoading && state.allServices.isEmpty) {
|
||||
if (state.status == ServicesStatus.loading &&
|
||||
state.allServices.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
@@ -111,7 +115,7 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.pushNamed('service-form'), // GoRouter
|
||||
onPressed: () => startNewService(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
@@ -171,7 +175,12 @@ class _ServicesScreenState extends State<ServicesScreen> {
|
||||
],
|
||||
),
|
||||
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/company/bloc/company_bloc.dart';
|
||||
import 'package:flux/features/company/data/company_repository.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_bloc.dart';
|
||||
import 'package:flux/features/customers/blocs/customer_cubit.dart';
|
||||
import 'package:flux/features/customers/data/customer_repository.dart';
|
||||
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
|
||||
import 'package:flux/features/master_data/products/data/product_repository.dart';
|
||||
@@ -37,8 +37,16 @@ void main() async {
|
||||
BlocProvider<ThemeBloc>(
|
||||
create: (context) => ThemeBloc()..add(LoadThemeEvent()),
|
||||
),
|
||||
BlocProvider<SessionBloc>(
|
||||
create: (context) => SessionBloc()..add(AppStarted()),
|
||||
BlocProvider<SessionBloc>(create: (_) => GetIt.I<SessionBloc>()),
|
||||
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(),
|
||||
@@ -65,6 +73,7 @@ Future<void> setupLocator() async {
|
||||
getIt.registerLazySingleton<StaffRepository>(() => StaffRepository());
|
||||
getIt.registerLazySingleton<ServicesRepository>(() => ServicesRepository());
|
||||
getIt.registerLazySingleton<ProviderRepository>(() => ProviderRepository());
|
||||
getIt.registerSingleton<SessionBloc>(SessionBloc()..add(AppStarted()));
|
||||
}
|
||||
|
||||
class FluxApp extends StatefulWidget {
|
||||
@@ -86,40 +95,51 @@ class _FluxAppState extends State<FluxApp> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<AuthBloc>(create: (_) => AuthBloc()),
|
||||
BlocProvider<CompanyBloc>(create: (_) => CompanyBloc()),
|
||||
BlocProvider<StoreCubit>(
|
||||
create: (_) => StoreCubit(context.read<SessionBloc>())..loadStores(),
|
||||
return BlocBuilder<SessionBloc, SessionState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == SessionStatus.unknown) {
|
||||
return _buildLoadingScreen();
|
||||
}
|
||||
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 file_picker
|
||||
import pdfx
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
PdfxPlugin.register(with: registry.registrar(forPlugin: "PdfxPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
74
pubspec.lock
74
pubspec.lock
@@ -90,7 +90,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
@@ -145,6 +145,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
extension:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: extension
|
||||
sha256: be3a6b7f8adad2f6e2e8c63c895d19811fcf203e23466c6296267941d0ff4f24
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -177,6 +185,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -312,6 +328,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
internet_file:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: internet_file
|
||||
sha256: c303ebf02caa853f072c49150557e76957622adacb18420008531c97a5ef5026
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -488,6 +512,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pdfx:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pdfx
|
||||
sha256: "29db9b71d46bf2335e001f91693f2c3fbbf0760e4c2eb596bf4bafab211471c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.2"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -496,6 +528,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
photo_view:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: photo_view
|
||||
sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -685,6 +725,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.2"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0+1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -709,6 +757,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
universal_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_file
|
||||
sha256: d1a957fccaad2a32023b62fe435b273ee47aaf2eb804709795e4bf4afff50960
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
universal_platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_platform
|
||||
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -773,6 +837,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -7,6 +7,7 @@ environment:
|
||||
sdk: ^3.11.3
|
||||
|
||||
dependencies:
|
||||
collection: ^1.19.1
|
||||
equatable: ^2.0.8
|
||||
file_picker: ^11.0.2
|
||||
flutter:
|
||||
@@ -17,7 +18,9 @@ dependencies:
|
||||
get_it: ^9.2.1
|
||||
go_router: ^17.2.0
|
||||
google_fonts: ^8.0.2
|
||||
internet_file: ^1.3.0
|
||||
intl: ^0.20.2
|
||||
pdfx: ^2.9.2
|
||||
shared_preferences: ^2.5.5
|
||||
supabase_flutter: ^2.12.2
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <app_links/app_links_plugin_c_api.h>
|
||||
#include <pdfx/pdfx_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AppLinksPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||
PdfxPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PdfxPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
app_links
|
||||
pdfx
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user