diff --git a/assets/images/default_avatar.png b/assets/images/avatar/default.png similarity index 100% rename from assets/images/default_avatar.png rename to assets/images/avatar/default.png diff --git a/assets/images/bk.jpg b/assets/images/bk.jpg new file mode 100644 index 0000000..eb27d19 Binary files /dev/null and b/assets/images/bk.jpg differ diff --git a/assets/images/notify/dd.png b/assets/images/notify/dd.png new file mode 100644 index 0000000..79d4bed Binary files /dev/null and b/assets/images/notify/dd.png differ diff --git a/assets/images/notify/guanzhu.png b/assets/images/notify/guanzhu.png new file mode 100644 index 0000000..a5c88f1 Binary files /dev/null and b/assets/images/notify/guanzhu.png differ diff --git a/assets/images/notify/hudong.png b/assets/images/notify/hudong.png new file mode 100644 index 0000000..d9be6f1 Binary files /dev/null and b/assets/images/notify/hudong.png differ diff --git a/assets/images/notify/msr.png b/assets/images/notify/msr.png new file mode 100644 index 0000000..ae4145c Binary files /dev/null and b/assets/images/notify/msr.png differ diff --git a/assets/images/notify/qun.png b/assets/images/notify/qun.png new file mode 100644 index 0000000..2c5100a Binary files /dev/null and b/assets/images/notify/qun.png differ diff --git a/assets/images/notify/xitong.png b/assets/images/notify/xitong.png new file mode 100644 index 0000000..53c761c Binary files /dev/null and b/assets/images/notify/xitong.png differ diff --git a/assets/images/svg/report.svg b/assets/images/svg/report.svg new file mode 100644 index 0000000..949b3f5 --- /dev/null +++ b/assets/images/svg/report.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile index e549ee2..2c53880 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,9 +1,11 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' - +platform :ios, '12.0' +# 允许拉取http资源 +# ENV['COCOAPODS_ALLOW_INSECURE_SOURCES'] = 'true' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' + project 'Runner', { 'Debug' => :debug, 'Profile' => :release, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9d1f391..9f0155d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,11 +1,25 @@ PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) + - flutter_image_compress_common (1.0.0): + - Flutter + - Mantle + - SDWebImage + - SDWebImageWebPCoder - flutter_native_splash (2.4.3): - Flutter - flutter_upgrader (1.0.7): - Flutter + - fluwx (0.0.1): + - Flutter + - fluwx/pay (= 0.0.1) + - fluwx/pay (0.0.1): + - Flutter + - WechatOpenSDK-XCFramework (~> 2.0.4) - geolocator_apple (1.2.0): - Flutter - FlutterMacOS @@ -14,6 +28,21 @@ PODS: - Flutter - install_plugin (2.0.0): - Flutter + - libwebp (1.3.2): + - libwebp/demux (= 1.3.2) + - libwebp/mux (= 1.3.2) + - libwebp/sharpyuv (= 1.3.2) + - libwebp/webp (= 1.3.2) + - libwebp/demux (1.3.2): + - libwebp/webp + - libwebp/mux (1.3.2): + - libwebp/demux + - libwebp/sharpyuv (1.3.2) + - libwebp/webp (1.3.2): + - libwebp/sharpyuv + - Mantle (2.2.0): + - Mantle/extobjc (= 2.2.0) + - Mantle/extobjc (2.2.0) - media_kit_libs_ios_video (1.0.4): - Flutter - media_kit_video (0.0.1): @@ -31,26 +60,47 @@ PODS: - photo_manager (3.7.1): - Flutter - FlutterMacOS + - record_ios (1.0.0): + - Flutter + - SDWebImage (5.20.0): + - SDWebImage/Core (= 5.20.0) + - SDWebImage/Core (5.20.0) + - SDWebImageWebPCoder (0.14.6): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.17) + - tencent_cloud_chat_push (8.6.7019): + - Flutter + - TIMPush (= 8.6.7019) + - TXIMSDK_Plus_iOS_XCFramework - tencent_cloud_chat_sdk (8.0.0): - Flutter - HydraAsync - TXIMSDK_Plus_iOS_XCFramework (~> 8.6.7019) + - TIMPush (8.6.7019): + - TXIMSDK_Plus_iOS_XCFramework (>= 8.6.7019) - TXIMSDK_Plus_iOS_XCFramework (8.6.7019) - url_launcher_ios (0.0.1): - Flutter - video_player_avfoundation (0.0.1): - Flutter - FlutterMacOS + - video_thumbnail (0.0.1): + - Flutter + - libwebp - volume_controller (0.0.1): - Flutter - wakelock_plus (0.0.1): - Flutter + - WechatOpenSDK-XCFramework (2.0.4) DEPENDENCIES: + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) + - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_upgrader (from `.symlinks/plugins/flutter_upgrader/ios`) + - fluwx (from `.symlinks/plugins/fluwx/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - install_plugin (from `.symlinks/plugins/install_plugin/ios`) @@ -61,26 +111,41 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) + - tencent_cloud_chat_push (from `.symlinks/plugins/tencent_cloud_chat_push/ios`) - tencent_cloud_chat_sdk (from `.symlinks/plugins/tencent_cloud_chat_sdk/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) SPEC REPOS: trunk: - HydraAsync + - libwebp + - Mantle + - SDWebImage + - SDWebImageWebPCoder + - TIMPush - TXIMSDK_Plus_iOS_XCFramework + - WechatOpenSDK-XCFramework EXTERNAL SOURCES: + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter + flutter_image_compress_common: + :path: ".symlinks/plugins/flutter_image_compress_common/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_upgrader: :path: ".symlinks/plugins/flutter_upgrader/ios" + fluwx: + :path: ".symlinks/plugins/fluwx/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/darwin" image_picker_ios: @@ -101,26 +166,37 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" photo_manager: :path: ".symlinks/plugins/photo_manager/ios" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" + tencent_cloud_chat_push: + :path: ".symlinks/plugins/tencent_cloud_chat_push/ios" tencent_cloud_chat_sdk: :path: ".symlinks/plugins/tencent_cloud_chat_sdk/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" video_player_avfoundation: :path: ".symlinks/plugins/video_player_avfoundation/darwin" + video_thumbnail: + :path: ".symlinks/plugins/video_thumbnail/ios" volume_controller: :path: ".symlinks/plugins/volume_controller/ios" wakelock_plus: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_upgrader: 16a975eb987fc210cdf6bebffe0069a480f80523 + fluwx: 6bf9c5a3a99ad31b0de137dd92370a0d10a60f4b geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e HydraAsync: 8d589bd725b0224f899afafc9a396327405f8063 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a install_plugin: e17e38d6f504857748a3ec1299d8a2bbeeeea854 + libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 + Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 @@ -128,13 +204,20 @@ SPEC CHECKSUMS: path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62 + record_ios: fee1c924aa4879b882ebca2b4bce6011bcfc3d8b + SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 + SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 + tencent_cloud_chat_push: f87ae58098c2062b06e81f39fc53afc528395916 tencent_cloud_chat_sdk: 0a406f1854a65aad2f853494c02a2e084a027ab2 + TIMPush: d0dfe96355ee413a7cacb2576f8aaa66f6073ab2 TXIMSDK_Plus_iOS_XCFramework: cb54f7de6e30e1368c6831c6eff31c25393bbb98 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + WechatOpenSDK-XCFramework: 36fb2bea0754266c17184adf4963d7e6ff98b69f -PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 +PODFILE CHECKSUM: 866435f3a12ad92d8fb66fa46b52776da7e16ce5 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index fa53fd0..c95476e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + C8092B212E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = C8092B202E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework */; }; + C8092B222E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C8092B202E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; ECDFBB33253E89949730F7D8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 898AE91CA73F2F6E910D884D /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -35,6 +37,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + C8092B222E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -63,6 +66,8 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A9774DDA95C7FD895F6925A4 /* 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 = ""; }; BB84C2FA9C50ACAF0C376254 /* 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 = ""; }; + C8092B202E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "WechatOpenSDK-XCFramework.xcframework"; path = "Pods/WechatOpenSDK-XCFramework/WechatOpenSDK-XCFramework.xcframework"; sourceTree = ""; }; + C891EF1D2E43F9730021EB39 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; DCA23AF172275D04ECB63EFB /* 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 = ""; }; E3EC116A6CCDD06C6D4615E2 /* 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 = ""; }; /* End PBXFileReference section */ @@ -72,6 +77,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C8092B212E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework in Frameworks */, ECDFBB33253E89949730F7D8 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -143,6 +149,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + C891EF1D2E43F9730021EB39 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -158,6 +165,7 @@ 9CE4C2341F34F9A85A1D3EED /* Frameworks */ = { isa = PBXGroup; children = ( + C8092B202E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework */, 898AE91CA73F2F6E910D884D /* Pods_Runner.framework */, 1AE799326ED7557212A901E0 /* Pods_RunnerTests.framework */, ); @@ -487,8 +495,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = VZ6V44Q3T4; + DEVELOPMENT_TEAM = 9C9VWBX77X; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -496,8 +507,9 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 4.1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.wzj41.test1; + PRODUCT_BUNDLE_IDENTIFIER = cn.net.wzj.mall; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -671,8 +683,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = VZ6V44Q3T4; + DEVELOPMENT_TEAM = 9C9VWBX77X; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -680,8 +695,9 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 4.1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.wzj41.test1; + PRODUCT_BUNDLE_IDENTIFIER = cn.net.wzj.mall; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -695,8 +711,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = VZ6V44Q3T4; + DEVELOPMENT_TEAM = 9C9VWBX77X; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -704,8 +723,9 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 4.1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.wzj41.test1; + PRODUCT_BUNDLE_IDENTIFIER = cn.net.wzj.mall; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 8be1cec..5cc1db3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,13 +1,39 @@ -import Flutter import UIKit +import Flutter -@main -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} +// Add these two import lines +import TIMPush +import tencent_cloud_chat_push + +// Add `, TIMPushDelegate` to the following line +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate, TIMPushDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // To be deprecated,please use the new field businessID below. + @objc func offlinePushCertificateID() -> Int32 { + return TencentCloudChatPushFlutterModal.shared.offlinePushCertificateID(); + } + + // Add this function + @objc func businessID() -> Int32 { + return TencentCloudChatPushFlutterModal.shared.businessID(); + } + + // Add this function + @objc func applicationGroupID() -> String { + return TencentCloudChatPushFlutterModal.shared.applicationGroupID() + } + + // Add this function + @objc func onRemoteNotificationReceived(_ notice: String?) -> Bool { + TencentCloudChatPushPlugin.shared.tryNotifyDartOnNotificationClickEvent(notice) + return true + } +} \ No newline at end of file diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c2851..0fb20ec 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 36250c5..2b101d6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - 无终见41 + 无终街 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -22,20 +22,44 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + weixin + CFBundleURLSchemes + + wxebcdaea31881caab + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) + LSApplicationQueriesSchemes + + weixin + wechat + weixinULAPI + LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSCameraUsageDescription App需要使用您的相机进行拍摄 NSMicrophoneUsageDescription - App需要访问麦克风用于视频录制 + App需要访问麦克风用于发送语音消息 NSPhotoLibraryAddUsageDescription - App需要权限以保存视频到您的相册 + App需要权限以保存图片或视频到您的相册 NSPhotoLibraryLimitedUsageDescription - App需要访问部分照片用于选择视频 + App需要访问部分照片用于选择图片或视频 NSPhotoLibraryUsageDescription - App需要访问您的相册用于选择视频 + App需要访问您的相册用于选择图片或视频 UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName @@ -59,5 +83,15 @@ UIViewControllerBasedStatusBarAppearance + + UIBackgroundModes + + remote-notification + + + com.apple.developer.associated-domains + + applinks:wuzhongjie.com.cn + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..cf469c6 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,9 @@ + + + + + aps-environment + development + + + diff --git a/lib/IM/controller/chat_controller.dart b/lib/IM/controller/chat_controller.dart index 665fb5c..cda9069 100644 --- a/lib/IM/controller/chat_controller.dart +++ b/lib/IM/controller/chat_controller.dart @@ -1,24 +1,118 @@ import 'package:get/get.dart'; import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/models/conversation_type.dart' as myConversationType; import 'package:loopin/models/conversation_view_model.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_filter.dart'; class ChatController extends GetxController { - RxInt count = 100.obs; // 每页条数 + RxInt count = 20.obs; // 每页条数 RxString nextSeq = '0'.obs; // 页码 + RxBool isFinished = false.obs; // 是否拉取完?默认未拉取完 final chatList = [].obs; - // 获取会话列表 + void initChatData() { + chatList.value = []; + nextSeq.value = '0'; + isFinished.value = false; + } + + // 获取所有会话列表 void getConversationList() async { + if (isFinished.value) { + // 拉取完数据了,直接结束 + return; + } final res = await ImService.instance.getConversationList(nextSeq.value, count.value); if (!res.success || res.data == null) return; final List convList = res.data; - // for (var conv in convList) { - // logger.i('基本会话: ${conv.conversation.toLogString()}, 头像: ${conv.faceUrl}'); - // } + for (var conv in convList) { + logger.i('基本会话: ${conv.conversation.toJson()}, 会话ID: ${conv.conversation.conversationID}'); + } - chatList.value = convList; + chatList.addAll(convList); + // 不包含noFriend才执行加载数据逻辑,分页加载时候过滤 + final hasNoFriend = chatList.any((item) => item.conversation.conversationGroupList?.contains(myConversationType.ConversationType.noFriend.name) ?? false); + if (!hasNoFriend) { + getNoFriendData(); + } } + + ///构建陌生人消息菜单入口 + void getNoFriendData({V2TimConversation? csion}) async { + // 检测会话列表是否已有陌生人消息菜单 + final hasNoFriend = chatList.any((item) => item.conversation.conversationGroupList?.contains(myConversationType.ConversationType.noFriend.name) ?? false); + if (hasNoFriend) { + // 已经有了入口 + final ConversationViewModel matchItem = chatList.firstWhere( + (item) => item.conversation.conversationGroupList?.contains(myConversationType.ConversationType.noFriend.name) ?? false, + ); + // 获取陌生人未读总数 + final unreadTotal = await ImService.instance.getUnreadMessageCountByFilter( + filter: V2TimConversationFilter( + conversationGroup: myConversationType.ConversationType.noFriend.name, + hasUnreadCount: true, + ), + ); + matchItem.conversation.lastMessage = csion!.lastMessage; + matchItem.conversation.unreadCount = unreadTotal.data; + chatList.refresh(); + return; + } + // 没有则执行创建逻辑 + final res = await ImService.instance.getConversationListByFilter( + filter: V2TimConversationFilter(conversationGroup: myConversationType.ConversationType.noFriend.name), + nextSeq: 0, + count: 1, + ); + if (res.success && res.data != null) { + final convList = res.data!.conversationList ?? []; + if (convList.isNotEmpty) { + // logger.i(res.data!.toJson()); + // 有陌生人消息,1.获取未读数,2.组装converstaionviewmodel + final unread = await ImService.instance.getUnreadMessageCountByFilter( + filter: V2TimConversationFilter( + conversationGroup: myConversationType.ConversationType.noFriend.name, + hasUnreadCount: true, + ), + ); + if (unread.success) { + final conv = convList.first; + final faceUrl = 'assets/images/notify/msr.png'; + conv.showName = '陌生人消息'; + conv.unreadCount = unread.data; + final createItem = ConversationViewModel( + conversation: conv, + faceUrl: faceUrl, + ); + final newList = List.from(chatList); + newList.add(createItem); + newList.sort((a, b) { + final atime = a.conversation.lastMessage?.timestamp ?? 0; + final btime = b.conversation.lastMessage?.timestamp ?? 0; + return btime.compareTo(atime); // 降序 + }); + chatList.value = newList; + } + } + } + } + + /// 按会话分组查询 getConversationListByFilter + // void getConversationList() async { + // final res = await ImService.instance.getConversationListByFilter( + // filter: V2TimConversationFilter(conversationGroup: null), + // nextSeq: nextSeq.value, + // ); + // final convList = res.data!.conversationList; + // logger.i(res.data!.toJson()); + // chatList.value = convList; + // // for (var element in convList ?? []) { + // // logger.i(element.toJson()); + // // // 你可以在这里继续处理 element + // // } + // } } diff --git a/lib/IM/controller/chat_detail_controller.dart b/lib/IM/controller/chat_detail_controller.dart index 1a29b49..566fc13 100644 --- a/lib/IM/controller/chat_detail_controller.dart +++ b/lib/IM/controller/chat_detail_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:loopin/IM/im_message.dart'; import 'package:loopin/IM/im_service.dart'; @@ -8,8 +9,10 @@ class ChatDetailController extends GetxController { final String userID; ChatDetailController({required this.userID}); + final ScrollController chatController = ScrollController(); final RxList chatList = [].obs; + final RxBool isFriend = true.obs; void updateChatListWithTimeLabels(List originMessages) async { final idRes = await ImService.instance.selfUserId(); @@ -37,17 +40,54 @@ class ChatDetailController extends GetxController { } } - // 把当前消息先插入后插入标签 - displayMessages.add(current); + // if (i == 0) { + // // 第一条一定插时间 + // needInsertLabel = true; + // } else { + // final prev = originMessages[i - 1]; + // final prevTimestamp = prev.timestamp ?? 0; + // final diff = currentTimestamp - prevTimestamp; + // if (diff > 180) { + // needInsertLabel = true; + // } + // } + // 把当前消息先插入,label后插入 + displayMessages.add(current); if (needInsertLabel) { final labelTime = Utils().formatChatTime(currentTimestamp); final timeLabel = await IMMessage().insertTimeLabel(labelTime, selfUserId); displayMessages.add(timeLabel.data); } } - + // 新加载的记录放在最上面 chatList.addAll(displayMessages); } } + + ///滚动 + void scrollToBottom() { + if (chatController.hasClients) { + chatController.animateTo( + 0, + duration: Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + // Future.delayed(Duration(milliseconds: 300), () { + // if (chatController.hasClients) { + // chatController.animateTo( + // chatController.position.maxScrollExtent, + // duration: Duration(milliseconds: 200), + // curve: Curves.easeOut, + // ); + // } + // }); + } + + @override + void onClose() { + chatController.dispose(); + super.onClose(); + } } diff --git a/lib/IM/controller/im_user_info_controller.dart b/lib/IM/controller/im_user_info_controller.dart new file mode 100644 index 0000000..5485fb3 --- /dev/null +++ b/lib/IM/controller/im_user_info_controller.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart'; + +class ImUserInfoController extends GetxController { + @override + void onInit() { + super.onInit(); + refreshUserInfo(); + logger.i('IM用户信息初始化'); + } + + V2TimUserFullInfo? rawUserInfo; + + final userID = ''.obs; + final nickname = ''.obs; + final faceUrl = ''.obs; + final signature = ''.obs; + final gender = 0.obs; + final allowType = 0.obs; + final customInfo = { + "coverBg": "", + "area": "", + "areaCode": "", + "openId": "", + }.obs; + final role = 0.obs; + final level = 0.obs; + final birthday = 0.obs; + + void init(V2TimUserFullInfo userInfo) { + logger.i(userInfo.toJson()); + rawUserInfo = userInfo; + userID.value = userInfo.userID ?? ''; + nickname.value = userInfo.nickName ?? ''; + faceUrl.value = userInfo.faceUrl ?? ''; + signature.value = userInfo.selfSignature ?? ''; + gender.value = userInfo.gender ?? 0; + allowType.value = userInfo.allowType ?? 0; + customInfo.assignAll(userInfo.customInfo ?? + { + "coverBg": "", + "area": "", + "areaCode": "", + "openId": "", + }); + + role.value = userInfo.role ?? 0; + level.value = userInfo.level ?? 0; + birthday.value = userInfo.birthday ?? 0; + } + + void refreshUserInfo() async { + try { + final updatedUserInfo = await ImService.instance.selfInfo(); + if (updatedUserInfo.success) { + init(updatedUserInfo.data); + } + } catch (e) { + print('刷新用户信息失败: $e'); + } + } + + /// 更新昵称 + Future updateNickname(newnickname) async { + final res = await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(nickName: newnickname)); + if (res.success) { + nickname.value = newnickname; + } else { + logger.i(res.desc); + if (res.code == 80001) { + MyDialog.toast('昵称违规', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + } + return res.success; + } + + /// 更新简介 + Future updateSignature(newsignature) async { + final res = await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(selfSignature: newsignature)); + if (res.success) { + signature.value = newsignature; + } else { + logger.i(res.desc); + if (res.code == 80001) { + MyDialog.toast('简介内容违规', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + } + return res.success; + } + + /// 更新头像 + Future updateFaceUrl() async { + if (faceUrl.value.trim().isEmpty) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(faceUrl: faceUrl.value)); + } + + /// 更新背景图 + Future updateCover() async { + final coverBg = customInfo['coverBg']; + if (coverBg == null || coverBg.trim().isEmpty) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(customInfo: customInfo)); + } + + /// 更新openId + Future updateOpenId() async { + final openId = customInfo['openId']; + if (openId == null || openId.trim().isEmpty) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(customInfo: customInfo)); + } + // customInfo.update("coverBg", (value) => coverBgUrl); + + /// 更新所在地 + Future updateArea() async { + final area = customInfo['area']; + if (area == null || area.trim().isEmpty) return; + final areaCode = customInfo['areaCode']; + if (areaCode == null || areaCode.trim().isEmpty) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(customInfo: customInfo)); + } + + ///更新生日 + Future updateBirthday() async { + if (birthday.value < 0) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(birthday: birthday.value)); + } + + ///更新性别 + Future updateGender() async { + if (gender.value < 0) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(gender: gender.value)); + } + + /// updateAvatar、updateSignature 等方法 +} diff --git a/lib/IM/global_badge.dart b/lib/IM/global_badge.dart index e677432..aef3fe0 100644 --- a/lib/IM/global_badge.dart +++ b/lib/IM/global_badge.dart @@ -1,9 +1,12 @@ import 'package:get/get.dart'; import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/controller/tab_bar_controller.dart'; -import 'package:loopin/IM/im_core.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/models/conversation_type.dart'; import 'package:loopin/models/tab_type.dart'; import 'package:tencent_cloud_chat_sdk/enum/V2TimConversationListener.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_filter.dart'; import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; class GlobalBadge extends GetxController { @@ -21,14 +24,103 @@ class GlobalBadge extends GetxController { logger.i('未读数发生变化$count'); totalUnread.value = count; Get.find().setBadge(TabType.chat, totalUnread.value); - // 更新会话列表 - Get.find().getConversationList(); + }, + onNewConversation: (List conversationList) { + for (var conv in conversationList) { + logger.i("新会话创建:${conv.conversationGroupList}"); + handleCoverstion(conv); + } + }, + onConversationChanged: (List conversationList) async { + logger.w('会话变更:会话分组:${conversationList.first.conversationGroupList},会话内容${conversationList.first.toLogString()}'); + final ctl = Get.find(); + final updatedIds = conversationList.map((e) => e.conversationID).toSet(); + logger.w('要变更的会话id:$updatedIds'); + for (int i = 0; i < ctl.chatList.length; i++) { + final chatItem = ctl.chatList[i]; + logger.w('需要更新的ID:${chatItem.conversation.conversationID}'); + if (updatedIds.contains(chatItem.conversation.conversationID)) { + final updatedConv = conversationList.firstWhere( + (c) => c.conversationID == chatItem.conversation.conversationID, + orElse: () => V2TimConversation(conversationID: ''), + ); + + if (updatedConv.conversationID != '' && (updatedConv.conversationGroupList?.contains(ConversationType.noFriend.name) ?? false)) { + // 单独处理陌生人会话 + final unread = await ImService.instance.getUnreadMessageCountByFilter( + filter: V2TimConversationFilter( + conversationGroup: ConversationType.noFriend.name, + hasUnreadCount: true, + ), + ); + chatItem.conversation.lastMessage = updatedConv.lastMessage; + chatItem.conversation.unreadCount = unread.data; // 获取陌生人未读总数 + } else { + // 其他类型统一更新处理 + chatItem.conversation = updatedConv; + } + } + } + //重新排序 + ctl.chatList.sort((a, b) { + final atime = a.conversation.lastMessage?.timestamp ?? 0; + final btime = b.conversation.lastMessage?.timestamp ?? 0; + return btime.compareTo(atime); // 降序 + }); + ctl.chatList.refresh(); }, ); + final ctl = Get.find(); + ctl.getConversationList(); _initUnreadCount(); _addListener(); } + // final rr = await ImService.instance.deleteConversationsFromGroup( + // conversationIDList: [cov.conversationID], + // groupName: 'noFriend', + // ); + // logger.w(rr.desc); + + /// 新建会话时候,根据消息的自定义属性给会话分组 + void handleCoverstion(V2TimConversation cov) async { + final message = cov.lastMessage; + final isSelfSend = message!.isSelf; // 是否本人发送的消息 + final typeEnum = conversationTypeFromString(message.cloudCustomData); // 会话类型 + final needAdd = cov.conversationGroupList!.isEmpty == true; // 当前会话是否已加入了分组中 + if (typeEnum != null && needAdd && isSelfSend == false) { + logger.i('当前会话的类型要加入的组是:$typeEnum'); + // 当前会话需要进行分组,检测 组 是否存在 + final hasGroupRes = await ImService.instance.getConversationGroupList(); + if (hasGroupRes.success) { + final exists = hasGroupRes.data?.any((item) => item == typeEnum) ?? false; + if (!exists) { + // 组不存在,创建组并把会话加入group中 + await ImService.instance.createConversationGroup( + groupName: typeEnum, + conversationIDList: ['c2c_${message.sender}'], + ); + logger.i('首次创建会话分组$typeEnum'); + } else { + // 分组存在直接添加 + await ImService.instance.addConversationsToGroup( + groupName: typeEnum, + conversationIDList: ['c2c_${message.sender}'], + ); + logger.i('添加会话分组$typeEnum成功'); + } + if (typeEnum == ConversationType.noFriend.name) { + //陌生人分组特殊处理 满足分组条件且已经有分组, + final ctl = Get.find(); + // 这个方法执行的逻辑:已有则刷新菜单入口数据,没有则创建菜单入口 + ctl.getNoFriendData(csion: cov); + } + } + } else { + logger.w('不需要进行分组'); + } + } + /// 初始化时获取一次未读总数 void _initUnreadCount() async { final res = await TencentImSDKPlugin.v2TIMManager.getConversationManager().getTotalUnreadMessageCount(); diff --git a/lib/IM/im_core.dart b/lib/IM/im_core.dart index b0d121d..3f7ba92 100644 --- a/lib/IM/im_core.dart +++ b/lib/IM/im_core.dart @@ -14,14 +14,16 @@ class ImCore { final res = await TencentImSDKPlugin.v2TIMManager.initSDK( sdkAppID: sdkAppId, - loglevel: LogLevelEnum.V2TIM_LOG_ALL, + loglevel: LogLevelEnum.V2TIM_LOG_ERROR, listener: V2TimSDKListener( - onConnectSuccess: () => logger.i("IM连接成功"), + onConnectSuccess: () { + logger.i("IM连接成功"); + }, onConnectFailed: (code, error) => logger.e("IM连接失败: $code $error"), onKickedOffline: () => logger.w("IM被踢下线"), onUserSigExpired: () => logger.w("UserSig 过期"), onSelfInfoUpdated: (V2TimUserFullInfo info) { - logger.i("用户信息更新: ${info.nickName}"); + logger.i("用户信息更新: ${info.toJson()}"); }, ), ); @@ -29,6 +31,7 @@ class ImCore { if (res.code == 0) { _isInitialized = true; logger.i("IM SDK 初始化成功"); + return true; } else { logger.e("IM SDK 初始化失败: ${res.code} - ${res.desc}"); diff --git a/lib/IM/im_friend_listeners.dart b/lib/IM/im_friend_listeners.dart index 16ac3dd..58234f3 100644 --- a/lib/IM/im_friend_listeners.dart +++ b/lib/IM/im_friend_listeners.dart @@ -1,4 +1,5 @@ import 'package:logger/logger.dart'; +import 'package:loopin/utils/notification_banner.dart'; import 'package:tencent_cloud_chat_sdk/enum/V2TimFriendshipListener.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_application.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart'; @@ -16,7 +17,6 @@ class ImFriendListeners { _listener = V2TimFriendshipListener(onFriendApplicationListAdded: (List list) async { //好友请求数量增加的回调 //applicationList 新增的好友请求信息列表 - logger.i('收到好友申请: ${list.map((e) => e.userID).join(",")}'); }, onFriendApplicationListRead: () async { //好友请求已读的回调 }, onFriendApplicationListDeleted: (List userIDList) async { @@ -25,11 +25,15 @@ class ImFriendListeners { }, onFriendListAdded: (List users) async { //好友列表增加人员的回调 //users 新增的好友信息列表 - logger.i('新增好友: ${users.map((u) => u.userID).join(",")}'); + for (var item in users) { + logger.i('新增好友:${item.toLogString()}'); + } }, onFriendListDeleted: (List userList) async { //好友列表减少人员的回调 //userList 减少的好友id列表 - logger.i('删除好友: ${userList.join(",")}'); + for (var item in userList) { + logger.i('新增好友:$item'); + } }, onFriendInfoChanged: (List list) async { //好友信息改变的回调 //infoList 好友信息改变的好友列表 @@ -43,14 +47,27 @@ class ImFriendListeners { }, onMyFollowingListChanged: (List userInfoList, bool isAdd) async { if (isAdd) { // 关注列表新增用户的通知 + for (var item in userInfoList) { + logger.i('我新关注的人:${item.toJson()}'); + } } else { // 关注列表删除用户的通知 + for (var item in userInfoList) { + logger.i('我取消关注了:${item.toJson()}'); + } } }, onMyFollowersListChanged: (List userInfoList, bool isAdd) async { if (isAdd) { // 粉丝列表新增用户的通知 + for (var item in userInfoList) { + logger.i('新增粉丝:${item.toJson()}'); + } + NotificationBanner.foucs(userInfoList.last); } else { // 粉丝列表删除用户的通知 + for (var item in userInfoList) { + logger.i('掉粉:${item.toJson()}'); + } } }, onMutualFollowersListChanged: (List userInfoList, bool isAdd) async { if (isAdd) { diff --git a/lib/IM/im_message.dart b/lib/IM/im_message.dart index 96a1337..45c8073 100644 --- a/lib/IM/im_message.dart +++ b/lib/IM/im_message.dart @@ -1,20 +1,27 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:get/get.dart'; import 'package:logger/logger.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; import 'package:loopin/IM/im_result.dart'; +import 'package:loopin/utils/parse_message_summary.dart'; import 'package:tencent_cloud_chat_sdk/enum/message_priority_enum.dart'; import 'package:tencent_cloud_chat_sdk/enum/offlinePushInfo.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_msg_create_info_result.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart'; import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; -final logger = Logger(); - class IMMessage { - /// 发送文本消息 - Future sendText({ - required String text, + final logger = Logger(); + + /// 1.发送消息 + Future sendMessage({ + required V2TimMessage msg, String? toUserID, String? groupID, - String? data, + String? cloudCustomData, }) async { // 必须且只能设置一个:toUserID(单聊)或 groupID(群聊) if ((toUserID == null && groupID == null) || (toUserID != null && groupID != null)) { @@ -24,40 +31,44 @@ class IMMessage { desc: "只能指定一个 receiver(toUserID)或 groupID", ); } - - // 创建消息 - final createRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createTextMessage(text: text); - - if (createRes.code != 0 || createRes.data == null) { - return ImResult( - success: false, - code: createRes.code, - desc: "创建消息失败", - ); + if (cloudCustomData != null) { + msg.cloudCustomData = cloudCustomData; } - final V2TimMessage? messageInfo = createRes.data?.messageInfo; - + // 解析消息类型 V2TimValueCallback sendRes; - + // final controller = Get.find(); + final myInfo = Get.find(); + logger.w('启用默认title:${myInfo.nickname.value}'); // 单聊 if (toUserID != null) { sendRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( - message: messageInfo, + message: msg, receiver: toUserID, + // onSyncMsgID: (msgID) async { + // 这里立刻拿到消息ID,可以提前把这条消息展示到列表中(发送中状态)有时间再改吧 + // 根据类型,创建对应的elem; + // logger.w(msg.imageElem!.toLogString()); + // controller.chatList.add(msg.imageElem); + // controller.scrollToBottom(); + // }, groupID: "", priority: MessagePriorityEnum.V2TIM_PRIORITY_DEFAULT, onlineUserOnly: false, isExcludedFromUnreadCount: false, isExcludedFromLastMessage: false, needReadReceipt: false, - offlinePushInfo: OfflinePushInfo(title: "新消息", desc: text), - cloudCustomData: "", + offlinePushInfo: OfflinePushInfo( + title: myInfo.nickname.value, + desc: parseMessageSummary(msg), + ext: jsonEncode({"userID": myInfo.userID.value, "title": myInfo.nickname.value}), + ), + cloudCustomData: cloudCustomData, localCustomData: "", ); } else { // 群聊 sendRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( - message: messageInfo, + message: msg, receiver: "", groupID: groupID!, priority: MessagePriorityEnum.V2TIM_PRIORITY_DEFAULT, @@ -65,7 +76,7 @@ class IMMessage { isExcludedFromUnreadCount: false, isExcludedFromLastMessage: false, needReadReceipt: false, - offlinePushInfo: OfflinePushInfo(title: "新群聊消息", desc: text), + offlinePushInfo: OfflinePushInfo(title: '群聊消息', desc: parseMessageSummary(msg)), cloudCustomData: "", localCustomData: "", ); @@ -79,62 +90,18 @@ class IMMessage { ); } - /// 发送自定义消息 - Future sendCustomMessage({ + /// 2=创建自定义消息 + Future> createCustomMessage({ required String data, - String? toUserID, - String? groupID, - String? description, - String? extension, + String desc = "", + String extension = "", }) async { - // 校验逻辑:单聊或群聊,二选一 - if ((toUserID == null && groupID == null) || (toUserID != null && groupID != null)) { - return ImResult( - success: false, - code: -1, - desc: "只能指定一个 receiver(toUserID)或 groupID", - ); - } - - // 1. 创建自定义消息 - final createRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createCustomMessage( + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createCustomMessage( data: data, - desc: description ?? '', - extension: extension ?? '', + desc: desc, + extension: extension, ); - - if (createRes.code != 0 || createRes.data?.id == null) { - return ImResult( - success: false, - code: createRes.code, - desc: "创建自定义消息失败", - ); - } - final V2TimMessage? messageInfo = createRes.data?.messageInfo; - - // 2. 发送消息 - final sendRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( - message: messageInfo, - receiver: toUserID ?? '', - groupID: groupID ?? '', - priority: MessagePriorityEnum.V2TIM_PRIORITY_DEFAULT, - onlineUserOnly: false, - isExcludedFromUnreadCount: false, - isExcludedFromLastMessage: false, - needReadReceipt: false, - offlinePushInfo: OfflinePushInfo( - title: "自定义消息", - desc: description ?? '您收到一条自定义消息', - ), - cloudCustomData: "", - localCustomData: "", - ); - - return ImResult( - success: sendRes.code == 0, - code: sendRes.code, - desc: sendRes.desc, - ); + return ImResult.wrap(res); } /// 构造单聊伪消息 @@ -162,24 +129,124 @@ class IMMessage { desc: "success", data: timeMsg, ); + } - // final sendRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( - // message: timeMsg, - // receiver: userId, - // groupID: "", - // onlineUserOnly: false, - // isExcludedFromUnreadCount: true, - // isExcludedFromLastMessage: true, - // needReadReceipt: false, - // cloudCustomData: "", - // localCustomData: "time_label", - // ); + /// 创建文本消息==1 + Future> createTextMessage({ + required String text, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createTextMessage(text: text); + return ImResult.wrap(res); + } - // return ImResult( - // success: sendRes.code == 0, - // code: sendRes.code, - // desc: sendRes.code == 0 ? "时间标签发送成功" : sendRes.desc, - // data: timeMsg, - // ); + /// 创建图片消息==3 + Future> createImageMessage({ + required String imagePath, + String? imageName, + }) async { + final fileExists = await File(imagePath).exists(); + if (fileExists) { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createImageMessage( + imagePath: imagePath, + imageName: imageName, + ); + return ImResult.wrap(res); + } else { + // 构造失败的回调 + final failed = V2TimValueCallback.fromJson({ + "code": -5, + "desc": "imagePath is not found", + "data": V2TimMsgCreateInfoResult.fromJson({}), + }); + + return ImResult.wrap(failed); + } + } + + ///创建视频消息==5 + Future> createVideoMessage({ + //最大100MB + required String videoFilePath, //视频地址 + required String type, // 类型mp4/avi== + required int duration, // 时长 + required String snapshotPath, // 封面图 + }) async { + final videoExists = await File(videoFilePath).exists(); + final snapshotExists = await File(snapshotPath).exists(); + + if (videoExists && snapshotExists) { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createVideoMessage( + videoFilePath: videoFilePath, + type: type, + duration: duration, + snapshotPath: snapshotPath, + ); + + return ImResult.wrap(res); + } + + // 构造失败回调 + final failed = V2TimValueCallback.fromJson({ + "code": -5, + "desc": "视频或首帧图缺失", + "data": V2TimMsgCreateInfoResult.fromJson({}), + }); + + return ImResult.wrap(failed); + } + + /// 语音消息==4 + Future> createSoundMessage({ + required String soundPath, + required int duration, + String? path, + }) async { + final soundExists = await File(soundPath).exists(); + + if (soundExists) { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createSoundMessage( + soundPath: soundPath, + duration: duration, + ); + + return ImResult.wrap(res); + } + + final failed = V2TimValueCallback.fromJson({ + "code": -5, + "desc": "音频文件缺失", + "data": V2TimMsgCreateInfoResult.fromJson({}), + }); + + return ImResult.wrap(failed); + } + + /// 表情 == 8 + Future> createFaceMessage({ + required int index, + required String data, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createFaceMessage( + index: index, + data: data, + ); + + return ImResult.wrap(res); + } + + ///相当于一个被禁用了网络发送能力的 sendMessage() 接口 + Future> insertC2CMessageToLocalStorageV2({ + required String userID, + required String senderID, + V2TimMessage? message, + String? createdMsgID, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().insertC2CMessageToLocalStorageV2( + userID: userID, + senderID: senderID, + message: message, + createdMsgID: createdMsgID, + ); + return ImResult.wrap(res); } } diff --git a/lib/IM/im_message_listeners.dart b/lib/IM/im_message_listeners.dart index a3d4241..2d85f31 100644 --- a/lib/IM/im_message_listeners.dart +++ b/lib/IM/im_message_listeners.dart @@ -8,6 +8,7 @@ import 'package:loopin/IM/im_service.dart'; import 'package:loopin/utils/index.dart'; import 'package:loopin/utils/lifecycle_handler.dart'; import 'package:loopin/utils/notification_banner.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; import 'package:tencent_cloud_chat_sdk/enum/V2TimAdvancedMsgListener.dart'; import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; @@ -15,14 +16,18 @@ import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_receipt.dart'; import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; class ImMessageListenerService extends GetxService { + ProgressController? _sendProgressController; // 消息发送提示 + final logger = Logger(); V2TimAdvancedMsgListener? _listener; Timer? _debounceTimer; + /// 插入标签时间间隔 bool needInsertTimeLabel(int lastTimestamp, int newTimestamp, {int interval = 3 * 60}) { return (newTimestamp - lastTimestamp) > interval * 1000; } + ///插入标签 void insertTimeLabel(message) async { // 待插入的消息 List messagesToInsert = []; @@ -54,21 +59,26 @@ class ImMessageListenerService extends GetxService { messagesToInsert.add(resMsg.data); } messagesToInsert.insert(0, message); + // messagesToInsert.add(message); + // 新进入的消息插入列表 chatDetailController.chatList.insertAll(0, messagesToInsert); + // chatDetailController.chatList.addAll(messagesToInsert); + // 滚动 + chatDetailController.scrollToBottom(); } + /// 处理消息 void _handleNewMessage(V2TimMessage message) async { final userID = message.sender ?? ''; if (userID.isEmpty) return; - // 是否正在聊天 优先处理 - if (Get.currentRoute == '/chat' && Get.isRegistered()) { + /// 是否正在聊天 优先处理 + if ((Get.currentRoute == '/chat' || Get.currentRoute == '/chatNoFriend' || Get.currentRoute == '/chatGroup') && Get.isRegistered()) { final chatDetailController = Get.find(); // 单聊的处理 if (chatDetailController.userID == userID) { - // 确认正在聊天 - // 插入消息前检测是否需要打时间标签 + // 确认正在聊天,插入消息前检测是否需要打时间标签 insertTimeLabel(message); // 标注为已读 await ImService.instance.clearConversationUnreadCount(conversationID: 'c2c_$userID'); @@ -142,9 +152,29 @@ class ImMessageListenerService extends GetxService { }, onRecvMessageModified: (V2TimMessage message) { logger.i("消息被修改: ${message.msgID}"); + // 目前就红包领取状态的变更 + if ((Get.currentRoute == '/chat' || Get.currentRoute == '/chatNoFriend' || Get.currentRoute == '/chatGroup') && + Get.isRegistered()) { + final controller = Get.find(); + final index = controller.chatList.indexWhere((m) => m.msgID == message.msgID); + if (index != -1) { + final newJson = message.customElem!.data!; + controller.chatList[index].customElem!.data = newJson; + controller.chatList.refresh(); + } + } }, onSendMessageProgress: (V2TimMessage message, int progress) { logger.i("发送中: ${message.msgID} -> $progress%"); + if (progress < 100) { + _sendProgressController ??= MyDialog.loading( + "发送中...", + duration: null, // 不自动关闭 + ); + } else { + _sendProgressController?.close(); + _sendProgressController = null; + } }, onRecvC2CReadReceipt: (List receiptList) { for (var receipt in receiptList) { @@ -159,7 +189,6 @@ class ImMessageListenerService extends GetxService { ); TencentImSDKPlugin.v2TIMManager.getMessageManager().addAdvancedMsgListener(listener: _listener!); - logger.i("$_listener"); logger.i("高级消息监听器已注册"); return this; diff --git a/lib/IM/im_result.dart b/lib/IM/im_result.dart index 73623a3..f4121d5 100644 --- a/lib/IM/im_result.dart +++ b/lib/IM/im_result.dart @@ -1,3 +1,6 @@ +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart'; + class ImResult { final bool success; final int code; @@ -10,4 +13,21 @@ class ImResult { required this.desc, this.data, }); + static ImResult wrap(V2TimValueCallback res) { + return ImResult( + success: res.code == 0, + code: res.code, + desc: res.desc, + data: res.data, + ); + } + + static ImResult wrapNoData(V2TimCallback res) { + return ImResult( + success: res.code == 0, + code: res.code, + desc: res.desc, + data: null, + ); + } } diff --git a/lib/IM/im_service.dart b/lib/IM/im_service.dart index 317831d..b401f5d 100644 --- a/lib/IM/im_service.dart +++ b/lib/IM/im_service.dart @@ -1,19 +1,40 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:logger/logger.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; import 'package:loopin/IM/controller/tab_bar_controller.dart'; import 'package:loopin/IM/global_badge.dart'; import 'package:loopin/IM/im_core.dart'; import 'package:loopin/IM/im_friend_listeners.dart'; import 'package:loopin/IM/im_message_listeners.dart'; import 'package:loopin/IM/im_result.dart'; +import 'package:loopin/IM/push_service.dart'; import 'package:loopin/models/conversation_view_model.dart'; +import 'package:loopin/utils/wxsdk.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_application_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_response_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_type_enum.dart'; import 'package:tencent_cloud_chat_sdk/enum/history_msg_get_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_filter.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_type_check_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_operation_result.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_change_info.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_info_result.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart'; import 'package:tencent_cloud_chat_sdk/native_im/adapter/tim_conversation_manager.dart'; +import 'package:tencent_cloud_chat_sdk/native_im/adapter/tim_friendship_manager.dart'; +import 'package:tencent_cloud_chat_sdk/native_im/adapter/tim_message_manager.dart'; import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; final logger = Logger(); @@ -40,6 +61,16 @@ class ImService { if (result.success) { logger.i("IM 登录成功:$userID"); + // 初始化push服务 + PushService().initPush( + sdkAppId: 1600080789, + appKey: 'vkFpe55aYqfV7Sk5uGaoxhEstJ3tcI9dquk7JwG1GloDSLD2HeMWeQweWWXgNlhC', + ); + // 初始化微信 SDK + await Wxsdk.init(); + + // 注册用户信息(基本信息+自定义信息) + Get.put(ImUserInfoController(), permanent: true); // 登录成功后注册高级消息监听器 final messageService = ImMessageListenerService(); Get.put(messageService, permanent: true); @@ -48,13 +79,11 @@ class ImService { // 注册关系链监听器 final friendListener = ImFriendListeners(); - logger.i(friendListener); Get.put(friendListener, permanent: true); friendListener.register(); /// 注册消息未读数监听器 Get.put(GlobalBadge(), permanent: true); - // Get.lazyPut(() => GlobalBadge()); } else { logger.i("IM 登录失败:${result.code} - ${result.desc}"); Get.snackbar( @@ -72,6 +101,9 @@ class ImService { Future logout() async { final res = await TencentImSDKPlugin.v2TIMManager.logout(); if (res.code == 0) { + /// 清理用户信息 + Get.delete(force: true); + /// 移出消息监听器 Get.find().onClose(); Get.delete(force: true); @@ -83,25 +115,114 @@ class ImService { /// 清理tabbar Get.find().badgeMap.clear(); + /// 清理会话列表数据 + Get.find().initChatData(); + /// 移出未读消息监听器 Get.find().onClose(); Get.delete(force: true); + /// 移除推送服务 + PushService.unInitPush(); + /// 反初始化 ImCore.unInit(); } - return ImResult( - success: res.code == 0, - code: res.code, - desc: res.desc, + return ImResult.wrapNoData(res); + } + + /// 设置会话自定义属性 + Future>> setConversationCustomData({ + required String customData, + required List conversationIDList, + }) async { + final res = await TIMConversationManager.instance.setConversationCustomData(customData: customData, conversationIDList: conversationIDList); + + return ImResult.wrap(res); + } + + /// 获取符合过滤条件的未读消息总数 + Future> getUnreadMessageCountByFilter({ + required V2TimConversationFilter filter, + }) async { + final res = await TIMConversationManager.instance.getUnreadMessageCountByFilter( + filter: filter, ); + return ImResult.wrap(res); + } + + /// 删除会话 + Future> deleteConversation({ + required String conversationID, + }) async { + final res = await TIMConversationManager.instance.deleteConversation(conversationID: conversationID); + + return ImResult.wrapNoData(res); + } + + /// 创建会话分组 + Future>> createConversationGroup({ + required String groupName, + required List conversationIDList, + }) async { + final res = await TIMConversationManager.instance.createConversationGroup( + groupName: groupName, + conversationIDList: conversationIDList, + ); + + return ImResult.wrap(res); + } + + /// 获取会话分组列表 + Future>> getConversationGroupList() async { + final res = await TIMConversationManager.instance.getConversationGroupList(); + return ImResult.wrap(res); + } + + /// 将会话添加到分组 + Future>> addConversationsToGroup({ + required String groupName, + required List conversationIDList, + }) async { + final res = await TIMConversationManager.instance.addConversationsToGroup( + groupName: groupName, + conversationIDList: conversationIDList, + ); + return ImResult.wrap(res); + } + + ///将会话移除分组 + Future>> deleteConversationsFromGroup({ + required String groupName, + required List conversationIDList, + }) async { + final res = await TIMConversationManager.instance.deleteConversationsFromGroup( + groupName: groupName, + conversationIDList: conversationIDList, + ); + return ImResult.wrap(res); + } + + /// 高级查询会话列表 + Future> getConversationListByFilter({ + required V2TimConversationFilter filter, + required int nextSeq, + int count = 20, + }) async { + final res = await TIMConversationManager.instance.getConversationListByFilter( + filter: filter, + nextSeq: nextSeq, + count: count, + ); + + return ImResult.wrap(res); } /// 查询会话记录 Future getConversationList(String nextSeq, int count) async { - final res = await TencentImSDKPlugin.v2TIMManager.getConversationManager().getConversationList(nextSeq: nextSeq, count: count); - - if (res.code != 0) { + // final res = await TencentImSDKPlugin.v2TIMManager.getConversationManager().getConversationList(nextSeq: nextSeq, count: count); + final res = await getConvData(nextSeq, count); + if (res.success == false) { return ImResult( success: false, code: res.code, @@ -138,7 +259,7 @@ class ImService { // 读取管理员标识 final customInfo = user.customInfo; if (customInfo != null) { - isCustomAdmin = customInfo['Tag_Profile_Custom_admin'] ?? '0'; + isCustomAdmin = customInfo['admin'] ?? '0'; } } } @@ -171,6 +292,40 @@ class ImService { return ConversationViewModel(conversation: conv, faceUrl: faceUrl, isCustomAdmin: isCustomAdmin); }).toList(); + // 筛选数据,过滤掉陌生人消息 + viewList.removeWhere((conv) { + final special = conv.conversation.conversationGroupList ?? []; + return special.contains('noFriend'); + }); + + ChatController chatcontroller = Get.find(); + logger.e('新的分页内容:${res.data!.toLogString()},控制器中的:${chatcontroller.nextSeq.value}'); + String newNextSeq = res.data?.nextSeq ?? '0'; + bool isEnd = res.data?.isFinished ?? true; + + if (isEnd) { + //没数据了,关闭拉取; + chatcontroller.isFinished.value = isEnd; + } else { + // 没拉完,记录游标 + chatcontroller.nextSeq.value = newNextSeq; + } + + // 更新分页 + chatcontroller.nextSeq.value = res.data!.nextSeq!; + if (res.data!.isFinished == false) { + if (viewList.length < 20) { + // 递归补偿拉取 + final nextRes = await getConversationList( + res.data!.nextSeq!, + count, + ); + if (nextRes.success && nextRes.data != null) { + viewList.addAll(nextRes.data as List); + } + } + } + return ImResult( success: res.code == 0, code: res.code, @@ -179,30 +334,13 @@ class ImService { ); } - /// 获取自己的userId - Future selfUserId() async { - V2TimValueCallback self = await TencentImSDKPlugin.v2TIMManager.getLoginUser(); - String? userId = self.data; - return ImResult( - success: self.code == 0, - code: self.code, - desc: self.desc, - data: userId, - ); - } - - /// 查询当前登录用户的个人信息 - Future selfInfo() async { - // 获取当前登录的用户 ID - final idRes = await selfUserId(); - // 获取用户信息 - V2TimValueCallback> res = await TencentImSDKPlugin.v2TIMManager.getUsersInfo(userIDList: [idRes.data]); - return ImResult( - success: res.code == 0, - code: res.code, - desc: res.desc, - data: res.data?.isNotEmpty == true ? res.data![0] : null, - ); + ///获取所有会话数据 + Future> getConvData(String nextSeq, int count) async { + final res = await TencentImSDKPlugin.v2TIMManager.getConversationManager().getConversationList(nextSeq: nextSeq, count: count); + // for (var element in res.data!.conversationList) { + // logger.e('所有的会话数据:${element.toJson()}'); + // } + return ImResult.wrap(res); } ///获取指定会话 @@ -219,13 +357,29 @@ class ImService { ); } + /// 获取消息 + Future>> findMessages({ + required List messageIDList, + }) async { + final res = await TIMMessageManager.instance.findMessages(messageIDList: messageIDList); + return ImResult.wrap(res); + } + + /// 修改消息 + Future> modifyMessage({ + required V2TimMessage message, + }) async { + final res = await TIMMessageManager.instance.modifyMessage(message: message); + return ImResult.wrap(res); + } + /// 获取聊天记录 如果是群聊传 groupID,单聊传 userID,二选一 Future>> getHistoryMessageList({ HistoryMsgGetTypeEnum getType = HistoryMsgGetTypeEnum.V2TIM_GET_LOCAL_OLDER_MSG, String? userID, String? groupID, int? lastMsgSeq, - int count = 20, + int count = 10, V2TimMessage? lastMsg, List? messageTypeList, List? messageSeqList, @@ -298,10 +452,223 @@ class ImService { cleanSequence: cleanSequence, // 群聊生效 ); + return ImResult.wrapNoData(res); + } + + /// 获取自己的userId + Future selfUserId() async { + V2TimValueCallback self = await TencentImSDKPlugin.v2TIMManager.getLoginUser(); + String? userId = self.data; + return ImResult( + success: self.code == 0, + code: self.code, + desc: self.desc, + data: userId, + ); + } + + /// 查询当前登录用户的个人信息 + Future selfInfo() async { + // 获取当前登录的用户 ID + final idRes = await selfUserId(); + // 获取用户信息 + V2TimValueCallback> res = await TencentImSDKPlugin.v2TIMManager.getUsersInfo(userIDList: [idRes.data]); return ImResult( success: res.code == 0, code: res.code, desc: res.desc, + data: res.data?.isNotEmpty == true ? res.data!.first : null, ); } + + /// 查询其他人的信息 + Future otherInfo(id) async { + // 获取用户信息 + V2TimValueCallback> res = await TencentImSDKPlugin.v2TIMManager.getUsersInfo(userIDList: [id]); + return ImResult( + success: res.code == 0, + code: res.code, + desc: res.desc, + data: res.data?.isNotEmpty == true ? res.data!.first : null, + ); + } + + /// 设置个人资料 + Future setSelfInfo({ + required V2TimUserFullInfo userFullInfo, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.setSelfInfo( + userFullInfo: userFullInfo, + ); + return ImResult.wrapNoData(res); + } + + /// 检查是否是好友(双向或单向) + Future isMyFriend(String userID, FriendTypeEnum checkType) async { + final res = await TIMFriendshipManager.instance.checkFriend( + userIDList: [userID], + checkType: checkType, //V2TIM_FRIEND_TYPE_BOTH V2TIM_FRIEND_TYPE_SINGLE + ); + + if (res.code == 0 && res.data != null && res.data!.isNotEmpty) { + final resultType = res.data!.first.resultType; + final isFriend = resultType == 3; //0=无, 1=单向, 2=我在对方列表,3=双向 + return ImResult( + success: true, + desc: res.desc, + code: res.code, + data: isFriend, + ); + } else { + return ImResult( + success: false, + code: res.code, + desc: res.desc, + data: false, + ); + } + } + + /// 添加好友 + Future> addFriend({ + required String userID, + String? remark, + String? friendGroup, + String? addWording, + String? addSource, + required FriendTypeEnum addType, + }) async { + final res = await TIMFriendshipManager.instance.addFriend( + userID: userID, + remark: remark, + friendGroup: friendGroup, + addWording: addWording, + addSource: addSource, + addType: addType, + ); + return ImResult.wrap(res); + } + + ///接受好友申请 + Future> acceptFriendApplication({ + required FriendResponseTypeEnum responseType, + required FriendApplicationTypeEnum type, // V2TIM_FRIEND_ACCEPT_AGREE,同意添加单向好友;V2TIM_FRIEND_ACCEPT_AGREE_AND_ADD,同意并添加为双向好友 + required String userID, + }) async { + final res = await TIMFriendshipManager.instance.acceptFriendApplication( + responseType: responseType, + type: type, + userID: userID, + ); + return ImResult.wrap(res); + } + + /// 拉黑 + Future>> addToBlackList({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.addToBlackList(userIDList: userIDList); + return ImResult.wrap(res); + } + + /// 取消拉黑 + Future>> deleteFromBlackList({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.deleteFromBlackList(userIDList: userIDList); + return ImResult.wrap(res); + } + + ///获取好友列表 + Future>> getFriendList() async { + final res = await TIMFriendshipManager.instance.getFriendList(); + return ImResult.wrap(res); + } + + /// set好友备注 + Future setFriendInfo({ + required String userID, + String? friendRemark, + Map? friendCustomInfo, + }) async { + late V2TimCallback res; + res = await TIMFriendshipManager.instance.setFriendInfo( + userID: userID, + friendRemark: friendRemark, + friendCustomInfo: friendCustomInfo, + ); + return ImResult.wrapNoData(res); + } + + /// 获取好友信息 + Future>> getFriendInfo({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.getFriendsInfo(userIDList: userIDList); + return ImResult.wrap(res); + } + + ///关注 + Future>> followUser({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.followUser( + userIDList: userIDList, + ); + return ImResult.wrap(res); + } + + ///取关 + Future>> unfollowUser({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.unfollowUser( + userIDList: userIDList, + ); + return ImResult.wrap(res); + } + + /// check关注的类型 + /// 0:不是好友也没有关注 + /// 1:你关注了对方(单向) + /// 2:对方关注了你(单向) + /// 3:互相关注(双向好友) + Future>> checkFollowType({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.checkFollowType( + userIDList: userIDList, + ); + return ImResult.wrap(res); + } + + ///获取指定用户的 关注/粉丝/互关 数量信息 + Future>> getUserFollowInfo({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.getUserFollowInfo(userIDList: userIDList); + return ImResult.wrap(res); + } + + /// 获取双向关注列表(互关好友) + /// [nextCursor] 分页游标,首次传空字符串 + Future> getMutualFollowersList({ + required String nextCursor, + }) async { + final res = await TIMFriendshipManager.instance.getMutualFollowersList( + nextCursor: nextCursor, + ); + return ImResult.wrap(res); + } + + /// 获取我的粉丝列表 + /// [nextCursor] 分页游标,首次传空字符串 + Future> getMyFollowersList({ + required String nextCursor, + }) async { + final res = await TIMFriendshipManager.instance.getMyFollowersList( + nextCursor: nextCursor, + ); + return ImResult.wrap(res); + } } diff --git a/lib/IM/push_service.dart b/lib/IM/push_service.dart new file mode 100644 index 0000000..02b839a --- /dev/null +++ b/lib/IM/push_service.dart @@ -0,0 +1,208 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:get/get.dart'; +import 'package:logger/logger.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/models/conversation_type.dart'; +import 'package:loopin/utils/storage.dart'; +import 'package:tencent_cloud_chat_push/common/tim_push_listener.dart'; +import 'package:tencent_cloud_chat_push/common/tim_push_message.dart'; +import 'package:tencent_cloud_chat_push/tencent_cloud_chat_push.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; + +final logger = Logger(); + +class PushService { + static late TIMPushListener _timPushListener; + + Future _registerPushInIsolate(Map args) async { + final sdkAppId = args['sdkAppId'] as int; + final appKey = args['appKey'] as String; + final apnsCertificateID = args['apnsCertificateID'] as int; + + try { + await TencentCloudChatPush().registerPush( + sdkAppId: sdkAppId, + appKey: appKey, + apnsCertificateID: apnsCertificateID, + onNotificationClicked: _onNotificationClicked, + ); + } catch (e) { + logger.e('注册推送失败: $e'); + } + } + + /// 初始化推送服务 + Future initPush({ + required int sdkAppId, + required String appKey, // 客户端密钥 + }) async { + int apnsCertificateID; + final devices = await _getDeviceBrand(); + apnsCertificateID = _getApnsCertificateIDForBrand(devices); + if (apnsCertificateID == 0) { + logger.w('手机厂商:$devices, 未配置证书'); + } + + // 注册推送(初始化) + // if (Platform.isAndroid) { + // await compute(_registerPushInIsolate, { + // 'sdkAppId': sdkAppId, + // 'appKey': appKey, + // 'apnsCertificateID': apnsCertificateID, + // }); + // } else { + await TencentCloudChatPush().registerPush( + onNotificationClicked: _onNotificationClicked, + sdkAppId: sdkAppId, + appKey: appKey, + apnsCertificateID: apnsCertificateID, + ); + // } + + // 关闭 App 在前台时弹出通知栏 + await TencentCloudChatPush().disablePostNotificationInForeground(disable: true); + + ///处理安卓端异常问题; + if (Platform.isAndroid) { + await TencentImSDKPlugin.v2TIMManager.login(userID: Storage.read('userId'), userSig: Storage.read('userSig')); + } + + logger.i('推送服务已注册,手机:$devices,证书ID:$apnsCertificateID'); + + // 添加在线时监听器 + _addPushListener(); + } + + /// 注销推送(退出登录时调用) + static Future unInitPush() async { + try { + await TencentCloudChatPush().unRegisterPush(); + _removePushListener(); + } catch (e) { + logger.i("注销推送失败: $e"); + } + } + + /// 添加监听器 + static void _addPushListener() { + _timPushListener = TIMPushListener( + onRecvPushMessage: (TimPushMessage message) { + logger.i("[推送] 收到消息: ${message.toLogString()}"); + }, + onRevokePushMessage: (String messageId) { + logger.i("[推送] 消息被撤回: $messageId"); + }, + onNotificationClicked: (String ext) { + logger.i("[推送] 点击横幅 ext: $ext"); + _handleNotificationClick(ext); + }, + ); + TencentCloudChatPush().addPushListener(listener: _timPushListener); + logger.i('推送服务在线监听器已注册'); + } + + /// 移除监听器 + static void _removePushListener() { + TencentCloudChatPush().removePushListener(listener: _timPushListener); + } + + /// 横幅点击事件处理 + static void _onNotificationClicked({ + required String ext, + String? userID, + String? groupID, + }) { + logger.i("[点击通知回调] ext: $ext, userID: $userID, groupID: $groupID"); + _handleNotificationClick(ext, userID: userID, groupID: groupID); + } + + /// 统一处理跳转逻辑 + static void _handleNotificationClick(String ext, {String? userID, String? groupID}) async { + try { + // ext={id:对应业务ID,type:'newFoucs',userID:发送人的id,groupID:群ID} + // final ext = jsonEncode({ + // "userID": "123456", + // "groupID": "654321", + // }); + final data = jsonDecode(ext); + logger.i(data); + final type = data['type']; + final router = conversationTypeFromString(type); + logger.w(router); + if (router == null || router != '') { + // 聊天 + if (data['userID'] != null) { + logger.w('有userID'); + // 单聊,获取会话 + final covRes = await ImService.instance.getConversation(conversationID: 'c2c_${data['userID']}'); + final V2TimConversation conversation = covRes.data; + logger.w(conversation.toJson()); + if (conversation.conversationGroupList?.contains(ConversationType.noFriend.name) ?? false) { + // nofriend会话,是否第一次聊天 + conversation.showName = conversation.showName ?? data['title']; + Get.toNamed('/chatNoFriend', arguments: conversation); + } else { + // 去正常的会话 + Get.toNamed('/chat', arguments: conversation); + } + } else { + logger.w('没有userID'); + + // 群聊消息 + final groupRes = await ImService.instance.getConversation(conversationID: 'group_${data['groupID']}'); + Get.toNamed('/chatGroup', arguments: groupRes.data); + } + } else { + // 通知类相关 + Get.toNamed('/$router', arguments: data['id'] ?? ''); + } + } catch (e) { + logger.i("[推送点击] ext 解析失败: $e"); + } + } + + /// 获取手机品牌 + static Future _getDeviceBrand() async { + final deviceInfo = DeviceInfoPlugin(); + try { + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + return androidInfo.brand.toLowerCase(); + } else if (Platform.isIOS) { + return 'apple'; + } else { + return 'unknown'; + } + } catch (e, stack) { + logger.w("获取设备品牌失败: $e\n$stack"); + return 'unknown'; + } + } + + /// 获取对应厂商的证书ID + static int _getApnsCertificateIDForBrand(String brand) { + switch (brand) { + case 'xiaomi': + case 'redmi': + return 41169; + case 'oppo': + return 41170; + case 'vivo': + return 41177; + case 'meizu': + return 41176; + case 'apple': + return 45356; + case 'huawei': + return 41171; + case 'honor': + return 41178; + default: + return 0; + } + } +} diff --git a/lib/api/common_api.dart b/lib/api/common_api.dart index 7fdd2b8..37dce77 100644 --- a/lib/api/common_api.dart +++ b/lib/api/common_api.dart @@ -1,7 +1,15 @@ class CommonApi { - static const String checkVersion = '/check/version'; // 查询版本 - static const String getCode = '/resource/sms/code'; // 发送短信验证码 - static const String login = '/auth/login'; // 登录 - static const String uploadFile = '/upload'; // 上传文件 - static const String accountInfo = '/ums/member/account/'; // 账户信息 + ///----------get + static const String getCode = '/resource/sms/code'; // 发送短信验证码 {'phonenumber'} + static const String accountInfo = '/app/member/info'; // 账户信息 + + ///---------post + static const String login = '/auth/login'; // 登录 {'phonenumber': '', 'smsCode': '', 'clientId': '428a8310cd442757ae699df5d894f051', 'grantType': 'sms'}; + static const String checkVersion = '/system/version/list'; // 查询app版本 {'platformType': Platform.isAndroid ? 'android' : 'ios','status': 1} + static const String uploadFile = '/resource/oss/upload'; + + ///[source]=wechat_open [clientId]=428a8310cd442757ae699df5d894f051 [grantType]=social [socialState]=1 + static const String wxLogin = '/app/member/bind/wechat'; + + ///resource/oss/upload } diff --git a/lib/api/shop_api.dart b/lib/api/shop_api.dart new file mode 100644 index 0000000..f984fd5 --- /dev/null +++ b/lib/api/shop_api.dart @@ -0,0 +1,17 @@ +class ShopApi { + ///---------------------post + /// [size]分页数量 + /// [current] 第几页 + /// [categoryId] 分类id + /// [nameLike] 商品名称 + static const String shopList = '/app/product/page'; // 商品列表 + + /// [showStatus]1=显示 [nameLike]分类名称 + static const String shopCategory = '/app/productCategory/page'; // 商品分类 + /// [] + static const String shopSwiperList = '/app/article/carousel'; // 商品首页轮播图 + + ///---------------------get + /// [url参数/id] + static const String shopDetail = '/app/product'; // 商品详情 +} diff --git a/lib/api/video_api.dart b/lib/api/video_api.dart index b86ce65..7cf0f30 100644 --- a/lib/api/video_api.dart +++ b/lib/api/video_api.dart @@ -1,18 +1,19 @@ class VideoApi { // get - static const String vlogList = '/vlog/indexList'; // 推荐视频列表数据 - static const String myPublicList = '/vlog/myPublicList'; // 我发布的视频 - static const String myPrivateList = '/vlog/myPrivateList'; // 我的私密视频 - static const String myLikedList = '/vlog/myLikedList'; // 我点赞的视频 - static const String friendList = '/vlog/friendList'; //互关好友的视频 - static const String followList = '/vlog/followList'; // 我关注的博主视频 - static const String detail = '/vlog/detail'; // 视频详情 + static const String vlogList = '/app/vlog/indexList'; // 推荐视频列表数据 + static const String myPrivateList = '/app/vlog/myPrivateList'; // 我的私密视频 + static const String friendList = '/app/vlog/friendList'; //互关好友的视频 + static const String followList = '/app/vlog/followList'; // 我关注的博主视频 + static const String detail = '/app/vlog/detail'; // 视频详情 // post - static const String unlike = '/vlog/unlike'; //取消点赞 - static const String totalLikedCounts = '/vlog/totalLikedCounts'; //收到点赞总数 - static const String publish = '/vlog/publish'; //发布视频 - static const String like = '/vlog/like'; //点赞 - static const String changeVlogStatus = '/vlog/changeVlogStatus'; //修改我的视频状态(删除视频) - static const String changeToPublic = '/vlog/changeToPublic'; //将视频改为公开状态 - static const String changeToPrivate = '/vlog/changeToPrivate'; //将视频改为私密状态 + static const String myPublicList = '/app/vlog/myPublicList'; // 我发布的视频 + static const String myLikedList = '/app/vlog/myLikedList'; // 我点赞的视频 + + static const String unlike = '/app/vlog/unlike'; //取消点赞 + static const String totalLikedCounts = '/app/vlog/totalLikedCounts'; //收到点赞总数 + static const String publish = '/app/vlog/publish'; //发布视频 + static const String like = '/app/vlog/like'; //点赞 + static const String changeVlogStatus = '/app/vlog/changeVlogStatus'; //修改我的视频状态(删除视频) + static const String changeToPublic = '/app/vlog/changeToPublic'; //将视频改为公开状态 + static const String changeToPrivate = '/app/vlog/changeToPrivate'; //将视频改为私密状态 } diff --git a/lib/bings/chat_binding.dart b/lib/bings/chat_binding.dart new file mode 100644 index 0000000..dbf8ac0 --- /dev/null +++ b/lib/bings/chat_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_detail_controller.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; + +class ChatBinding extends Bindings { + @override + void dependencies() { + V2TimConversation conversation = Get.arguments; + Get.put(ChatDetailController(userID: conversation.userID!)); + } +} diff --git a/lib/components/custom_sticky_header.dart b/lib/components/custom_sticky_header.dart index 4d65ae6..8017bea 100644 --- a/lib/components/custom_sticky_header.dart +++ b/lib/components/custom_sticky_header.dart @@ -2,11 +2,20 @@ library; import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +typedef OnPinnedChanged = void Function(bool pinned); class CustomStickyHeader extends SliverPersistentHeaderDelegate { final PreferredSize child; + RxBool? isPinned; + RxDouble? positions; - CustomStickyHeader({required this.child}); + CustomStickyHeader({ + required this.child, + this.isPinned, + this.positions, + }); @override double get minExtent => child.preferredSize.height; @@ -21,6 +30,21 @@ class CustomStickyHeader extends SliverPersistentHeaderDelegate { @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + // overlapsContent 或 shrinkOffset >= maxExtent - minExtent 都可以判断是否吸顶 + bool pinned = overlapsContent; // true 表示已经吸顶 + if (isPinned != null && isPinned!.value != pinned) { + WidgetsBinding.instance.addPostFrameCallback((_) { + isPinned!.value = pinned; + }); + } + if (positions != null) { + if ((maxExtent - minExtent) >= shrinkOffset) { + WidgetsBinding.instance.addPostFrameCallback((_) { + positions!.value = shrinkOffset; + }); + } + } + return child; } } diff --git a/lib/components/my_toast.dart b/lib/components/my_toast.dart new file mode 100644 index 0000000..806f604 --- /dev/null +++ b/lib/components/my_toast.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; + +class MyToast { + /// + void tip({ + required String title, + String? type, // 默认失败 + String? position, // 默认底部显示 + }) { + final baseStyle = position == 'top' + ? MyDialog.theme.toastStyle?.top() + : position == 'center' + ? MyDialog.theme.toastStyle?.center() + : MyDialog.theme.toastStyle?.bottom(); + MyDialog.toast( + title, + icon: type == 'success' ? const Icon(Icons.check_circle) : Icon(Icons.warning), + duration: Duration(milliseconds: 5000), + style: baseStyle?.copyWith( + backgroundColor: type == 'success' ? Colors.green.withAlpha(200) : Colors.red.withAlpha(200), + ), + ); + } +} diff --git a/lib/components/network_or_asset_image.dart b/lib/components/network_or_asset_image.dart new file mode 100644 index 0000000..5d3d3b9 --- /dev/null +++ b/lib/components/network_or_asset_image.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class NetworkOrAssetImage extends StatelessWidget { + final String? imageUrl; + final double width; + final double? height; + final BoxFit fit; + final String placeholderAsset; + + const NetworkOrAssetImage({ + super.key, + required this.imageUrl, + this.width = 60.0, + this.height, + this.fit = BoxFit.cover, + this.placeholderAsset = 'assets/images/avatar/default.png', + }); + + @override + Widget build(BuildContext context) { + final isNetwork = imageUrl != null && imageUrl!.isNotEmpty && (imageUrl!.startsWith('http://') || imageUrl!.startsWith('https://')); + + if (isNetwork) { + return Image.network( + imageUrl!, + width: width, + height: height, + fit: fit, + errorBuilder: (context, error, stackTrace) { + return Image.asset( + placeholderAsset, + width: width, + height: height, + fit: fit, + ); + }, + ); + } else { + return Image.asset( + (imageUrl != null && imageUrl!.isNotEmpty) ? imageUrl! : placeholderAsset, + width: width, + height: height, + fit: fit, + ); + } + } +} diff --git a/lib/components/preview_video.dart b/lib/components/preview_video.dart new file mode 100644 index 0000000..5a4cfec --- /dev/null +++ b/lib/components/preview_video.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:loopin/components/shark_video.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +class PreviewVideo extends StatefulWidget { + final String videoUrl; + final double? width; + final double? height; + + const PreviewVideo({ + super.key, + required this.videoUrl, + this.width, + this.height, + }); + + @override + State createState() => _PreviewVideoPageState(); +} + +class _PreviewVideoPageState extends State { + late final Player _player = Player(); + late VideoController videoController = VideoController(_player); + + @override + void initState() { + super.initState(); + _player.open(Media(widget.videoUrl)); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final videoWidth = widget.width ?? 1.0; + final videoHeight = widget.height ?? 1.0; + final isHorizontal = videoWidth > videoHeight; + + return SafeArea( + child: Stack( + children: [ + Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Video( + controller: videoController, + fit: isHorizontal ? BoxFit.contain : BoxFit.cover, + controls: (state) => MyMaterialVideoControls(state), + ), + ), + ), + // 关闭按钮 + Positioned( + top: 20, + left: 20, + child: GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Icon( + Icons.close, + color: Colors.white, + size: 28, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/scan_util.dart b/lib/components/scan_util.dart index a74ee88..5de2e22 100644 --- a/lib/components/scan_util.dart +++ b/lib/components/scan_util.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:ai_barcode_scanner/ai_barcode_scanner.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; class ScanUtil { static Future openScanner({required void Function(String) onResult}) async { @@ -42,6 +43,29 @@ class ScanUtil { width: MediaQuery.of(Get.context!).size.width * 0.8, height: MediaQuery.of(Get.context!).size.height * 0.5, ), + // 异常处理 + errorBuilder: (context, error) { + String message = "无法启动摄像头,请检查权限"; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, color: Colors.red, size: 60), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle(fontSize: 18, color: Colors.red), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () async => await openAppSettings(), + child: const Text('去开启权限'), + ), + ], + ), + ); + }, ), ); diff --git a/lib/components/shark_video.dart b/lib/components/shark_video.dart new file mode 100644 index 0000000..943b54f --- /dev/null +++ b/lib/components/shark_video.dart @@ -0,0 +1,2062 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +library; + +// ignore_for_file: non_constant_identifier_names +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart'; +import 'package:screen_brightness_platform_interface/screen_brightness_platform_interface.dart'; +import 'package:volume_controller/volume_controller.dart'; + +/// {@template material_video_controls} +/// +/// [Video] controls which use Material design. +/// +/// {@endtemplate} +Widget MyMaterialVideoControls(VideoState state) { + return const VideoControlsThemeDataInjector( + child: _MaterialVideoControls(), + ); +} + +/// [MaterialVideoControlsThemeData] available in this [context]. +MaterialVideoControlsThemeData _theme(BuildContext context) => FullscreenInheritedWidget.maybeOf(context) == null + ? MaterialVideoControlsTheme.maybeOf(context)?.normal ?? kDefaultMaterialVideoControlsThemeData + : MaterialVideoControlsTheme.maybeOf(context)?.fullscreen ?? kDefaultMaterialVideoControlsThemeDataFullscreen; + +/// Default [MaterialVideoControlsThemeData]. +const kDefaultMaterialVideoControlsThemeData = MaterialVideoControlsThemeData(); + +/// Default [MaterialVideoControlsThemeData] for fullscreen. +const kDefaultMaterialVideoControlsThemeDataFullscreen = MaterialVideoControlsThemeData( + displaySeekBar: true, + automaticallyImplySkipNextButton: true, + automaticallyImplySkipPreviousButton: true, + volumeGesture: true, + brightnessGesture: true, + seekGesture: true, + gesturesEnabledWhileControlsVisible: true, + seekOnDoubleTap: true, + seekOnDoubleTapEnabledWhileControlsVisible: true, + visibleOnMount: false, + speedUpOnLongPress: false, + speedUpFactor: 2.0, + verticalGestureSensitivity: 100, + horizontalGestureSensitivity: 1000, + backdropColor: Color(0x66000000), + padding: null, + controlsHoverDuration: Duration(seconds: 3), + controlsTransitionDuration: Duration(milliseconds: 300), + bufferingIndicatorBuilder: null, + volumeIndicatorBuilder: null, + brightnessIndicatorBuilder: null, + seekIndicatorBuilder: null, + speedUpIndicatorBuilder: null, + primaryButtonBar: [ + Spacer(flex: 2), + MaterialSkipPreviousButton(), + Spacer(), + MaterialPlayOrPauseButton(iconSize: 56.0), + Spacer(), + MaterialSkipNextButton(), + Spacer(flex: 2), + ], + topButtonBar: [], + topButtonBarMargin: EdgeInsets.symmetric( + horizontal: 16.0, + ), + bottomButtonBar: [ + MaterialPositionIndicator(), + Spacer(), + // 全屏后的 + MaterialFullscreenButton(), + ], + bottomButtonBarMargin: EdgeInsets.only( + left: 16.0, + right: 8.0, + bottom: 42.0, + ), + buttonBarHeight: 56.0, + buttonBarButtonSize: 24.0, + buttonBarButtonColor: Color(0xFFFFFFFF), + seekBarMargin: EdgeInsets.only( + left: 16.0, + right: 16.0, + bottom: 42.0, + ), + seekBarHeight: 2.4, + seekBarContainerHeight: 36.0, + seekBarColor: Color(0x3DFFFFFF), + seekBarPositionColor: Color(0xFFFF0000), + seekBarBufferColor: Color(0x3DFFFFFF), + seekBarThumbSize: 12.8, + seekBarThumbColor: Color(0xFFFF0000), + seekBarAlignment: Alignment.bottomCenter, + shiftSubtitlesOnControlsVisibilityChange: false, +); + +/// {@template material_video_controls_theme_data} +/// +/// Theming related data for [MaterialVideoControls]. These values are used to theme the descendant [MaterialVideoControls]. +/// +/// {@endtemplate} +class MaterialVideoControlsThemeData { + // BEHAVIOR + + /// Whether to display seek bar. + final bool displaySeekBar; + + /// Whether a skip next button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipNextButton; + + /// Whether a skip previous button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipPreviousButton; + + /// Whether to modify volume on vertical drag gesture on the right side of the screen. + final bool volumeGesture; + + /// Whether to modify screen brightness on vertical drag gesture on the left side of the screen. + final bool brightnessGesture; + + /// Whether to seek on horizontal drag gesture. + final bool seekGesture; + + /// Whether to allow gesture controls to work while controls are visible. + /// NOTE: This option is ignored when gestures are false. + final bool gesturesEnabledWhileControlsVisible; + + /// Whether to enable double tap to seek on left or right side of the screen. + final bool seekOnDoubleTap; + + /// Whether to allow double tap to seek on left or right side of the screen to work while controls are visible. + /// NOTE: This option is ignored when [seekOnDoubleTap] is false. + final bool seekOnDoubleTapEnabledWhileControlsVisible; + + /// `seekOnDoubleTapLayoutTapsRatios` defines the width proportions for the interactive areas + /// responsible for seek actions (backward seek, instant tap, forward seek) when a double tap + /// occurs on the video widget. This property divides the video widget into three segments + /// horizontally. Each integer in the list represents the relative width of each segment. + /// By default, the value `[1, 1, 1]` means that the video widget is equally divided into three + /// segments: the left segment for backward seek, the middle segment for instant tap (usually show and hide controls), + /// and the right segment for forward seek. Adjusting these values changes the width of the interactive areas + /// for each double tap action. + final List seekOnDoubleTapLayoutTapsRatios; + + /// `seekOnDoubleTapLayoutWidgetRatios` defines the width proportions for the visual indicators or + /// widgets that appear during the double tap actions (backward seek, instant tap, forward seek). + /// Similar to `seekOnDoubleTapLayoutTapsRatios`, it divides the area where these indicators are + /// displayed into three segments. Each integer in the list represents the relative width of each + /// segment where the corresponding indicators will be shown. The default `[1, 1, 1]` equally divides + /// the space for each indicator. Modifying these values can change the layout of the seek indicators, + /// giving more or less space to each one based on the specified ratios. + final List seekOnDoubleTapLayoutWidgetRatios; + + /// Duration of seek on double tap backward. + final Duration seekOnDoubleTapBackwardDuration; + + /// Duration of seek on double tap forward. + final Duration seekOnDoubleTapForwardDuration; + + /// Whether the controls are initially visible. + final bool visibleOnMount; + + /// Whether to speed up on long press. + final bool speedUpOnLongPress; + + /// Factor to speed up on long press. + final double speedUpFactor; + + /// Gesture sensitivity on vertical drag gestures, the higher the value is the less sensitive the gesture. + final double verticalGestureSensitivity; + + /// Gesture sensitivity on horizontal drag gestures, the higher the value is the less sensitive the gesture. + final double horizontalGestureSensitivity; + + /// Color of backdrop that comes up when controls are visible. + final Color? backdropColor; + + // GENERIC + + /// Padding around the controls. + /// + /// * Default: `EdgeInsets.zero` + /// * FullScreen: `MediaQuery.of(context).padding` + /// + /// NOTE: In fullscreen, this will be safe area (set [padding] to [EdgeInsets.zero] to disable safe area) + final EdgeInsets? padding; + + /// [Duration] after which the controls will be hidden when there is no mouse movement. + final Duration controlsHoverDuration; + + /// [Duration] for which the controls will be animated when shown or hidden. + final Duration controlsTransitionDuration; + + /// Builder for the buffering indicator. + final Widget Function(BuildContext)? bufferingIndicatorBuilder; + + /// Custom builder for volume indicator. + final Widget Function(BuildContext, double)? volumeIndicatorBuilder; + + /// Custom builder for brightness indicator. + final Widget Function(BuildContext, double)? brightnessIndicatorBuilder; + + /// Custom builder for seek indicator. + final Widget Function(BuildContext, Duration)? seekIndicatorBuilder; + + /// Custom builder for seek indicator. + final Widget Function(BuildContext, double)? speedUpIndicatorBuilder; + + // BUTTON BAR + + /// Buttons to be displayed in the primary button bar. + final List primaryButtonBar; + + /// Buttons to be displayed in the top button bar. + final List topButtonBar; + + /// Margin around the top button bar. + final EdgeInsets topButtonBarMargin; + + /// Buttons to be displayed in the bottom button bar. + final List bottomButtonBar; + + /// Margin around the button bar. + final EdgeInsets bottomButtonBarMargin; + + /// Height of the button bar. + final double buttonBarHeight; + + /// Size of the button bar buttons. + final double buttonBarButtonSize; + + /// Color of the button bar buttons. + final Color buttonBarButtonColor; + + // SEEK BAR + + /// Margin around the seek bar. + final EdgeInsets seekBarMargin; + + /// Height of the seek bar. + final double seekBarHeight; + + /// Height of the seek bar [Container]. + final double seekBarContainerHeight; + + /// [Color] of the seek bar. + final Color seekBarColor; + + /// [Color] of the playback position section in the seek bar. + final Color seekBarPositionColor; + + /// [Color] of the playback buffer section in the seek bar. + final Color seekBarBufferColor; + + /// Size of the seek bar thumb. + final double seekBarThumbSize; + + /// [Color] of the seek bar thumb. + final Color seekBarThumbColor; + + /// [Alignment] of seek bar inside the seek bar container. + final Alignment seekBarAlignment; + + // SUBTITLE + + /// Whether to shift the subtitles upwards when the controls are visible. + final bool shiftSubtitlesOnControlsVisibilityChange; + + /// {@macro material_video_controls_theme_data} + const MaterialVideoControlsThemeData({ + this.displaySeekBar = true, + this.automaticallyImplySkipNextButton = true, + this.automaticallyImplySkipPreviousButton = true, + this.volumeGesture = false, + this.brightnessGesture = false, + this.seekGesture = false, + this.gesturesEnabledWhileControlsVisible = true, + this.seekOnDoubleTap = false, + this.seekOnDoubleTapEnabledWhileControlsVisible = true, + this.seekOnDoubleTapLayoutTapsRatios = const [1, 1, 1], + this.seekOnDoubleTapLayoutWidgetRatios = const [1, 1, 1], + this.seekOnDoubleTapBackwardDuration = const Duration(seconds: 10), + this.seekOnDoubleTapForwardDuration = const Duration(seconds: 10), + this.visibleOnMount = false, + this.speedUpOnLongPress = false, + this.speedUpFactor = 2.0, + this.verticalGestureSensitivity = 100, + this.horizontalGestureSensitivity = 1000, + this.backdropColor = const Color(0x66000000), + this.padding, + this.controlsHoverDuration = const Duration(seconds: 3), + this.controlsTransitionDuration = const Duration(milliseconds: 300), + this.bufferingIndicatorBuilder, + this.volumeIndicatorBuilder, + this.brightnessIndicatorBuilder, + this.seekIndicatorBuilder, + this.speedUpIndicatorBuilder, + this.primaryButtonBar = const [ + Spacer(flex: 2), + MaterialSkipPreviousButton(), + Spacer(), + MaterialPlayOrPauseButton(iconSize: 48.0), + Spacer(), + MaterialSkipNextButton(), + Spacer(flex: 2), + ], + this.topButtonBar = const [], + this.topButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), + this.bottomButtonBar = const [ + MaterialPositionIndicator(), + Spacer(), + // 未全屏的 + // MaterialFullscreenButton(), + ], + this.bottomButtonBarMargin = const EdgeInsets.only(left: 16.0, right: 8.0), + this.buttonBarHeight = 56.0, + this.buttonBarButtonSize = 24.0, + this.buttonBarButtonColor = const Color(0xFFFFFFFF), + this.seekBarMargin = EdgeInsets.zero, + this.seekBarHeight = 2.4, + this.seekBarContainerHeight = 36.0, + this.seekBarColor = const Color(0x3DFFFFFF), + this.seekBarPositionColor = const Color(0xFFFF0000), + this.seekBarBufferColor = const Color(0x3DFFFFFF), + this.seekBarThumbSize = 12.8, + this.seekBarThumbColor = const Color(0xFFFF0000), + this.seekBarAlignment = Alignment.bottomCenter, + this.shiftSubtitlesOnControlsVisibilityChange = false, + }); + + /// Creates a copy of this [MaterialVideoControlsThemeData] with the given fields replaced by the non-null parameter values. + MaterialVideoControlsThemeData copyWith({ + bool? displaySeekBar, + bool? automaticallyImplySkipNextButton, + bool? automaticallyImplySkipPreviousButton, + bool? volumeGesture, + bool? brightnessGesture, + bool? seekGesture, + bool? gesturesEnabledWhileControlsVisible, + bool? seekOnDoubleTap, + bool? seekOnDoubleTapEnabledWhileControlsVisible, + List? seekOnDoubleTapLayoutTapsRatios, + List? seekOnDoubleTapLayoutWidgetRatios, + Duration? seekOnDoubleTapBackwardDuration, + Duration? seekOnDoubleTapForwardDuration, + bool? visibleOnMount, + bool? speedUpOnLongPress, + double? speedUpFactor, + double? verticalGestureSensitivity, + double? horizontalGestureSensitivity, + Color? backdropColor, + Duration? controlsHoverDuration, + Duration? controlsTransitionDuration, + Widget Function(BuildContext)? bufferingIndicatorBuilder, + Widget Function(BuildContext, double)? volumeIndicatorBuilder, + Widget Function(BuildContext, double)? brightnessIndicatorBuilder, + Widget Function(BuildContext, Duration)? seekIndicatorBuilder, + Widget Function(BuildContext, double)? speedUpIndicatorBuilder, + List? primaryButtonBar, + List? topButtonBar, + EdgeInsets? topButtonBarMargin, + List? bottomButtonBar, + EdgeInsets? bottomButtonBarMargin, + double? buttonBarHeight, + double? buttonBarButtonSize, + Color? buttonBarButtonColor, + EdgeInsets? seekBarMargin, + double? seekBarHeight, + double? seekBarContainerHeight, + Color? seekBarColor, + Color? seekBarPositionColor, + Color? seekBarBufferColor, + double? seekBarThumbSize, + Color? seekBarThumbColor, + Alignment? seekBarAlignment, + bool? shiftSubtitlesOnControlsVisibilityChange, + }) { + return MaterialVideoControlsThemeData( + displaySeekBar: displaySeekBar ?? this.displaySeekBar, + automaticallyImplySkipNextButton: automaticallyImplySkipNextButton ?? this.automaticallyImplySkipNextButton, + automaticallyImplySkipPreviousButton: automaticallyImplySkipPreviousButton ?? this.automaticallyImplySkipPreviousButton, + volumeGesture: volumeGesture ?? this.volumeGesture, + brightnessGesture: brightnessGesture ?? this.brightnessGesture, + seekGesture: seekGesture ?? this.seekGesture, + gesturesEnabledWhileControlsVisible: gesturesEnabledWhileControlsVisible ?? this.gesturesEnabledWhileControlsVisible, + seekOnDoubleTap: seekOnDoubleTap ?? this.seekOnDoubleTap, + seekOnDoubleTapEnabledWhileControlsVisible: seekOnDoubleTapEnabledWhileControlsVisible ?? this.seekOnDoubleTapEnabledWhileControlsVisible, + seekOnDoubleTapLayoutTapsRatios: seekOnDoubleTapLayoutTapsRatios ?? this.seekOnDoubleTapLayoutTapsRatios, + seekOnDoubleTapLayoutWidgetRatios: seekOnDoubleTapLayoutWidgetRatios ?? this.seekOnDoubleTapLayoutWidgetRatios, + seekOnDoubleTapBackwardDuration: seekOnDoubleTapBackwardDuration ?? this.seekOnDoubleTapBackwardDuration, + seekOnDoubleTapForwardDuration: seekOnDoubleTapForwardDuration ?? this.seekOnDoubleTapForwardDuration, + visibleOnMount: visibleOnMount ?? this.visibleOnMount, + speedUpOnLongPress: speedUpOnLongPress ?? this.speedUpOnLongPress, + speedUpFactor: speedUpFactor ?? this.speedUpFactor, + verticalGestureSensitivity: verticalGestureSensitivity ?? this.verticalGestureSensitivity, + horizontalGestureSensitivity: horizontalGestureSensitivity ?? this.horizontalGestureSensitivity, + backdropColor: backdropColor ?? this.backdropColor, + controlsHoverDuration: controlsHoverDuration ?? this.controlsHoverDuration, + controlsTransitionDuration: controlsTransitionDuration ?? this.controlsTransitionDuration, + bufferingIndicatorBuilder: bufferingIndicatorBuilder ?? this.bufferingIndicatorBuilder, + volumeIndicatorBuilder: volumeIndicatorBuilder ?? this.volumeIndicatorBuilder, + brightnessIndicatorBuilder: brightnessIndicatorBuilder ?? this.brightnessIndicatorBuilder, + seekIndicatorBuilder: seekIndicatorBuilder ?? this.seekIndicatorBuilder, + speedUpIndicatorBuilder: speedUpIndicatorBuilder ?? this.speedUpIndicatorBuilder, + primaryButtonBar: primaryButtonBar ?? this.primaryButtonBar, + topButtonBar: topButtonBar ?? this.topButtonBar, + topButtonBarMargin: topButtonBarMargin ?? this.topButtonBarMargin, + bottomButtonBar: bottomButtonBar ?? this.bottomButtonBar, + bottomButtonBarMargin: bottomButtonBarMargin ?? this.bottomButtonBarMargin, + buttonBarHeight: buttonBarHeight ?? this.buttonBarHeight, + buttonBarButtonSize: buttonBarButtonSize ?? this.buttonBarButtonSize, + buttonBarButtonColor: buttonBarButtonColor ?? this.buttonBarButtonColor, + seekBarMargin: seekBarMargin ?? this.seekBarMargin, + seekBarHeight: seekBarHeight ?? this.seekBarHeight, + seekBarContainerHeight: seekBarContainerHeight ?? this.seekBarContainerHeight, + seekBarColor: seekBarColor ?? this.seekBarColor, + seekBarPositionColor: seekBarPositionColor ?? this.seekBarPositionColor, + seekBarBufferColor: seekBarBufferColor ?? this.seekBarBufferColor, + seekBarThumbSize: seekBarThumbSize ?? this.seekBarThumbSize, + seekBarThumbColor: seekBarThumbColor ?? this.seekBarThumbColor, + seekBarAlignment: seekBarAlignment ?? this.seekBarAlignment, + shiftSubtitlesOnControlsVisibilityChange: shiftSubtitlesOnControlsVisibilityChange ?? this.shiftSubtitlesOnControlsVisibilityChange, + ); + } +} + +/// {@template material_video_controls_theme} +/// +/// Inherited widget which provides [MaterialVideoControlsThemeData] to descendant widgets. +/// +/// {@endtemplate} +class MaterialVideoControlsTheme extends InheritedWidget { + final MaterialVideoControlsThemeData normal; + final MaterialVideoControlsThemeData fullscreen; + const MaterialVideoControlsTheme({ + super.key, + required this.normal, + required this.fullscreen, + required super.child, + }); + + static MaterialVideoControlsTheme? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + static MaterialVideoControlsTheme of(BuildContext context) { + final MaterialVideoControlsTheme? result = maybeOf(context); + assert( + result != null, + 'No [MaterialVideoControlsTheme] found in [context]', + ); + return result!; + } + + @override + bool updateShouldNotify(MaterialVideoControlsTheme oldWidget) => identical(normal, oldWidget.normal) && identical(fullscreen, oldWidget.fullscreen); +} + +/// {@macro material_video_controls} +class _MaterialVideoControls extends StatefulWidget { + const _MaterialVideoControls(); + + @override + State<_MaterialVideoControls> createState() => _MaterialVideoControlsState(); +} + +/// {@macro material_video_controls} +class _MaterialVideoControlsState extends State<_MaterialVideoControls> { + late bool mount = _theme(context).visibleOnMount; + late bool visible = _theme(context).visibleOnMount; + Timer? _timer; + + double _brightnessValue = 0.0; + bool _brightnessIndicator = false; + Timer? _brightnessTimer; + double _currentRate = 1.0; + double _volumeValue = 0.0; + bool _volumeIndicator = false; + Timer? _volumeTimer; + // The default event stream in package:volume_controller is buggy. + bool _volumeInterceptEventStream = false; + + Offset _dragInitialDelta = Offset.zero; // Initial position for horizontal drag + int swipeDuration = 0; // Duration to seek in video + bool showSwipeDuration = false; // Whether to show the seek duration overlay + + bool _speedUpIndicator = false; + late /* private */ var playlist = controller(context).player.state.playlist; + late bool buffering = controller(context).player.state.buffering; + final VolumeController _volumeController = VolumeController.instance; + + bool _mountSeekBackwardButton = false; + bool _mountSeekForwardButton = false; + bool _hideSeekBackwardButton = false; + bool _hideSeekForwardButton = false; + Timer? _timerSeekBackwardButton; + Timer? _timerSeekForwardButton; + + final ValueNotifier _seekBarDeltaValueNotifier = ValueNotifier(Duration.zero); + + final List subscriptions = []; + + double get subtitleVerticalShiftOffset => + (_theme(context).padding?.bottom ?? 0.0) + + (_theme(context).bottomButtonBarMargin.vertical) + + (_theme(context).bottomButtonBar.isNotEmpty ? _theme(context).buttonBarHeight : 0.0); + Offset? _tapPosition; + + void _handleDoubleTapDown(TapDownDetails details) { + setState(() { + _tapPosition = details.localPosition; + }); + } + + void _handleLongPress() { + setState(() { + _speedUpIndicator = true; + }); + _currentRate = controller(context).player.state.rate; + controller(context).player.setRate(_theme(context).speedUpFactor); + } + + void _handleLongPressEnd(LongPressEndDetails details) { + setState(() { + _speedUpIndicator = false; + }); + controller(context).player.setRate(_currentRate); + } + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.playlist.listen( + (event) { + setState(() { + playlist = event; + }); + }, + ), + controller(context).player.stream.buffering.listen( + (event) { + setState(() { + buffering = event; + }); + }, + ), + ], + ); + + if (_theme(context).visibleOnMount) { + _timer = Timer( + _theme(context).controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + } + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + // -------------------------------------------------- + // package:screen_brightness + Future.microtask(() async { + try { + await ScreenBrightnessPlatform.instance.resetApplicationScreenBrightness(); + } catch (_) {} + }); + // -------------------------------------------------- + _timerSeekBackwardButton?.cancel(); + _timerSeekForwardButton?.cancel(); + super.dispose(); + } + + void shiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding + + EdgeInsets.fromLTRB( + 0.0, + 0.0, + 0.0, + subtitleVerticalShiftOffset, + ), + ); + } + } + + void unshiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding, + ); + } + } + + void onTap() { + if (!visible) { + setState(() { + mount = true; + visible = true; + }); + shiftSubtitle(); + _timer?.cancel(); + _timer = Timer(_theme(context).controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }); + } else { + setState(() { + visible = false; + }); + unshiftSubtitle(); + _timer?.cancel(); + } + } + + void onDoubleTapSeekBackward() { + setState(() { + _mountSeekBackwardButton = true; + }); + } + + void onDoubleTapSeekForward() { + setState(() { + _mountSeekForwardButton = true; + }); + } + + void onHorizontalDragUpdate(DragUpdateDetails details) { + if (_dragInitialDelta == Offset.zero) { + _dragInitialDelta = details.localPosition; + return; + } + + final diff = _dragInitialDelta.dx - details.localPosition.dx; + final duration = controller(context).player.state.duration.inSeconds; + final position = controller(context).player.state.position.inSeconds; + + final seconds = -(diff * duration / _theme(context).horizontalGestureSensitivity).round(); + final relativePosition = position + seconds; + + if (relativePosition <= duration && relativePosition >= 0) { + setState(() { + swipeDuration = seconds; + showSwipeDuration = true; + _seekBarDeltaValueNotifier.value = Duration(seconds: seconds); + }); + } + } + + void onHorizontalDragEnd() { + if (swipeDuration != 0) { + Duration newPosition = controller(context).player.state.position + Duration(seconds: swipeDuration); + newPosition = newPosition.clamp( + Duration.zero, + controller(context).player.state.duration, + ); + controller(context).player.seek(newPosition); + } + + setState(() { + _dragInitialDelta = Offset.zero; + showSwipeDuration = false; + }); + } + + bool _isInSegment(double localX, int segmentIndex) { + // Local variable with the list of ratios + List segmentRatios = _theme(context).seekOnDoubleTapLayoutTapsRatios; + + int totalRatios = segmentRatios.reduce((a, b) => a + b); + + double segmentWidthMultiplier = widgetWidth(context) / totalRatios; + double start = 0; + double end; + + for (int i = 0; i < segmentRatios.length; i++) { + end = start + (segmentWidthMultiplier * segmentRatios[i]); + + // Check if the current index matches the segmentIndex and if localX falls within it + if (i == segmentIndex && localX >= start && localX <= end) { + return true; + } + + // Set the start of the next segment + start = end; + } + + // If localX does not fall within the specified segment + return false; + } + + bool _isInRightSegment(double localX) { + return _isInSegment(localX, 2); + } + + bool _isInLeftSegment(double localX) { + return _isInSegment(localX, 0); + } + + void _handlePointerDown(PointerDownEvent event) { + onTap(); + } + + @override + void initState() { + super.initState(); + // -------------------------------------------------- + // package:volume_controller + Future.microtask(() async { + try { + _volumeController.showSystemUI = false; + _volumeValue = await _volumeController.getVolume(); + _volumeController.addListener((value) { + if (mounted && !_volumeInterceptEventStream) { + setState(() { + _volumeValue = value; + }); + } + }); + } catch (_) {} + }); + // -------------------------------------------------- + // -------------------------------------------------- + // package:screen_brightness + Future.microtask(() async { + try { + _brightnessValue = await ScreenBrightnessPlatform.instance.application; + ScreenBrightnessPlatform.instance.onApplicationScreenBrightnessChanged.listen((value) { + if (mounted) { + setState(() { + _brightnessValue = value; + }); + } + }); + } catch (_) {} + }); + // -------------------------------------------------- + } + + Future setVolume(double value) async { + // -------------------------------------------------- + // package:volume_controller + try { + _volumeController.setVolume(value); + } catch (_) {} + setState(() { + _volumeValue = value; + _volumeIndicator = true; + _volumeInterceptEventStream = true; + }); + _volumeTimer?.cancel(); + _volumeTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted) { + setState(() { + _volumeIndicator = false; + _volumeInterceptEventStream = false; + }); + } + }); + // -------------------------------------------------- + } + + Future setBrightness(double value) async { + // -------------------------------------------------- + // package:screen_brightness + try { + await ScreenBrightnessPlatform.instance.setApplicationScreenBrightness(value); + } catch (_) {} + setState(() { + _brightnessIndicator = true; + }); + _brightnessTimer?.cancel(); + _brightnessTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted) { + setState(() { + _brightnessIndicator = false; + }); + } + }); + // -------------------------------------------------- + } + + @override + Widget build(BuildContext context) { + var seekOnDoubleTapEnabledWhileControlsAreVisible = (_theme(context).seekOnDoubleTap && _theme(context).seekOnDoubleTapEnabledWhileControlsVisible); + assert(_theme(context).seekOnDoubleTapLayoutTapsRatios.length == 3, "The number of seekOnDoubleTapLayoutTapsRatios must be 3, i.e. [1, 1, 1]"); + assert(_theme(context).seekOnDoubleTapLayoutWidgetRatios.length == 3, "The number of seekOnDoubleTapLayoutWidgetRatios must be 3, i.e. [1, 1, 1]"); + return Theme( + data: Theme.of(context).copyWith( + focusColor: const Color(0x00000000), + hoverColor: const Color(0x00000000), + splashColor: const Color(0x00000000), + highlightColor: const Color(0x00000000), + ), + child: Focus( + autofocus: true, + child: Material( + elevation: 0.0, + borderOnForeground: false, + animationDuration: Duration.zero, + color: const Color(0x00000000), + shadowColor: const Color(0x00000000), + surfaceTintColor: const Color(0x00000000), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // Controls: + AnimatedOpacity( + curve: Curves.easeInOut, + opacity: visible ? 1.0 : 0.0, + duration: _theme(context).controlsTransitionDuration, + onEnd: () { + setState(() { + if (!visible) { + mount = false; + } + }); + }, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Container( + color: _theme(context).backdropColor, + ), + ), + // We are adding 16.0 boundary around the actual controls (which contain the vertical drag gesture detectors). + // This will make the hit-test on edges (e.g. swiping to: show status-bar, show navigation-bar, go back in navigation) not activate the swipe gesture annoyingly. + Positioned.fill( + left: 16.0, + top: 16.0, + right: 16.0, + bottom: 16.0 + subtitleVerticalShiftOffset, + child: Listener( + onPointerDown: (event) => _handlePointerDown(event), + child: GestureDetector( + onDoubleTapDown: _handleDoubleTapDown, + onLongPress: _theme(context).speedUpOnLongPress ? _handleLongPress : null, + onLongPressEnd: _theme(context).speedUpOnLongPress ? _handleLongPressEnd : null, + onDoubleTap: () { + if (_tapPosition == null) { + return; + } + if (_isInRightSegment(_tapPosition!.dx)) { + if ((!mount && _theme(context).seekOnDoubleTap) || seekOnDoubleTapEnabledWhileControlsAreVisible) { + onDoubleTapSeekForward(); + } + } else { + if (_isInLeftSegment(_tapPosition!.dx)) { + if ((!mount && _theme(context).seekOnDoubleTap) || seekOnDoubleTapEnabledWhileControlsAreVisible) { + onDoubleTapSeekBackward(); + } + } + } + }, + onHorizontalDragUpdate: (details) { + if ((!mount && _theme(context).seekGesture) || + (_theme(context).seekGesture && _theme(context).gesturesEnabledWhileControlsVisible)) { + onHorizontalDragUpdate(details); + } + }, + onHorizontalDragEnd: (details) { + onHorizontalDragEnd(); + }, + onVerticalDragUpdate: (e) async { + final delta = e.delta.dy; + final Offset position = e.localPosition; + + if (position.dx <= widgetWidth(context) / 2) { + // Left side of screen swiped + if ((!mount && _theme(context).brightnessGesture) || + (_theme(context).brightnessGesture && _theme(context).gesturesEnabledWhileControlsVisible)) { + final brightness = _brightnessValue - delta / _theme(context).verticalGestureSensitivity; + final result = brightness.clamp(0.0, 1.0); + setBrightness(result); + } + } else { + // Right side of screen swiped + + if ((!mount && _theme(context).volumeGesture) || + (_theme(context).volumeGesture && _theme(context).gesturesEnabledWhileControlsVisible)) { + final volume = _volumeValue - delta / _theme(context).verticalGestureSensitivity; + final result = volume.clamp(0.0, 1.0); + setVolume(result); + } + } + }, + child: Container( + color: const Color(0x00000000), + ), + ), + ), + ), + if (mount) + Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) ? MediaQuery.of(context).padding : EdgeInsets.zero), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: _theme(context).topButtonBar, + ), + ), + // Only display [primaryButtonBar] if [buffering] is false. + Expanded( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: buffering ? 0.0 : 1.0, + duration: _theme(context).controlsTransitionDuration, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: _theme(context).primaryButtonBar, + ), + ), + ), + ), + Stack( + alignment: Alignment.bottomCenter, + children: [ + if (_theme(context).displaySeekBar) + MaterialSeekBar( + onSeekStart: () { + _timer?.cancel(); + }, + onSeekEnd: () { + _timer = Timer( + _theme(context).controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + }, + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: _theme(context).bottomButtonBar, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + // Double-Tap Seek Seek-Bar: + if (!mount) + if (_mountSeekBackwardButton || _mountSeekForwardButton || showSwipeDuration) + Column( + children: [ + const Spacer(), + Stack( + alignment: Alignment.bottomCenter, + children: [ + if (_theme(context).displaySeekBar) + MaterialSeekBar( + delta: _seekBarDeltaValueNotifier, + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ], + ), + // Buffering Indicator. + IgnorePointer( + child: Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) ? MediaQuery.of(context).padding : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + ), + Expanded( + child: Center( + child: TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: buffering ? 1.0 : 0.0, + ), + duration: _theme(context).controlsTransitionDuration, + builder: (context, value, child) { + // Only mount the buffering indicator if the opacity is greater than 0.0. + // This has been done to prevent redundant resource usage in [CircularProgressIndicator]. + if (value > 0.0) { + return Opacity( + opacity: value, + child: _theme(context).bufferingIndicatorBuilder?.call(context) ?? child!, + ); + } + return const SizedBox.shrink(); + }, + child: const CircularProgressIndicator( + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ), + ), + // Volume Indicator. + IgnorePointer( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: (!mount || _theme(context).gesturesEnabledWhileControlsVisible) && _volumeIndicator ? 1.0 : 0.0, + duration: _theme(context).controlsTransitionDuration, + child: _theme(context).volumeIndicatorBuilder?.call(context, _volumeValue) ?? + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 52.0, + width: 108.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 52.0, + width: 42.0, + alignment: Alignment.centerRight, + child: Icon( + _volumeValue == 0.0 + ? Icons.volume_off + : _volumeValue < 0.5 + ? Icons.volume_down + : Icons.volume_up, + color: const Color(0xFFFFFFFF), + size: 24.0, + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + '${(_volumeValue * 100.0).round()}%', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + const SizedBox(width: 16.0), + ], + ), + ), + ), + ), + // Brightness Indicator. + IgnorePointer( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: (!mount || _theme(context).gesturesEnabledWhileControlsVisible) && _brightnessIndicator ? 1.0 : 0.0, + duration: _theme(context).controlsTransitionDuration, + child: _theme(context).brightnessIndicatorBuilder?.call(context, _brightnessValue) ?? + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 52.0, + width: 108.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 52.0, + width: 42.0, + alignment: Alignment.centerRight, + child: Icon( + _brightnessValue < 1.0 / 3.0 + ? Icons.brightness_low + : _brightnessValue < 2.0 / 3.0 + ? Icons.brightness_medium + : Icons.brightness_high, + color: const Color(0xFFFFFFFF), + size: 24.0, + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + '${(_brightnessValue * 100.0).round()}%', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + const SizedBox(width: 16.0), + ], + ), + ), + ), + ), + // Speedup Indicator. + IgnorePointer( + child: Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) ? MediaQuery.of(context).padding : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + ), + Expanded( + child: AnimatedOpacity( + duration: _theme(context).controlsTransitionDuration, + opacity: _speedUpIndicator ? 1 : 0, + child: _theme(context).speedUpIndicatorBuilder?.call(context, _theme(context).speedUpFactor) ?? + Container( + alignment: Alignment.topCenter, + child: Container( + margin: const EdgeInsets.all(16.0), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 48.0, + width: 108.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 16.0), + Expanded( + child: Text( + '${_theme(context).speedUpFactor.toStringAsFixed(1)}x', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + Container( + height: 48.0, + width: 48.0 - 16.0, + alignment: Alignment.centerRight, + child: const Icon( + Icons.fast_forward, + color: Color(0xFFFFFFFF), + size: 24.0, + ), + ), + const SizedBox(width: 16.0), + ], + ), + ), + ), + ), + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ), + ), + // Seek Indicator. + IgnorePointer( + child: AnimatedOpacity( + duration: _theme(context).controlsTransitionDuration, + opacity: showSwipeDuration ? 1 : 0, + child: _theme(context).seekIndicatorBuilder?.call(context, Duration(seconds: swipeDuration)) ?? + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 52.0, + width: 108.0, + child: Text( + swipeDuration > 0 ? "+ ${Duration(seconds: swipeDuration).label()}" : "- ${Duration(seconds: swipeDuration).label()}", + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + ), + + // Double-Tap Seek Button(s): + if (!mount || seekOnDoubleTapEnabledWhileControlsAreVisible) + if (_mountSeekBackwardButton || _mountSeekForwardButton) + Positioned.fill( + child: Row( + children: [ + Expanded( + flex: _theme(context).seekOnDoubleTapLayoutWidgetRatios[0], + child: _mountSeekBackwardButton + ? AnimatedOpacity( + opacity: _hideSeekBackwardButton ? 0 : 1.0, + duration: const Duration(milliseconds: 200), + child: _BackwardSeekIndicator( + duration: _theme(context).seekOnDoubleTapBackwardDuration, + onChanged: (value) { + _seekBarDeltaValueNotifier.value = -value; + }, + onSubmitted: (value) { + _timerSeekBackwardButton?.cancel(); + _timerSeekBackwardButton = Timer( + const Duration(milliseconds: 200), + () { + setState(() { + _hideSeekBackwardButton = false; + _mountSeekBackwardButton = false; + }); + }, + ); + + setState(() { + _hideSeekBackwardButton = true; + }); + var result = controller(context).player.state.position - value; + result = result.clamp( + Duration.zero, + controller(context).player.state.duration, + ); + controller(context).player.seek(result); + }, + ), + ) + : const SizedBox(), + ), + //Area in the middle where the double-tap seek buttons are ignored in + if (_theme(context).seekOnDoubleTapLayoutWidgetRatios[1] > 0) + Spacer( + flex: _theme(context).seekOnDoubleTapLayoutWidgetRatios[1], + ), + Expanded( + flex: _theme(context).seekOnDoubleTapLayoutWidgetRatios[2], + child: _mountSeekForwardButton + ? AnimatedOpacity( + opacity: _hideSeekForwardButton ? 0 : 1.0, + duration: const Duration(milliseconds: 200), + child: _ForwardSeekIndicator( + duration: _theme(context).seekOnDoubleTapForwardDuration, + onChanged: (value) { + _seekBarDeltaValueNotifier.value = value; + }, + onSubmitted: (value) { + _timerSeekForwardButton?.cancel(); + _timerSeekForwardButton = Timer(const Duration(milliseconds: 200), () { + if (_hideSeekForwardButton) { + setState(() { + _hideSeekForwardButton = false; + _mountSeekForwardButton = false; + }); + } + }); + setState(() { + _hideSeekForwardButton = true; + }); + + var result = controller(context).player.state.position + value; + result = result.clamp( + Duration.zero, + controller(context).player.state.duration, + ); + controller(context).player.seek(result); + }, + ), + ) + : const SizedBox(), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + double widgetWidth(BuildContext context) => (context.findRenderObject() as RenderBox).paintBounds.width; +} + +// SEEK BAR + +/// Material design seek bar. +class MaterialSeekBar extends StatefulWidget { + final ValueNotifier? delta; + final VoidCallback? onSeekStart; + final VoidCallback? onSeekEnd; + + const MaterialSeekBar({ + super.key, + this.delta, + this.onSeekStart, + this.onSeekEnd, + }); + + @override + MaterialSeekBarState createState() => MaterialSeekBarState(); +} + +class MaterialSeekBarState extends State { + bool tapped = false; + double slider = 0.0; + + late bool playing = controller(context).player.state.playing; + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + late Duration buffer = controller(context).player.state.buffer; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + void listener() { + setState(() { + final delta = widget.delta?.value ?? Duration.zero; + position = controller(context).player.state.position + delta; + }); + } + + @override + void initState() { + super.initState(); + widget.delta?.addListener(listener); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty && widget.delta == null) { + subscriptions.addAll( + [ + controller(context).player.stream.playing.listen((event) { + setState(() { + playing = event; + }); + }), + controller(context).player.stream.completed.listen((event) { + setState(() { + position = Duration.zero; + }); + }), + controller(context).player.stream.position.listen((event) { + setState(() { + if (!tapped) { + position = event; + } + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + controller(context).player.stream.buffer.listen((event) { + setState(() { + buffer = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + widget.delta?.removeListener(listener); + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + void onPointerMove(PointerMoveEvent e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + controller(context).player.seek(duration * slider); + } + + void onPointerDown() { + widget.onSeekStart?.call(); + setState(() { + tapped = true; + }); + } + + void onPointerUp() { + widget.onSeekEnd?.call(); + setState(() { + // Explicitly set the position to prevent the slider from jumping. + tapped = false; + position = duration * slider; + }); + controller(context).player.seek(duration * slider); + } + + void onPanStart(DragStartDetails e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onPanDown(DragDownDetails e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onPanUpdate(DragUpdateDetails e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + /// Returns the current playback position in percentage. + double get positionPercent { + if (position == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = position.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + /// Returns the current playback buffer position in percentage. + double get bufferPercent { + if (buffer == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = buffer.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.none, + margin: _theme(context).seekBarMargin, + child: LayoutBuilder( + builder: (context, constraints) => MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onHorizontalDragUpdate: (_) {}, + onPanStart: (e) => onPanStart(e, constraints), + onPanDown: (e) => onPanDown(e, constraints), + onPanUpdate: (e) => onPanUpdate(e, constraints), + child: Listener( + onPointerMove: (e) => onPointerMove(e, constraints), + onPointerDown: (e) => onPointerDown(), + onPointerUp: (e) => onPointerUp(), + child: Container( + color: Colors.transparent, + width: constraints.maxWidth, + alignment: _theme(context).seekBarAlignment, + height: _theme(context).seekBarContainerHeight, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomCenter, + children: [ + Container( + width: constraints.maxWidth, + height: _theme(context).seekBarHeight, + alignment: Alignment.bottomLeft, + color: _theme(context).seekBarColor, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomLeft, + children: [ + Container( + width: constraints.maxWidth * bufferPercent, + color: _theme(context).seekBarBufferColor, + ), + Container( + width: tapped ? constraints.maxWidth * slider : constraints.maxWidth * positionPercent, + color: _theme(context).seekBarPositionColor, + ), + ], + ), + ), + Positioned( + left: tapped + ? (constraints.maxWidth - _theme(context).seekBarThumbSize / 2) * slider + : (constraints.maxWidth - _theme(context).seekBarThumbSize / 2) * positionPercent, + bottom: -1.0 * _theme(context).seekBarThumbSize / 2 + _theme(context).seekBarHeight / 2, + child: Container( + width: _theme(context).seekBarThumbSize, + height: _theme(context).seekBarThumbSize, + decoration: BoxDecoration( + color: _theme(context).seekBarThumbColor, + borderRadius: BorderRadius.circular( + _theme(context).seekBarThumbSize / 2, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +// BUTTON: PLAY/PAUSE + +/// A material design play/pause button. +class MaterialPlayOrPauseButton extends StatefulWidget { + /// Overriden icon size for [MaterialSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialSkipPreviousButton]. + final Color? iconColor; + + const MaterialPlayOrPauseButton({ + super.key, + this.iconSize, + this.iconColor, + }); + + @override + MaterialPlayOrPauseButtonState createState() => MaterialPlayOrPauseButtonState(); +} + +class MaterialPlayOrPauseButtonState extends State with SingleTickerProviderStateMixin { + late final animation = AnimationController( + vsync: this, + value: controller(context).player.state.playing ? 1 : 0, + duration: const Duration(milliseconds: 200), + ); + + StreamSubscription? subscription; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= controller(context).player.stream.playing.listen((event) { + if (event) { + animation.forward(); + } else { + animation.reverse(); + } + }); + } + + @override + void dispose() { + animation.dispose(); + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: controller(context).player.playOrPause, + iconSize: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + icon: IgnorePointer( + child: AnimatedIcon( + progress: animation, + icon: AnimatedIcons.play_pause, + size: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + ), + ), + ); + } +} + +// BUTTON: SKIP NEXT + +/// Material design skip next button. +class MaterialSkipNextButton extends StatelessWidget { + /// Icon for [MaterialSkipNextButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialSkipNextButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialSkipNextButton]. + final Color? iconColor; + + const MaterialSkipNextButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipNextButton || + (controller(context).player.state.playlist.medias.length > 1 && _theme(context).automaticallyImplySkipNextButton)) { + return IconButton( + onPressed: controller(context).player.next, + icon: icon ?? const Icon(Icons.skip_next), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: SKIP PREVIOUS + +/// Material design skip previous button. +class MaterialSkipPreviousButton extends StatelessWidget { + /// Icon for [MaterialSkipPreviousButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialSkipPreviousButton]. + final Color? iconColor; + + const MaterialSkipPreviousButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipPreviousButton || + (controller(context).player.state.playlist.medias.length > 1 && _theme(context).automaticallyImplySkipPreviousButton)) { + return IconButton( + onPressed: controller(context).player.previous, + icon: icon ?? const Icon(Icons.skip_previous), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: FULL SCREEN + +/// Material design fullscreen button. +class MaterialFullscreenButton extends StatelessWidget { + /// Icon for [MaterialFullscreenButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialFullscreenButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialFullscreenButton]. + final Color? iconColor; + + const MaterialFullscreenButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => toggleFullscreen(context), + icon: icon ?? (isFullscreen(context) ? const Icon(Icons.fullscreen_exit) : const Icon(Icons.fullscreen)), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// BUTTON: CUSTOM + +/// Material design custom button. +class MaterialCustomButton extends StatelessWidget { + /// Icon for [MaterialCustomButton]. + final Widget? icon; + + /// Icon size for [MaterialCustomButton]. + final double? iconSize; + + /// Icon color for [MaterialCustomButton]. + final Color? iconColor; + + /// The callback that is called when the button is tapped or otherwise activated. + final VoidCallback onPressed; + + const MaterialCustomButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: icon ?? const Icon(Icons.settings), + padding: EdgeInsets.zero, + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// POSITION INDICATOR + +/// Material design position indicator. +class MaterialPositionIndicator extends StatefulWidget { + /// Overriden [TextStyle] for the [MaterialPositionIndicator]. + final TextStyle? style; + const MaterialPositionIndicator({super.key, this.style}); + + @override + MaterialPositionIndicatorState createState() => MaterialPositionIndicatorState(); +} + +class MaterialPositionIndicatorState extends State { + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.position.listen((event) { + setState(() { + position = event; + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text( + '${position.label(reference: duration)} / ${duration.label(reference: duration)}', + style: widget.style ?? + TextStyle( + height: 1.0, + fontSize: 12.0, + color: _theme(context).buttonBarButtonColor, + ), + ); + } +} + +class _BackwardSeekIndicator extends StatefulWidget { + final Duration duration; + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const _BackwardSeekIndicator({ + super.key, + required this.duration, + required this.onChanged, + required this.onSubmitted, + }); + + @override + State<_BackwardSeekIndicator> createState() => _BackwardSeekIndicatorState(); +} + +class _BackwardSeekIndicatorState extends State<_BackwardSeekIndicator> { + late Duration value = widget.duration; + + Timer? timer; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x88767676), + Color(0x00767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_rewind, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '${value.inSeconds} seconds', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ForwardSeekIndicator extends StatefulWidget { + final Duration duration; + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const _ForwardSeekIndicator({ + super.key, + required this.duration, + required this.onChanged, + required this.onSubmitted, + }); + + @override + State<_ForwardSeekIndicator> createState() => _ForwardSeekIndicatorState(); +} + +class _ForwardSeekIndicatorState extends State<_ForwardSeekIndicator> { + late Duration value = widget.duration; + + Timer? timer; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x00767676), + Color(0x88767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_forward, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '${value.inSeconds} seconds', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/controller/shop_index_controller.dart b/lib/controller/shop_index_controller.dart new file mode 100644 index 0000000..17667ce --- /dev/null +++ b/lib/controller/shop_index_controller.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/api/shop_api.dart'; +import 'package:loopin/service/http.dart'; + +/// 单个 Tab 的状态 +class TabState { + final ScrollController scrollController; + final RxInt currentPage; + final RxDouble scrollOffset; + final RxList dataList; + final RxBool isLoading; + final RxBool hasLoaded; + + TabState({ + required this.scrollController, + required this.currentPage, + required this.scrollOffset, + required this.dataList, + required this.isLoading, + required this.hasLoaded, + }); +} + +class ShopIndexController extends GetxController with GetSingleTickerProviderStateMixin { + TabController? tabController; + + ///轮播图数据 + RxList swiperData = [].obs; + + /// tab 分类列表 + RxList tabList = [].obs; + + /// 每个 tab 对应的状态 + final Map tabs = {}; + + /// 当前 tab index + RxInt currentTabIndex = 0.obs; + + /// 初始化 Tab 分类 + void initTabs() async { + // 释放旧的 ScrollController + tabs.forEach((_, state) => state.scrollController.dispose()); + tabs.clear(); + tabList.clear(); + // 先释放旧 TabController(并移除监听) + tabController?.removeListener(_tabListener); + tabController?.dispose(); + + // 赋值 tab 数据 + final res = await Http.post(ShopApi.shopCategory, data: { + 'showStatus': 1, + }); + final data = res['data']['records'] as List; + logger.w(data); + tabList.addAll(data); + + // 初始化每个 tab 的状态 + for (int i = 0; i < tabList.length; i++) { + final controller = ScrollController(); + tabs[i] = TabState( + scrollController: controller, + currentPage: 1.obs, + scrollOffset: 0.0.obs, + dataList: [].obs, + isLoading: false.obs, + hasLoaded: false.obs, + ); + } + + // 创建新的 TabController + tabController = TabController(length: tabList.length, vsync: this); + tabController!.addListener(_tabListener); + // 初始化第一个 tab 的数据 + if (tabList.isNotEmpty) { + loadSwiperData(); + loadData(0); + } + } + + /// Tab 切换监听 + void _tabListener() { + if (!tabController!.indexIsChanging) { + currentTabIndex.value = tabController!.index; + + final tab = tabs[currentTabIndex.value]; + if (tab != null && !tab.hasLoaded.value) { + loadData(currentTabIndex.value); + } + } + } + + Future refreshData(int index) async { + await loadSwiperData(); + final tab = tabs[index]; + if (tab == null) return; + + tab.currentPage.value = 1; + tab.dataList.clear(); + tab.isLoading.value = false; + tab.hasLoaded.value = false; + + await loadData(index); + } + + /// 加载pageview数据 + Future loadData(int index) async { + final tab = tabs[index]; + if (tab == null || tab.isLoading.value) return; + + tab.isLoading.value = true; + final res = await Http.post(ShopApi.shopList, data: { + 'size': 10, + 'current': tab.currentPage.value, + 'categoryId': tabList[index]['id'], + }); + + final data = res['data']['records']; + tab.dataList.addAll(data); + logger.w(res); + + tab.currentPage.value += 1; + tab.isLoading.value = false; + tab.hasLoaded.value = true; + } + + /// 加载swiper数据 + Future loadSwiperData() async { + final res = await Http.post(ShopApi.shopSwiperList, data: { + 'type': 1, + }); + final data = res['data']; + logger.w(res); + swiperData.assignAll(data); + } + + @override + void onClose() { + tabController?.removeListener(_tabListener); + tabController?.dispose(); + tabs.forEach((_, state) => state.scrollController.dispose()); + super.onClose(); + } +} diff --git a/lib/layouts/index.dart b/lib/layouts/index.dart index 0d00390..17cff8a 100644 --- a/lib/layouts/index.dart +++ b/lib/layouts/index.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/controller/tab_bar_controller.dart'; -import 'package:loopin/IM/im_service.dart'; import 'package:loopin/models/tab_type.dart'; import 'package:loopin/pages/video/module/recommend.dart'; import 'package:loopin/update/upgrade_service.dart'; @@ -38,7 +37,7 @@ class _LayoutState extends State { // tabs选项 List navItems = [ BottomNavigationBarItem(icon: Icon(Icons.play_circle_outline), label: '视频'), - BottomNavigationBarItem(icon: Icon(Icons.local_mall), label: '团购'), + BottomNavigationBarItem(icon: Icon(Icons.local_mall), label: '易选'), BottomNavigationBarItem( icon: Icon( Icons.camera_alt_rounded, @@ -79,7 +78,7 @@ class _LayoutState extends State { super.initState(); // 页面初始化后检查版本更新 WidgetsBinding.instance.addPostFrameCallback((_) { - UpgradeService.checkUpgrade(context); + UpgradeService.checkUpgrade(this); }); } @@ -209,7 +208,7 @@ class _LayoutState extends State { // 点击底部导航 void onTabTap(int index) { - logger.i(index); + // logger.i(index); if (index == 0) { if (videoModuleController.videoTabIndex.value == 2) { RecommendModule.playVideo(); @@ -223,7 +222,10 @@ class _LayoutState extends State { } if (index == 3) { // 更新会话列表 - Get.find().getConversationList(); + final ctl = Get.find(); + if (ctl.chatList.isEmpty) { + Get.find().getConversationList(); + } } if (index == 4) { myPageKey.currentState?.refreshData(); diff --git a/lib/main.dart b/lib/main.dart index 15593e0..4d8de78 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,6 +28,7 @@ void main() async { Get.put(TabBarController()); // 注入会话列表 Get.put(ChatController()); + // 监听app前后台状态 WidgetsFlutterBinding.ensureInitialized(); WidgetsBinding.instance.addObserver(LifecycleHandler()); @@ -52,13 +53,23 @@ void main() async { // 检测登录状态 if (Common.isLogin()) { // 初始化Im,并进行Im登录 - await im_core.ImCore.init(sdkAppId: 1600080789); - // 缺少userSig先用固定值ios用1587,安卓用188 - await ImService.instance.login(userID: Storage.read('userId'), userSig: Storage.read('userSig')); - // String userId = '1909990634551795712'; //15877777777 - // String userId = '18832510385'; + final res = await im_core.ImCore.init(sdkAppId: 1600080789); + + // 缺少userSig先用固定值ios用9365,安卓用0385 + try { + if (res) { + await ImService.instance.login(userID: Storage.read('userId'), userSig: Storage.read('userSig')); + } else { + logger.w('初始化未完成'); + } + } catch (e) { + logger.w(e.toString()); + Common.logout(); + } + // String userId = '1940667704585248769'; //13212279365 + // String userId = '1943510443312078850'; //18832510385 // String userSig = - // 'eJwtzcsOgjAQBdB-6dqQKThth8QdG*JrIRHjTqGYiagNDzUx-rsVWN5zJ3M-IlvtgqdtRCzCAMRsyFzae8cVDywJiAhUNEeUmlDLcDpry*vJOS5FLBUAGNCGxsa*HTfWOyKGvhq149vftH8DhHLSli9*Jdrk2hqTVgdK2dKxXSfKbYuXpnPSu1zl*0fd18skg2Ihvj*7ADL4'; + // 'eJwtjcEKgkAURf9l1iFPm*e8EdoYYUWFURAtg5nk5VRiEln0703q8p57Ofcj9qtd8LS1SEQUgBh1mY29NXzmDodaQhwrBRIJI0kq1sPsYcpTVbERSRgDAIEi3Tf2VXFtPUfEyFc9bfj6ZwrH4J1Ig4UL-6LX0ihyS7U5bi-Wzd8LzrK8TFs6TJ1sZwWGxlGas71PxPcHwH4y9Q__'; // 'eJwtzLEKwjAUheF3ySwlNzXNbcHFxSIOaqTWUUgsF1FDG2tEfHdj2-F8P5wPO2x00tuWFUwknM2GTcbePV1oYEBMhQSeopxyZ65n58iwAjLOOXKF*VhscNTa6FJKEdOonm5-UxJQpZhN2lET3599Xllbv9ZBH2uHuDfvst5tG6FX0EFYVhpOpZ973z8W7PsDmYwyIw__'; // await ImService.instance.login(userID: userId, userSig: userSig); } diff --git a/lib/models/conversation_type.dart b/lib/models/conversation_type.dart new file mode 100644 index 0000000..e8e286a --- /dev/null +++ b/lib/models/conversation_type.dart @@ -0,0 +1,48 @@ +/// 枚举定义:所有的会话类型分组,用于一级消息分类 +enum ConversationType { + noFriend, // 陌生人消息 + system, //系统消息 + newFoucs, //新的关注 + interaction, //互动 + order, //订单类通知消息 + groupNotify, //群通知 +} + +extension ConversationTypeExtension on ConversationType { + String get name { + switch (this) { + case ConversationType.noFriend: + return 'noFriend'; + case ConversationType.system: + return 'system'; + case ConversationType.newFoucs: + return 'newFoucs'; + case ConversationType.interaction: + return 'interaction'; + case ConversationType.order: + return 'order'; + case ConversationType.groupNotify: + return 'groupNotify'; + } + } +} + +conversationTypeFromString(String? type) { + if (type == null) return null; + + if (type.contains('noFriend')) { + return ConversationType.noFriend.name; + } else if (type.contains('system')) { + return ConversationType.system.name; + } else if (type.contains('newFoucs')) { + return ConversationType.newFoucs.name; + } else if (type.contains('interaction')) { + return ConversationType.interaction.name; + } else if (type.contains('order')) { + return ConversationType.order.name; + } else if (type.contains('groupNotify')) { + return ConversationType.groupNotify.name; + } + + return null; +} diff --git a/lib/models/conversation_view_model.dart b/lib/models/conversation_view_model.dart index e420584..70f26c6 100644 --- a/lib/models/conversation_view_model.dart +++ b/lib/models/conversation_view_model.dart @@ -1,9 +1,9 @@ import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; class ConversationViewModel { - final V2TimConversation conversation; - final String? faceUrl; - final String isCustomAdmin; + late V2TimConversation conversation; + String? faceUrl; + String? isCustomAdmin; ConversationViewModel({ required this.conversation, diff --git a/lib/models/notify_message.type.dart b/lib/models/notify_message.type.dart new file mode 100644 index 0000000..97ebfde --- /dev/null +++ b/lib/models/notify_message.type.dart @@ -0,0 +1,97 @@ +/// 枚举定义:所有通知的二级类型 +enum NotifyMessageType { + newFoucs, //新的关注 + systemNotify, // 系统->通知 + systemReport, // 系统->举报下架(视频,视频评论) + systemCheck, // 系统->审核结果(复审,驳回 ,通过) + systemPush, //系统->推广 + interactionComment, //互动->评论 + interactionAt, //互动->视频评论中的@ + interactionLike, //互动->点赞 + interactionReply, //互动->评论回复 + orderRecharge, //订单->充值 online + orderPay, //订单->订单交易成功通知 online + orderRefund, //订单->退款结果通知 + groupNotifyCheck, //群通知->进群申请 online + groupNotifyAccpet, // 群通知->进群审核审核通过 online + groupNotifyFail, // 群通知->进群审核审核拒绝 online + groupNotifyLeaveUp, // 群通知->群升级为达人群通知 +} + +extension NotifyMessageTypeExtension on NotifyMessageType { + String get name { + switch (this) { + case NotifyMessageType.newFoucs: + return 'newFoucs'; + case NotifyMessageType.systemNotify: + return 'systemNotify'; + case NotifyMessageType.systemReport: + return 'systemReport'; + case NotifyMessageType.systemCheck: + return 'systemCheck'; + case NotifyMessageType.systemPush: + return 'systemPush'; + case NotifyMessageType.interactionComment: + return 'interactionComment'; + case NotifyMessageType.interactionAt: + return 'interactionAt'; + case NotifyMessageType.interactionLike: + return 'interactionLike'; + case NotifyMessageType.interactionReply: + return 'interactionReply'; + case NotifyMessageType.orderRecharge: + return 'orderRecharge'; + case NotifyMessageType.orderPay: + return 'orderPay'; + case NotifyMessageType.orderRefund: + return 'orderRefund'; + case NotifyMessageType.groupNotifyCheck: + return 'groupNotifyCheck'; + case NotifyMessageType.groupNotifyAccpet: + return 'groupNotifyAccpet'; + case NotifyMessageType.groupNotifyFail: + return 'groupNotifyFail'; + case NotifyMessageType.groupNotifyLeaveUp: + return 'groupNotifyLeaveUp'; + } + } +} + +notifyMessageTypeFromString(String? type) { + switch (type) { + case 'newFoucs': + return NotifyMessageType.newFoucs.name; + case 'systemNotify': + return NotifyMessageType.systemNotify.name; + case 'systemReport': + return NotifyMessageType.systemReport.name; + case 'systemCheck': + return NotifyMessageType.systemCheck.name; + case 'systemPush': + return NotifyMessageType.systemPush.name; + case 'interactionComment': + return NotifyMessageType.interactionComment.name; + case 'interactionAt': + return NotifyMessageType.interactionAt.name; + case 'interactionLike': + return NotifyMessageType.interactionLike.name; + case 'interactionReply': + return NotifyMessageType.interactionReply.name; + case 'orderRecharge': + return NotifyMessageType.orderRecharge.name; + case 'orderPay': + return NotifyMessageType.orderPay.name; + case 'orderRefund': + return NotifyMessageType.orderRefund.name; + case 'groupNotifyCheck': + return NotifyMessageType.groupNotifyCheck.name; + case 'groupNotifyAccpet': + return NotifyMessageType.groupNotifyAccpet.name; + case 'groupNotifyFail': + return NotifyMessageType.groupNotifyFail.name; + case 'groupNotifyLeaveUp': + return NotifyMessageType.groupNotifyLeaveUp.name; + default: + return null; + } +} diff --git a/lib/models/summary_type.dart b/lib/models/summary_type.dart new file mode 100644 index 0000000..15db9dc --- /dev/null +++ b/lib/models/summary_type.dart @@ -0,0 +1,6 @@ +/// 枚举定义:自定义消息标签类型 +class SummaryType { + static const hongbao = 'hongbao'; + static const shareVideo = 'shareVideo'; + static const shareTuangou = 'shareTuangou'; +} diff --git a/lib/pages/auth/login.dart b/lib/pages/auth/login.dart index 5fa1c91..19dafc6 100644 --- a/lib/pages/auth/login.dart +++ b/lib/pages/auth/login.dart @@ -74,10 +74,10 @@ class _LoginState extends State { // 初始化im_sdk await im_core.ImCore.init(sdkAppId: 1600080789); - // String userId = '1909990634551795712'; //15877777777 - // String userId = '18832510385'; + // String userId = '1940667704585248769'; //13212279365 + // String userId = '1943510443312078850'; //18832510385 // String userSig = - // 'eJwtzcsOgjAQBdB-6dqQKThth8QdG*JrIRHjTqGYiagNDzUx-rsVWN5zJ3M-IlvtgqdtRCzCAMRsyFzae8cVDywJiAhUNEeUmlDLcDpry*vJOS5FLBUAGNCGxsa*HTfWOyKGvhq149vftH8DhHLSli9*Jdrk2hqTVgdK2dKxXSfKbYuXpnPSu1zl*0fd18skg2Ihvj*7ADL4'; + // 'eJwtjcEKgkAURf9l1iFPm*e8EdoYYUWFURAtg5nk5VRiEln0703q8p57Ofcj9qtd8LS1SEQUgBh1mY29NXzmDodaQhwrBRIJI0kq1sPsYcpTVbERSRgDAIEi3Tf2VXFtPUfEyFc9bfj6ZwrH4J1Ig4UL-6LX0ihyS7U5bi-Wzd8LzrK8TFs6TJ1sZwWGxlGas71PxPcHwH4y9Q__'; // 'eJwtzLEKwjAUheF3ySwlNzXNbcHFxSIOaqTWUUgsF1FDG2tEfHdj2-F8P5wPO2x00tuWFUwknM2GTcbePV1oYEBMhQSeopxyZ65n58iwAjLOOXKF*VhscNTa6FJKEdOonm5-UxJQpZhN2lET3599Xllbv9ZBH2uHuDfvst5tG6FX0EFYVhpOpZ973z8W7PsDmYwyIw__'; try { @@ -87,13 +87,17 @@ class _LoginState extends State { if (loginRes.success) { // 存储登录信息 Storage.write('hasLogged', true); - // Storage.write('userSig', userSig); + Storage.write('userSig', userSig); Storage.write('userId', userId); - // Storage.write('token', obj['access_token']); + Storage.write('token', obj['access_token']); + // 获取用户账户信息 + final accountRes = await Http.get('${CommonApi.accountInfo}/$userId'); + logger.i(accountRes); // 刷新短视频列表 final videoController = Get.find(); videoController.markNeedRefresh(); dialogController.close(); + Get.back(); } } catch (e) { @@ -126,7 +130,7 @@ class _LoginState extends State { vcodeText = '获取验证码(${time--})'; } else { vcodeText = '获取验证码'; - time = 6; + time = 60; disabled = false; timer.cancel(); } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 7a9658d..3f3839a 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -1,18 +1,26 @@ /// 聊天模板 library; +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/controller/chat_detail_controller.dart'; import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_result.dart'; import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; +import 'package:loopin/components/preview_video.dart'; +import 'package:loopin/models/summary_type.dart'; +import 'package:loopin/utils/audio_player_service.dart'; +import 'package:loopin/utils/snapshot.dart'; +import 'package:loopin/utils/voice_service.dart'; import 'package:shirne_dialog/shirne_dialog.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; -import '../../behavior/custom_scroll_behavior.dart'; -import '../../components/image_group.dart'; import '../../styles/index.dart'; import '../../utils/index.dart'; import './components/redpacket.dart'; @@ -30,18 +38,16 @@ class Chat extends StatefulWidget { class _ChatState extends State with SingleTickerProviderStateMixin { late final ChatDetailController controller; // 接收参数 - V2TimConversation arguments = Get.arguments; + late final Rx arguments; late String selfUserId; // 聊天消息模块 final bool isNeedScrollBottom = true; - // final RxList chatList = [].obs; - bool isLoading = false; // 是否在加载中 bool hasMore = true; // 是否还有更多数据 - bool _throttleFlag = false; // 滚动节流锁 + final RxBool _throttleFlag = false.obs; // 滚动节流锁 // 表情json List emoJson = emotionData; @@ -70,7 +76,9 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ]; // controller监听 - ScrollController chatController = ScrollController(); + // ScrollController chatController = ScrollController(); + late ScrollController chatController; + ScrollController emojController = ScrollController(); // 模拟开红包按钮动画 @@ -83,7 +91,10 @@ class _ChatState extends State with SingleTickerProviderStateMixin { @override void initState() { super.initState(); - controller = Get.put(ChatDetailController(userID: arguments.userID ?? '')); + final arg = Get.arguments as V2TimConversation; + arguments = arg.obs; + controller = Get.find(); + chatController = controller.chatController; animController = AnimationController( vsync: this, @@ -103,18 +114,22 @@ class _ChatState extends State with SingleTickerProviderStateMixin { setState(() { toolbarEnable = false; }); - scrollToBottom(); + controller.scrollToBottom(); } }); // 滚动监听 + // Future.delayed(Duration(milliseconds: 1000), () { + + // }); chatController.addListener(() { - if (_throttleFlag) return; + if (_throttleFlag.value) return; if (chatController.position.pixels >= chatController.position.maxScrollExtent - 50) { - _throttleFlag = true; + // if (chatController.position.pixels <= 50) { + _throttleFlag.value = true; getMsgData().then((_) { // 解锁 - Future.delayed(Duration(milliseconds: 300), () { - _throttleFlag = false; + Future.delayed(Duration(milliseconds: 1000), () { + _throttleFlag.value = false; }); }); } @@ -126,16 +141,67 @@ class _ChatState extends State with SingleTickerProviderStateMixin { if (Get.isRegistered()) { Get.delete(); } - chatController.dispose(); emojController.dispose(); editorFocusNode.dispose(); animController.dispose(); super.dispose(); } + // 设置好友备注 + void setRemark() async { + String remark = ''; + await MyDialog.confirm( + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + TextField( + onChanged: (value) => remark = value, + maxLength: 16, + maxLengthEnforcement: MaxLengthEnforcement.enforced, // 强制不能输入超过 + decoration: InputDecoration( + hintText: '请输入备注', + filled: true, + fillColor: const Color(0xFFF5F5F5), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Color(0xFFBE4EFF), width: 1), + ), + ), + ) + ], + ), + title: '设置备注', + buttonText: '确认', + cancelText: '取消', + onConfirm: () async { + // print('备注为:$remark'); + final res = await ImService.instance.setFriendInfo(userID: arguments.value.userID!, friendRemark: remark); + if (res.success) { + // 刷新会话列表数据 + // Get.find().getConversationList(); + arguments.update((val) { + val?.showName = remark; + }); + } else { + print(res.desc); + print(arguments.value.userID); + MyDialog.toast(res.desc, icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + return true; + }, + ); + } + void cleanUnRead() async { - if ((arguments.unreadCount ?? 0) > 0) { - final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.conversationID); + if ((arguments.value.unreadCount ?? 0) > 0) { + final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.value.conversationID); if (!res.success) { MyDialog.toast(res.desc, icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); } @@ -156,18 +222,20 @@ class _ChatState extends State with SingleTickerProviderStateMixin { // 获取最旧一条消息作为游标 V2TimMessage? lastRealMsg; + // for (var msg in controller.chatList.reversed) { for (var msg in controller.chatList.reversed) { if (msg.localCustomData != 'time_label') { lastRealMsg = msg; break; } } - final lastMsg = lastRealMsg ?? arguments.lastMessage; // 如果找不到,就用传入的参数 + final lastMsg = lastRealMsg ?? arguments.value.lastMessage; // 如果找不到,就用传入的参数 + print(lastMsg?.toLogString()); // final lastMsg = controller.chatList.isNotEmpty ? controller.chatList.last : arguments.lastMessage; final res = await ImService.instance.getHistoryMessageList( - userID: arguments.userID, + userID: arguments.value.userID, lastMsg: lastMsg, ); @@ -176,14 +244,26 @@ class _ChatState extends State with SingleTickerProviderStateMixin { if (newMessages.isEmpty) { hasMore = false; - MyDialog.toast('没有更多了~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); - } else { - if (initFlag) { - newMessages.insert(0, lastMsg!); - } - controller.updateChatListWithTimeLabels(newMessages); - print('聊天数据加载成功'); + // MyDialog.toast('没有更多了~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); } + if (initFlag && lastMsg != null) { + newMessages.insert(0, lastMsg); + // controller.scrollToBottom(); + } + controller.updateChatListWithTimeLabels(newMessages); + if (initFlag) { + // 初始化时滚到最底部 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (chatController.hasClients) { + // controller.scrollToBottom(); + // final bottomPadding = MediaQuery.of(context).padding.bottom; // 底部安全区域高度 + // chatController.jumpTo(chatController.position.maxScrollExtent); // 60为底部操作栏高度 + chatController.jumpTo(0); + } + }); + } + + print('聊天数据加载成功'); } else { MyDialog.toast("获取聊天记录失败:${res.desc}", icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); } @@ -210,31 +290,33 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 文本消息模板 + // 文本消息模板=1 else if (item.elemType == 1) { - msgtpl.add(RenderChatItem( - data: item, - child: Ink( - decoration: BoxDecoration( - color: !(item.isSelf ?? false) ? Color(0xFFFFFFFF) : Color(0xFF89E45B), - borderRadius: BorderRadius.circular(10.0), - ), - child: InkWell( - overlayColor: WidgetStateProperty.all(Colors.transparent), - borderRadius: BorderRadius.circular(10.0), - child: Container( - padding: const EdgeInsets.all(10.0), - child: RichTextUtil.getRichText(item.textElem?.text ?? '', color: !(item.isSelf ?? false) ? Colors.black : Colors.white), // 可自定义解析emoj/网址/电话 + msgtpl.add( + RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? Color(0xFFFFFFFF) : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + child: RichTextUtil.getRichText(item.textElem?.text ?? '', color: !(item.isSelf ?? false) ? Colors.black : Colors.white), // 可自定义解析emoj/网址/电话 + ), + onLongPress: () { + contextMenuDialog(); + }, ), - onLongPress: () { - contextMenuDialog(); - }, ), ), - )); + ); } - // gif表情模板 - else if (item.elemType == 4) { + // gif表情模板=8 + else if (item.elemType == 8) { msgtpl.add(RenderChatItem( data: item, child: Ink( @@ -245,7 +327,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { maxHeight: 100.0, maxWidth: 100.0, ), - child: Image.asset('assets/images/emotion/${item.customElem?.data}'), + // child: Image.asset('assets/images/emotion/${item.faceElem?.data}'), + child: Image.asset('${item.faceElem?.data}'), ), onLongPress: () { contextMenuDialog(); @@ -254,9 +337,11 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 图片模板 - else if (item.elemType == 5) { - List imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? []; + // 图片模板=3 + else if (item.elemType == 3) { + // List imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? []; + final originImage = item.imageElem?.imageList?.firstWhere((e) => e?.type == 0 && e?.url != null, orElse: () => null); + List imagePaths = originImage != null ? [originImage.url!] : []; msgtpl.add(RenderChatItem( data: item, child: Ink( @@ -264,9 +349,36 @@ class _ChatState extends State with SingleTickerProviderStateMixin { overlayColor: WidgetStateProperty.all(Colors.transparent), child: ClipRRect( borderRadius: BorderRadius.circular(10.0), - child: ImageGroup( - images: imagePaths, + // child: ImageGroup( + // images: imagePaths, + // width: 120, + // ), + child: Image.network( + imagePaths.first, width: 120, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + // controller.scrollToBottom(); + return child; // 加载完成,显示图片 + } + return Container( + width: 120, + height: 240, + color: Colors.grey[300], + alignment: Alignment.center, + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[300], + alignment: Alignment.center, + child: Icon(Icons.broken_image, color: Colors.grey, size: 40), + ); + }, ), ), onLongPress: () { @@ -276,22 +388,24 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 视频模板 - else if (item.elemType == 6) { + // 视频模板=5 + else if (item.elemType == 5) { + // print(item.videoElem!.toLogString()); msgtpl.add(RenderChatItem( data: item, child: Ink( child: InkWell( overlayColor: WidgetStateProperty.all(Colors.transparent), child: SizedBox( - width: 90.0, + width: 120.0, child: Stack( alignment: Alignment.center, children: [ ClipRRect( borderRadius: BorderRadius.circular(10.0), - child: Image.network( - item.videoElem?.videoUrl ?? '', + child: NetworkOrAssetImage( + imageUrl: item.videoElem?.snapshotUrl ?? '', + width: 120, ), ), const Align( @@ -306,7 +420,24 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), ), onTap: () { - MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + showGeneralDialog( + context: context, + // barrierDismissible: true, + barrierColor: Colors.black.withAlpha((1.0 * 255).round()), + pageBuilder: (_, __, ___) { + return SafeArea( + child: PreviewVideo( + videoUrl: item.videoElem?.videoUrl ?? '', + width: item.videoElem?.snapshotWidth?.toDouble(), + height: item.videoElem?.snapshotHeight?.toDouble(), + ), + ); + }, + transitionBuilder: (_, anim, __, child) { + return FadeTransition(opacity: anim, child: child); + }, + transitionDuration: const Duration(milliseconds: 200), + ); }, onLongPress: () { contextMenuDialog(); @@ -315,8 +446,12 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 语音模板 - else if (item.elemType == 7) { + // 语音模板=4 + else if (item.elemType == 4) { + final durationMs = item.soundElem?.duration ?? 0; + final durationSeconds = (durationMs / 1000).round(); + final maxWidth = (durationSeconds / 60 * 230).clamp(80.0, 230.0); + List audiobody = [ Ink( decoration: BoxDecoration( @@ -329,8 +464,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { child: Container( padding: const EdgeInsets.all(10.0), constraints: BoxConstraints( - // maxWidth: 120.0, - maxWidth: (item.soundElem?.duration)! / 60 * 230, + maxWidth: maxWidth, + // maxWidth: (item.soundElem!.duration! / 1000) / 60 * 230, ), child: Row( mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, @@ -340,10 +475,10 @@ class _ChatState extends State with SingleTickerProviderStateMixin { const SizedBox( width: 5.0, ), - Text('${item.soundElem?.duration}'), + Text('$durationSeconds"'), ] : [ - Text('${item.soundElem?.duration}'), + Text('$durationSeconds"'), const SizedBox( width: 5.0, ), @@ -352,7 +487,15 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), ), onTap: () { - MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + final locUrl = item.soundElem?.path ?? ''; + final netUrl = item.soundElem?.url ?? ''; + if (locUrl.isNotEmpty) { + AudioPlayerService().playNetwork(locUrl); + } else if (netUrl.isNotEmpty) { + AudioPlayerService().playLocal(netUrl); + } else { + MyDialog.toast('音频文件已过期', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } }, onLongPress: () { contextMenuDialog(); @@ -362,7 +505,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { const SizedBox( width: 5.0, ), - FStyle.badge(0, isdot: true), + + // FStyle.badge(0, isdot: true), ]; if (item.isSelf ?? false) { @@ -379,8 +523,157 @@ class _ChatState extends State with SingleTickerProviderStateMixin { children: audiobody, ))); } - // 红包模板 - else if (item.elemType == 0 && item.customElem?.desc == 'hongbao') { + // 分享团购商品 + else if (item.elemType == 2 && item.cloudCustomData == SummaryType.shareTuangou) { + //price,title,url,sell + final obj = jsonDecode(item.customElem!.data!); + final url = obj['url']; + final title = obj['title']; + final price = obj['price']; + final sell = Utils().graceNumber(int.tryParse(obj['sell'])!); + msgtpl.add(RenderChatItem( + data: item, + child: GestureDetector( + child: Container( + width: 160, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15.0), boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(5), + offset: Offset(0.0, 1.0), + blurRadius: 1.0, + spreadRadius: 0.0, + ), + ]), + child: Column( + children: [ + NetworkOrAssetImage( + imageUrl: url, + width: 160.0, + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5.0, + children: [ + Text( + '$title', + style: TextStyle(fontSize: 14.0, height: 1.2), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text.rich( + overflow: TextOverflow.ellipsis, + maxLines: 1, + TextSpan(style: TextStyle(color: Colors.red, fontSize: 12.0, fontWeight: FontWeight.w700, fontFamily: 'Arial'), children: [ + TextSpan(text: '¥'), + TextSpan( + text: '$price', + style: TextStyle( + fontSize: 14.0, + )), + ]), + ), + ), + SizedBox( + width: 5, + ), + Text( + '已售$sell件', + style: TextStyle(color: Colors.grey, fontSize: 10.0), + ), + ], + ), + ], + ), + ) + ], + ), + ), + onTap: () { + // 这里带上分享人的ID + Get.toNamed('/goods'); + }, + ), + )); + } + // 分享短视频 + else if (item.elemType == 2 && item.cloudCustomData == SummaryType.shareVideo) { + /// {imgUrl,videoUrl,width,height} + final obj = jsonDecode(item.customElem!.data!); + final videoUrl = obj['videoUrl']; + final imgUrl = obj['imgUrl']; + final width = obj['width'] as num; + final height = obj['height'] as num; + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: SizedBox( + width: 120.0, + child: Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: NetworkOrAssetImage( + imageUrl: imgUrl, + width: 120, + ), + ), + const Align( + alignment: Alignment.center, + child: Icon( + Icons.play_circle, + color: Colors.white, + size: 30.0, + ), + ), + ], + ), + ), + onTap: () { + showGeneralDialog( + context: context, + barrierColor: Colors.black.withAlpha((1.0 * 255).round()), + pageBuilder: (_, __, ___) { + return SafeArea( + bottom: true, + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: PreviewVideo( + videoUrl: videoUrl, + width: width.toDouble(), + height: height.toDouble(), + ), + ), + ); + }, + transitionBuilder: (_, anim, __, child) { + return FadeTransition(opacity: anim, child: child); + }, + transitionDuration: const Duration(milliseconds: 200), + ); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 红包模板=自定义=2; + else if (item.elemType == 2 && item.cloudCustomData == SummaryType.hongbao) { + final obj = jsonDecode(item.customElem!.data!); + final open = obj['open'] ?? false; + final remark = obj['remark']; + // final maxNum = obj['maxNum']; msgtpl.add(RenderChatItem( data: item, child: Ink( @@ -399,16 +692,26 @@ class _ChatState extends State with SingleTickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( + width: 210.0, padding: const EdgeInsets.all(10.0), child: Row( - spacing: 10.0, children: [ - Image.asset( - 'assets/images/hbico.png', - width: 32.0, - fit: BoxFit.contain, + open + ? Icon(Icons.check_circle, size: 32.0, color: Colors.white70) + : Image.asset( + 'assets/images/hbico.png', + width: 32.0, + fit: BoxFit.contain, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '$remark', + style: const TextStyle(color: Colors.white, fontSize: 14.0), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - Text(item.customElem?.data ?? '', style: const TextStyle(color: Colors.white, fontSize: 14.0)), ], ), ), @@ -418,7 +721,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { width: double.infinity, decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.white30, width: .5))), child: const Text( - '拼手气红包', + '红包', style: TextStyle(color: Colors.white70, fontSize: 11.0), ), ), @@ -435,8 +738,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 位置模板 - else if (item.elemType == 9) { + // 位置模板=7 + else if (item.elemType == 7) { msgtpl.add(RenderChatItem( data: item, child: Ink( @@ -583,7 +886,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { child: Image.asset(emoj), ), onTap: () { - handleGIFClick(emoj); + handleGIFClick(emoj, item['index']); }, ), ); @@ -663,17 +966,19 @@ class _ChatState extends State with SingleTickerProviderStateMixin { // chatController.animateTo(isNeedScrollBottom ? 0 : chatController.position.maxScrollExtent, // duration: const Duration(milliseconds: 200), curve: Curves.easeIn); // } - void scrollToBottom() { - Future.delayed(Duration(milliseconds: 100), () { - if (chatController.hasClients) { - chatController.animateTo( - 0, // reverse: true 时滚动到底部是 offset: 0 - duration: Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - }); - } + + // void scrollToBottom() { + // Future.delayed(Duration(milliseconds: 300), () { + // if (chatController.hasClients) { + // chatController.animateTo( + // // 0, // reverse: true 时滚动到底部是 offset: 0 + // chatController.position.maxScrollExtent, + // duration: Duration(milliseconds: 300), + // curve: Curves.easeOut, + // ); + // } + // }); + // } // 点击消息区域 void handleClickChatArea() { @@ -745,20 +1050,22 @@ class _ChatState extends State with SingleTickerProviderStateMixin { } // 不需要时间标签 - final res = await IMMessage().sendText( - text: message['content'], - toUserID: arguments.userID, + // 消息类型 + late final ImResult res; + res = await IMMessage().sendMessage( + msg: message, + toUserID: arguments.value.userID, ); if (res.success && res.data != null) { messagesToInsert.insert(0, res.data); // 加入消息本体 + // messagesToInsert.add(res.data); // 加入消息本体 controller.chatList.insertAll(0, messagesToInsert); + // controller.chatList.addAll(messagesToInsert); - scrollToBottom(); + controller.scrollToBottom(); print('发送成功'); - // 更新会话列表数据 - Get.find().getConversationList(); } else { print('消息发送失败: ${res.code} - ${res.desc}'); } @@ -783,7 +1090,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { toolbarIndex = index; voiceBtnEnable = false; }); - scrollToBottom(); + controller.scrollToBottom(); } // 表情Tab切换 @@ -799,51 +1106,107 @@ class _ChatState extends State with SingleTickerProviderStateMixin { emojController.jumpTo(0); } - // 点击表情 + // 点击表情插入到输入框 void handleEmojClick(emoj) { insertTextAtCursor(emoj); } - // 点击Gif大图 - void handleGIFClick(gifpath) { + // 点击Gif大图发送=8 + void handleGIFClick(gifpath, index) async { // 消息队列 - Map message = { - 'id': Utils.uuid(), - 'contentType': 4, - 'isme': true, - 'avatar': 'assets/images/avatar/img11.jpg', - 'author': 'Andy', - 'content': '', - 'image': gifpath, - 'video': '', - }; - sendMessage(message); + // Map message = { + // 'contentType': 8, + // 'content': gifpath, + // }; + final res = await IMMessage().createFaceMessage(data: gifpath, index: index); + if (res.success) { + sendMessage(res.data?.messageInfo); + } } - // 提交消息 - void handleSubmit() { + // 发送文本消息=1 + void handleSubmit() async { if (editorController.text.isEmpty) return; // 消息队列 - Map message = { - 'id': Utils.uuid(), - 'contentType': 3, - 'isme': true, - 'avatar': 'assets/images/avatar/img11.jpg', - 'author': 'Andy', - 'content': editorController.text, - 'image': '', - 'video': '', - }; - sendMessage(message); - editorController.clear(); + // Map message = { + // 'contentType': 1, + // 'content': editorController.text, + // }; + final res = await IMMessage().createTextMessage(text: editorController.text); + if (res.success) { + sendMessage(res.data?.messageInfo); + editorController.clear(); + } } - // 选择区操作 + // 发红包消息 + void sendHongbao(date) async { + final amount = date['amount']; //用户输入的金额 + final remark = date['remark']; //用户输入的留言 + final maxNum = date['maxNum']; //红包数量 + + // 先检测可用余额 + final makeJson = jsonEncode({ + "amount": amount, + "remark": remark, + "maxNum": maxNum, + "open": false, + }); + final res = await IMMessage().createCustomMessage(data: makeJson); + if (res.success && (res.data != null)) { + final custMsg = res.data!.messageInfo; + custMsg!.cloudCustomData = SummaryType.hongbao; + sendMessage(res.data!.messageInfo); + Get.back(); + } + } + + // 发送图片消息=3 + void sendImage(imgPath) async { + final resImg = await IMMessage().createImageMessage(imagePath: imgPath); + if (resImg.success) { + sendMessage(resImg.data?.messageInfo); + } + } + + // 发送语音消息=4 + void sendVoiceMsg() async { + final fileMap = await VoiceService().stopRecording(); + if (fileMap != null) { + final res = await IMMessage().createSoundMessage( + soundPath: fileMap['path'], + duration: fileMap['duration'], + ); + if (res.success && res.data != null) { + sendMessage(res.data!.messageInfo); + } else { + MyDialog.toast('创建语音消息失败'); + } + } else { + MyDialog.toast('语音限制1-60秒'); + } + } + + // 发送视频消息=5 + void sendVideo(videoFilePath, type, duration, snapshotPath) async { + final resImg = await IMMessage().createVideoMessage( + videoFilePath: videoFilePath, + type: type, + duration: duration, + snapshotPath: snapshotPath, + ); + if (resImg.success) { + sendMessage(resImg.data?.messageInfo); + } + } + + // 底部操作蓝选择区操作 void handleChooseAction(key) { MyDialog.toast('$key'); switch (key) { case 'photo': // .... + pickFile(context); break; case 'camera': // .... @@ -854,12 +1217,97 @@ class _ChatState extends State with SingleTickerProviderStateMixin { } } + ///从相册选取图片/视频 + void pickFile(BuildContext context) async { + final pickedAssets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + textDelegate: const AssetPickerTextDelegate(), + pathNameBuilder: (AssetPathEntity album) { + return Utils.translateAlbumName(album); + }, + maxAssets: 5, + requestType: RequestType.common, + filterOptions: FilterOptionGroup( + imageOption: const FilterOption(), + videoOption: const FilterOption( + durationConstraint: DurationConstraint( + max: Duration(seconds: 120), + ), + ), + ), + ), + ); + + if (pickedAssets != null && pickedAssets.isNotEmpty) { + for (final asset in pickedAssets) { + switch (asset.type) { + case AssetType.image: + print("选中了图片:${asset.title}"); + var file = await asset.file; + if (file != null) { + var fileSizeInBytes = await file.length(); + var sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 28) { + MyDialog.toast('图片大小不能超过28MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + // 执行发送逻辑 + sendImage(file.path); + } + } + + break; + case AssetType.video: + print("选中了视频:${asset.title}"); + var file = await asset.file; + if (file != null) { + var fileSizeInBytes = await file.length(); + var sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 28) { + MyDialog.toast('图片大小不能超过28MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + // 执行发送逻辑 + var snapshot = await generateVideoThumbnail(file.path); + String? mimeType = await asset.mimeTypeAsync; + String vdType = mimeType?.split('/').last ?? 'mp4'; + print(vdType); + sendVideo(file.path, vdType, asset.duration, snapshot); + } + } + break; + default: + print("不支持的类型:${asset.type}"); + } + } + // final asset = pickedAssets.first; + // final file = await asset.file; // 获取实际文件 + // if (file != null) { + // final fileSizeInBytes = await file.length(); + // final sizeInMB = fileSizeInBytes / (1024 * 1024); + // if (sizeInMB > 100) { + // MyDialog.toast('图片大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + // } else { + // print("图片合法,大小:$sizeInMB MB"); + // //走upload(file)上传图片拿到url地址 + // // file; + // } + // } + } + } + /* ---------- { 弹窗功能模块 } ---------- */ // 红包弹窗 - void receiveRedPacketDialog(data) { + void receiveRedPacketDialog(V2TimMessage data) { showDialog( context: context, builder: (context) { + final obj = jsonDecode(data.customElem!.data!); + final amount = obj['amount']; + final remark = obj['remark']; + final open = obj['open'] ?? false; + return Material( type: MaterialType.transparency, child: Column( @@ -868,7 +1316,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { Container( width: double.infinity, margin: const EdgeInsets.symmetric(horizontal: 50.0), - padding: const EdgeInsets.symmetric(vertical: 50.0), + padding: const EdgeInsets.symmetric(vertical: 50.0, horizontal: 20.0), decoration: const BoxDecoration( color: Color(0xFFFF7F43), borderRadius: BorderRadius.all(Radius.circular(12.0)), @@ -877,54 +1325,70 @@ class _ChatState extends State with SingleTickerProviderStateMixin { children: [ ClipRRect( borderRadius: BorderRadius.circular(5.0), - child: Image.asset(data['avatar'], height: 40.0, width: 40.0, fit: BoxFit.cover), + child: NetworkOrAssetImage( + imageUrl: data.senderProfile?.faceUrl, + ), ), const SizedBox( height: 5.0, ), Text( - data['author'], + '${data.senderProfile?.nickName}的红包', style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w600), ), + SizedBox(height: 10), Text( - data['content'], + amount, + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), + ), + SizedBox(height: 20.0), + Text( + remark, style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), ), SizedBox( height: 100.0, ), - AnimatedBuilder( - animation: animTurns, - builder: (context, child) { - return Transform( - transform: Matrix4.rotationY(animTurns.value), - alignment: Alignment.center, - child: FilledButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(const Color(0xFFFFF9C7)), - padding: WidgetStateProperty.all(EdgeInsets.zero), - minimumSize: WidgetStateProperty.all(const Size(80.0, 80.0)), - shape: WidgetStateProperty.all(const CircleBorder()), - elevation: WidgetStateProperty.all(3.0), + if (open == false && data.isSelf == false) + AnimatedBuilder( + animation: animTurns, + builder: (context, child) { + return Transform( + transform: Matrix4.rotationY(animTurns.value), + alignment: Alignment.center, + child: FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(const Color(0xFFFFF9C7)), + padding: WidgetStateProperty.all(EdgeInsets.zero), + minimumSize: WidgetStateProperty.all(const Size(80.0, 80.0)), + shape: WidgetStateProperty.all(const CircleBorder()), + elevation: WidgetStateProperty.all(3.0), + ), + child: Text( + '开', + style: TextStyle(color: Color(0xFF3B3B3B), fontSize: 28.0), + ), + onPressed: () async { + // 点击开红包,开始动画 + animController.repeat(); + // 执行抢红包结果查询,(群)展示抢红包人员信息,单不用管 + // 执行消费红包动作 + //-------- + // 成功后修改消息体 + obj['open'] = true; //成功标记为true + data.customElem!.data = jsonEncode(obj); + ImService.instance.modifyMessage(message: data); + // 模拟开红包逻辑,1 秒后停止动画 + Future.delayed(Duration(seconds: 1), () { + animController.stop(); + animController.reset(); + Get.back(); + }); + }, ), - child: Text( - '開', - style: TextStyle(color: Color(0xFF3B3B3B), fontSize: 28.0), - ), - onPressed: () { - // 开始动画 - animController.repeat(); - // 模拟开红包逻辑,1 秒后停止动画 - Future.delayed(Duration(seconds: 1), () { - animController.stop(); - animController.reset(); - Get.back(); - }); - }, - ), - ); - }, - ), + ); + }, + ), ], ), ), @@ -998,7 +1462,11 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), context: context, builder: (context) { - return const RedPacket(); + return RedPacket( + flag: false, + onSend: (date) { + sendHongbao(date); + }); }, ); } @@ -1013,6 +1481,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { // 页面主体(聊天消息区/底部操作区) Scaffold( backgroundColor: Colors.grey[200], + resizeToAvoidBottomInset: true, // 启用键盘自动避让 appBar: AppBar( centerTitle: true, backgroundColor: Colors.transparent, @@ -1027,11 +1496,13 @@ class _ChatState extends State with SingleTickerProviderStateMixin { }, ), titleSpacing: 1.0, - title: Text( - // '${arguments['title']}', - '${arguments.showName}', - style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'), - ), + title: Obx(() { + return Text( + // '${arguments['title']}', + '${arguments.value.showName}', + style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'), + ); + }), flexibleSpace: Container( decoration: const BoxDecoration( gradient: LinearGradient( @@ -1046,7 +1517,109 @@ class _ChatState extends State with SingleTickerProviderStateMixin { Icons.more_horiz, color: Colors.white, ), - onPressed: () {}, + onPressed: () async { + final paddingTop = MediaQuery.of(Get.context!).padding.top; + + final selected = await showMenu( + context: Get.context!, + position: RelativeRect.fromLTRB( + double.infinity, + kToolbarHeight + paddingTop - 12, + 8, + double.infinity, + ), + color: FStyle.primaryColor, + elevation: 8, + items: [ + PopupMenuItem( + value: 'remark', + child: Row( + children: [ + Icon(Icons.edit, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设置备注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'not', + child: Row( + children: [ + Icon(Icons.do_not_disturb_on, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设为免打扰', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'report', + child: Row( + children: [ + Icon(Icons.report, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '举报', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'block', + child: Row( + children: [ + Icon(Icons.block, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '拉黑', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'foucs', + child: Row( + children: [ + Icon(Icons.person_remove_alt_1, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '取消关注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ); + + if (selected != null) { + switch (selected) { + case 'remark': + print('点击了备注'); + setRemark(); + break; + case 'not': + print('点击了免打扰'); + break; + case 'report': + print('点击了举报'); + break; + case 'block': + print('点击了拉黑'); + break; + case 'foucs': + print('点击了取关'); + break; + } + } + }, ), ], ), @@ -1056,19 +1629,31 @@ class _ChatState extends State with SingleTickerProviderStateMixin { children: [ // 渲染聊天消息 Expanded( - child: ScrollConfiguration( - behavior: CustomScrollBehavior(), - child: GestureDetector( - child: Obx(() { - return ListView( - controller: chatController, - reverse: true, - padding: const EdgeInsets.all(10.0), - children: renderChatList(), - ); - }), - onTap: () { - handleClickChatArea(); + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: handleClickChatArea, + child: LayoutBuilder( + builder: (context, constraints) { + return Obx(() { + final msgWidgets = renderChatList().reversed.toList(); + + return ListView( + controller: chatController, + reverse: true, + padding: const EdgeInsets.all(10.0), + children: [ + ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 40, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: msgWidgets, + ), + ), + ], + ); + }); }, ), ), @@ -1076,201 +1661,210 @@ class _ChatState extends State with SingleTickerProviderStateMixin { // 底部操作栏 Container( - color: Colors.grey[100], - child: SafeArea( - bottom: true, - child: Container( - decoration: BoxDecoration( - color: Colors.grey[100], - border: const Border(top: BorderSide(color: Colors.black38, width: .1)), - ), - child: Column( - children: [ - // 输入框编辑器模块 - Container( - padding: const EdgeInsets.all(10.0), - child: Row( - children: [ - InkWell( - child: Icon( - voiceBtnEnable ? Icons.keyboard_outlined : Icons.contactless_outlined, - color: const Color(0xFF3B3B3B), - size: 30.0, + color: Colors.grey[100], + child: SafeArea( + bottom: true, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[100], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: Column( + children: [ + // 输入框编辑器模块 + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + children: [ + InkWell( + child: Icon( + voiceBtnEnable ? Icons.keyboard_outlined : Icons.contactless_outlined, + color: const Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + setState(() { + toolbarEnable = false; + if (voiceBtnEnable) { + voiceBtnEnable = false; + editorFocusNode.requestFocus(); + } else { + voiceBtnEnable = true; + editorFocusNode.unfocus(); + } + }); + }, + ), + const SizedBox( + width: 10.0, + ), + Expanded( + child: Container( + constraints: const BoxConstraints(minHeight: 40.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), ), - onTap: () { - setState(() { - toolbarEnable = false; - if (voiceBtnEnable) { - voiceBtnEnable = false; - editorFocusNode.requestFocus(); - } else { - voiceBtnEnable = true; - editorFocusNode.unfocus(); - } - }); - }, - ), - const SizedBox( - width: 10.0, - ), - Expanded( - child: Container( - constraints: const BoxConstraints(minHeight: 40.0), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Stack( - children: [ - // 输入框 - Offstage( - offstage: voiceBtnEnable, - child: TextField( - decoration: const InputDecoration( - isDense: true, - hoverColor: Colors.transparent, - contentPadding: EdgeInsets.all(8.0), - border: OutlineInputBorder(borderSide: BorderSide.none), - ), - style: const TextStyle( - fontSize: 16.0, - ), - maxLines: null, - controller: editorController, - focusNode: editorFocusNode, - cursorColor: const Color(0xFF07C160), - onChanged: (value) {}, + child: Stack( + children: [ + // 输入框 + Offstage( + offstage: voiceBtnEnable, + child: TextField( + decoration: const InputDecoration( + isDense: true, + hoverColor: Colors.transparent, + contentPadding: EdgeInsets.all(8.0), + border: OutlineInputBorder(borderSide: BorderSide.none), ), + style: const TextStyle( + fontSize: 16.0, + ), + maxLines: null, + controller: editorController, + focusNode: editorFocusNode, + cursorColor: const Color(0xFF07C160), + onChanged: (value) {}, ), - // 语音 - Offstage( - offstage: !voiceBtnEnable, - child: GestureDetector( - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - alignment: Alignment.center, - height: 40.0, - width: double.infinity, - child: Text( - voiceTypeMap[voiceType], - style: const TextStyle(fontSize: 15.0), - ), + ), + // 语音 + Offstage( + offstage: !voiceBtnEnable, + child: GestureDetector( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), ), - onPanStart: (details) { + alignment: Alignment.center, + height: 40.0, + width: double.infinity, + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(fontSize: 15.0), + ), + ), + onPanStart: (details) async { + // 开始录音 + final res = await VoiceService().startRecording(); + if (res) { setState(() { voiceType = 1; voicePanelEnable = true; }); - }, - onPanUpdate: (details) { - Offset pos = details.globalPosition; - double swipeY = MediaQuery.of(context).size.height - 120; - double swipeX = MediaQuery.of(context).size.width / 2 + 50; - setState(() { - if (pos.dy >= swipeY) { - voiceType = 1; // 松开发送 - } else if (pos.dy < swipeY && pos.dx < swipeX) { - voiceType = 2; // 左滑松开取消 - } else if (pos.dy < swipeY && pos.dx >= swipeX) { - voiceType = 3; // 右滑语音转文字 - } - }); - }, - onPanEnd: (details) { - // print('停止录音'); - setState(() { - switch (voiceType) { - case 1: - MyDialog.toast('发送录音文件'); - voicePanelEnable = false; - break; - case 2: - MyDialog.toast('取消发送'); - voicePanelEnable = false; - break; - case 3: - MyDialog.toast('语音转文字'); - voicePanelEnable = true; - voiceToTransfer = true; - break; - } - voiceType = 0; - }); - }, - ), + } else { + MyDialog.toast('未获得麦克风权限'); + } + }, + onPanUpdate: (details) { + Offset pos = details.globalPosition; + double swipeY = MediaQuery.of(context).size.height - 120; + double swipeX = MediaQuery.of(context).size.width / 2 + 50; + setState(() { + if (pos.dy >= swipeY) { + voiceType = 1; // 松开发送 + } else if (pos.dy < swipeY && pos.dx < swipeX) { + voiceType = 2; // 左滑松开取消 + } else if (pos.dy < swipeY && pos.dx >= swipeX) { + voiceType = 3; // 右滑语音转文字 + } + }); + }, + onPanEnd: (details) { + // print('停止录音'); + setState(() { + switch (voiceType) { + case 1: + // MyDialog.toast('发送录音文件'); + sendVoiceMsg(); + voicePanelEnable = false; + break; + case 2: + // MyDialog.toast('取消发送'); + VoiceService().cancelRecording; + voicePanelEnable = false; + break; + case 3: + MyDialog.toast('语音转文字'); + voicePanelEnable = true; + voiceToTransfer = true; + break; + } + voiceType = 0; + }); + }, ), - ], - ), + ), + ], ), ), - const SizedBox( - width: 10.0, + ), + const SizedBox( + width: 10.0, + ), + InkWell( + child: const Icon( + Icons.add_reaction_rounded, + color: Color(0xFF3B3B3B), + size: 30.0, ), - InkWell( + onTap: () { + handleEmojChooseState(0); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: const Icon( + Icons.add, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(1); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: Container( + height: 25.0, + width: 25.0, + decoration: BoxDecoration( + color: const Color(0xFF07C160), + borderRadius: BorderRadius.circular(20.0), + ), child: const Icon( - Icons.add_reaction_rounded, - color: Color(0xFF3B3B3B), - size: 30.0, + Icons.arrow_upward, + color: Colors.white, + size: 20.0, ), - onTap: () { - handleEmojChooseState(0); - }, ), - const SizedBox( - width: 8.0, - ), - InkWell( - child: const Icon( - Icons.add, - color: Color(0xFF3B3B3B), - size: 30.0, - ), - onTap: () { - handleEmojChooseState(1); - }, - ), - const SizedBox( - width: 8.0, - ), - InkWell( - child: Container( - height: 25.0, - width: 25.0, - decoration: BoxDecoration( - color: const Color(0xFF07C160), - borderRadius: BorderRadius.circular(20.0), - ), - child: const Icon( - Icons.arrow_upward, - color: Colors.white, - size: 20.0, - ), - ), - onTap: () { - handleSubmit(); - }, - ), - ], + onTap: () { + handleSubmit(); + }, + ), + ], + ), + ), + + // 表情+选择模块 + Visibility( + visible: toolbarEnable, + child: SizedBox( + height: keyboardHeight, + child: Column( + children: toolbarIndex == 0 ? renderEmojWidget() : renderChooseWidget(), ), ), - - // 表情+选择模块 - Visibility( - visible: toolbarEnable, - child: SizedBox( - height: keyboardHeight, - child: Column( - children: toolbarIndex == 0 ? renderEmojWidget() : renderChooseWidget(), - ), - ), - ) - ], - ), + ) + ], ), - )) + ), + ), + ) ], ), ), @@ -1285,13 +1879,14 @@ class _ChatState extends State with SingleTickerProviderStateMixin { children: [ // 取消发送+语音转文字 Positioned( - bottom: 120, + bottom: 160, left: 30, right: 30, child: Visibility( visible: !voiceToTransfer, child: Column( - crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center, + // crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ // 语音动画层 Stack( @@ -1300,7 +1895,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { AnimatedContainer( duration: Duration(milliseconds: 200), height: 70.0, - width: voiceType == 2 ? 70.0 : 200.0, + // width: voiceType == 2 ? 70.0 : 200.0, + width: 200.0, decoration: BoxDecoration( color: voiceType == 2 ? Colors.red : Color(0xFF89E45B), borderRadius: BorderRadius.circular(15.0), @@ -1324,7 +1920,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), // 操作项 Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.center, children: [ // 取消发送 Container( @@ -1340,18 +1936,18 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), ), // 语音转文字 - Container( - height: 60.0, - width: 60.0, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(50.0), - color: voiceType == 3 ? Color(0xFF89E45B) : Colors.black38, - ), - child: Icon( - Icons.translate, - color: Colors.white54, - ), - ), + // Container( + // height: 60.0, + // width: 60.0, + // decoration: BoxDecoration( + // borderRadius: BorderRadius.circular(50.0), + // color: voiceType == 3 ? Color(0xFF89E45B) : Colors.black38, + // ), + // child: Icon( + // Icons.translate, + // color: Colors.white54, + // ), + // ), ], ), ], @@ -1404,7 +2000,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), // 操作项 Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.center, children: [ GestureDetector( child: Container( @@ -1498,7 +2094,12 @@ class _ChatState extends State with SingleTickerProviderStateMixin { alignment: Alignment.bottomCenter, child: Visibility( visible: !voiceToTransfer, - child: Image.asset('assets/images/voice_bg.webp', width: double.infinity, height: 100.0, fit: BoxFit.fill), + child: Image.asset( + 'assets/images/voice_bg.webp', + width: double.infinity, + height: 100.0, + fit: BoxFit.fill, + ), ), ), // 背景图标 @@ -1564,7 +2165,7 @@ class RenderChatItem extends StatelessWidget { width: 35.0, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(20.0)), - child: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), + child: NetworkOrAssetImage(imageUrl: data.faceUrl), ), ) : const SizedBox.shrink(), @@ -1612,7 +2213,7 @@ class RenderChatItem extends StatelessWidget { width: 35.0, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(20.0)), - child: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), + child: NetworkOrAssetImage(imageUrl: data.faceUrl), ), ) : const SizedBox.shrink(), diff --git a/lib/pages/chat/chat_group.dart b/lib/pages/chat/chat_group.dart new file mode 100644 index 0000000..988dac8 --- /dev/null +++ b/lib/pages/chat/chat_group.dart @@ -0,0 +1,1813 @@ +/// 聊天模板 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; +import 'package:loopin/IM/controller/chat_detail_controller.dart'; +import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; + +import '../../behavior/custom_scroll_behavior.dart'; +import '../../components/image_group.dart'; +import '../../styles/index.dart'; +import '../../utils/index.dart'; +import './components/redpacket.dart'; +import './components/richtext.dart'; +// import 'mock/chat_json.dart'; +import 'mock/emoj_json.dart'; + +class ChatGroup extends StatefulWidget { + const ChatGroup({super.key}); + + @override + State createState() => _ChatState(); +} + +class _ChatState extends State with SingleTickerProviderStateMixin { + late final ChatDetailController controller; + // 接收参数 + // Rx arguments = Get.arguments.obs; + late final Rx arguments; + + late String selfUserId; + + // 聊天消息模块 + final bool isNeedScrollBottom = true; + + // final RxList chatList = [].obs; + + bool isLoading = false; // 是否在加载中 + bool hasMore = true; // 是否还有更多数据 + bool _throttleFlag = false; // 滚动节流锁 + + // 表情json + List emoJson = emotionData; + + // 底部操作栏模块 + TextEditingController editorController = TextEditingController(); + FocusNode editorFocusNode = FocusNode(); + bool voiceBtnEnable = false; // 语音按钮 + bool voicePanelEnable = false; // 语音操作面板 + bool voiceToTransfer = false; // 语音转文字中 + int voiceType = 0; // 语音操作类型 + Map voiceTypeMap = { + 0: '按住 说话', // 按住说话 + 1: '松开 发送', // 松开发送 + 2: '松开 取消', // 松开取消(左滑) + 3: '语音转文字', // 语音转文字(右滑) + }; + bool toolbarEnable = false; // 显示表情/选择区域 + int toolbarIndex = 0; // 0 表情 1 选择 + double keyboardHeight = 157.6; // 键盘高度 + List chooseOptions = [ + {'key': 'photo', 'name': '相册', 'icon': 'assets/images/icon_photo.webp'}, + {'key': 'camera', 'name': '拍摄', 'icon': 'assets/images/icon_camera.webp'}, + {'key': 'location', 'name': '位置', 'icon': 'assets/images/icon_location.webp'}, + {'key': 'redpacket', 'name': '红包', 'icon': 'assets/images/icon_hb.webp'}, + ]; + + // controller监听 + ScrollController chatController = ScrollController(); + ScrollController emojController = ScrollController(); + + // 模拟开红包按钮动画 + late AnimationController animController; + + // 创建一个从 0 到 π 的旋转动画 + late Animation animTurns; + + // 初始化状态 + @override + void initState() { + super.initState(); + final arg = Get.arguments as V2TimConversation; + arguments = arg.obs; + + controller = Get.put(ChatDetailController(userID: arguments.value.userID ?? '')); + + animController = AnimationController( + vsync: this, + duration: Duration(milliseconds: 500), + ); + animTurns = Tween(begin: 0, end: 3.1415926).animate(animController); + + cleanUnRead(); + + getUserId(); + + getMsgData(initFlag: true); + + // 编辑框获取焦点 + editorFocusNode.addListener(() { + if (editorFocusNode.hasFocus) { + setState(() { + toolbarEnable = false; + }); + scrollToBottom(); + } + }); + // 滚动监听 + chatController.addListener(() { + if (_throttleFlag) return; + if (chatController.position.pixels >= chatController.position.maxScrollExtent - 50) { + _throttleFlag = true; + getMsgData().then((_) { + // 解锁 + Future.delayed(Duration(milliseconds: 300), () { + _throttleFlag = false; + }); + }); + } + }); + } + + @override + void dispose() { + if (Get.isRegistered()) { + Get.delete(); + } + // 更新会话列表数据 + Get.find().getConversationList(); + chatController.dispose(); + emojController.dispose(); + editorFocusNode.dispose(); + animController.dispose(); + super.dispose(); + } + + // 设置好友备注 + void setRemark() async { + String remark = ''; + await MyDialog.confirm( + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + TextField( + onChanged: (value) => remark = value, + maxLength: 16, + maxLengthEnforcement: MaxLengthEnforcement.enforced, // 强制不能输入超过 + decoration: InputDecoration( + hintText: '请输入备注', + filled: true, + fillColor: const Color(0xFFF5F5F5), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Color(0xFFBE4EFF), width: 1), + ), + ), + ) + ], + ), + title: '设置备注', + buttonText: '确认', + cancelText: '取消', + onConfirm: () async { + // print('备注为:$remark'); + final res = await ImService.instance.setFriendInfo(userID: arguments.value.userID!, friendRemark: remark); + if (res.success) { + // 刷新会话列表数据 + // Get.find().getConversationList(); + arguments.update((val) { + val?.showName = remark; + }); + } else { + print(res.desc); + print(arguments.value.userID); + MyDialog.toast(res.desc, icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + return true; + }, + ); + } + + void cleanUnRead() async { + if ((arguments.value.unreadCount ?? 0) > 0) { + final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.value.conversationID); + if (!res.success) { + MyDialog.toast(res.desc, icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + } + } + + void getUserId() async { + final idRes = await ImService.instance.selfUserId(); + if (idRes.success) { + selfUserId = idRes.data; + } + } + + Future getMsgData({bool initFlag = false}) async { + if (isLoading || !hasMore) return; // 正在加载 or 没有更多了 + + isLoading = true; + + // 获取最旧一条消息作为游标 + V2TimMessage? lastRealMsg; + for (var msg in controller.chatList.reversed) { + if (msg.localCustomData != 'time_label') { + lastRealMsg = msg; + break; + } + } + final lastMsg = lastRealMsg ?? arguments.value.lastMessage; // 如果找不到,就用传入的参数 + + // final lastMsg = controller.chatList.isNotEmpty ? controller.chatList.last : arguments.lastMessage; + + final res = await ImService.instance.getHistoryMessageList( + userID: arguments.value.userID, + lastMsg: lastMsg, + ); + + if (res.success) { + final newMessages = res.data ?? []; + + if (newMessages.isEmpty) { + hasMore = false; + MyDialog.toast('没有更多了~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + if (initFlag) { + newMessages.insert(0, lastMsg!); + } + controller.updateChatListWithTimeLabels(newMessages); + print('聊天数据加载成功'); + } + } else { + MyDialog.toast("获取聊天记录失败:${res.desc}", icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + + isLoading = false; + } + + // 渲染聊天消息 + List renderChatList() { + List msgtpl = []; + for (var item in controller.chatList) { + // 时间提示,公告提示 + if (item.localCustomData == 'time_label') { + msgtpl.add(Container( + margin: const EdgeInsets.only(bottom: 15.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.customElem!.data ?? '', + style: TextStyle(color: Colors.grey[600], fontSize: 12.0), + ), + ], + ), + )); + } + // 文本消息模板 + else if (item.elemType == 1) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? Color(0xFFFFFFFF) : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + child: RichTextUtil.getRichText(item.textElem?.text ?? '', color: !(item.isSelf ?? false) ? Colors.black : Colors.white), // 可自定义解析emoj/网址/电话 + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // gif表情模板 + else if (item.elemType == 4) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: Container( + constraints: const BoxConstraints( + maxHeight: 100.0, + maxWidth: 100.0, + ), + child: Image.asset('assets/images/emotion/${item.customElem?.data}'), + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 图片模板 + else if (item.elemType == 5) { + List imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? []; + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: ImageGroup( + images: imagePaths, + width: 120, + ), + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 视频模板 + else if (item.elemType == 6) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: SizedBox( + width: 90.0, + child: Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: Image.network( + item.videoElem?.videoUrl ?? '', + ), + ), + const Align( + alignment: Alignment.center, + child: Icon( + Icons.play_circle, + color: Colors.white, + size: 30.0, + ), + ), + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 语音模板 + else if (item.elemType == 7) { + List audiobody = [ + Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? const Color(0xFFFFFFFF) : const Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + constraints: BoxConstraints( + // maxWidth: 120.0, + maxWidth: (item.soundElem?.duration)! / 60 * 230, + ), + child: Row( + mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, + children: !(item.isSelf ?? false) + ? [ + const Icon(Icons.multitrack_audio), + const SizedBox( + width: 5.0, + ), + Text('${item.soundElem?.duration}'), + ] + : [ + Text('${item.soundElem?.duration}'), + const SizedBox( + width: 5.0, + ), + const Icon(Icons.multitrack_audio), + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + const SizedBox( + width: 5.0, + ), + FStyle.badge(0, isdot: true), + ]; + + if (item.isSelf ?? false) { + // 内容反转 + audiobody = audiobody.reversed.toList(); + } else { + audiobody = audiobody; + } + + msgtpl.add(RenderChatItem( + data: item, + child: Row( + mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, + children: audiobody, + ))); + } + // 红包模板 + else if (item.elemType == 0 && item.customElem?.desc == 'hongbao') { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: const Color(0xFFFF7F43), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + constraints: const BoxConstraints( + maxWidth: 210.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + spacing: 10.0, + children: [ + Image.asset( + 'assets/images/hbico.png', + width: 32.0, + fit: BoxFit.contain, + ), + Text(item.customElem?.data ?? '', style: const TextStyle(color: Colors.white, fontSize: 14.0)), + ], + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 10.0), + padding: const EdgeInsets.symmetric(vertical: 5.0), + width: double.infinity, + decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.white30, width: .5))), + child: const Text( + '拼手气红包', + style: TextStyle(color: Colors.white70, fontSize: 11.0), + ), + ), + ], + ), + ), + onTap: () { + receiveRedPacketDialog(item); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 位置模板 + else if (item.elemType == 9) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + // splashColor: Colors.transparent, + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + constraints: const BoxConstraints( + maxWidth: 210.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, + vertical: 5.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.locationElem?.desc ?? '位置信息异常', + overflow: TextOverflow.ellipsis, + ), + Text( + "${item.locationElem?.latitude},${item.locationElem?.longitude}", + style: const TextStyle(color: Colors.grey, fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ClipRRect( + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(10.0)), + child: Image.asset('assets/images/map.jpg', width: 210.0, height: 70.0, fit: BoxFit.cover), + ) + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + } + return msgtpl; + } + + // 表情列表集合 + List renderEmojWidget() { + return [ + // Tab切换 + Container( + padding: const EdgeInsets.symmetric(horizontal: 5.0), + child: Row( + children: emoJson.map((item) { + return InkWell( + child: Container( + margin: const EdgeInsets.all(5.0), + alignment: Alignment.center, + height: 40.0, + width: 40.0, + decoration: BoxDecoration(color: item['selected'] ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(5.0)), + child: item['index'] == 0 + ? Text( + item['pathLabel'], + style: const TextStyle(fontSize: 22.0), + ) + : Image.asset(item['pathLabel'], height: 24.0, width: 24.0, fit: BoxFit.cover), + ), + onTap: () { + handleEmojTab(item['index']); + }, + ); + }).toList(), + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.grey[200], + border: const Border(top: BorderSide(color: Colors.black54, width: .1)), + ), + child: ListView( + controller: emojController, + padding: const EdgeInsets.all(10.0), + children: emoJson.map((item) { + return Visibility( + visible: item['selected'], + child: GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + // 横轴元素个数 + crossAxisCount: item['type'] == 'emoj' ? 8 : 5, + // 纵轴间距 + mainAxisSpacing: 5.0, + // 横轴间距 + crossAxisSpacing: 5.0, + // 子组件宽高比例 + childAspectRatio: 1, + ), + children: item['nodes'].map((emoj) { + if (item['type'] == 'emoj') { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(5.0), + child: Container( + alignment: Alignment.center, + height: 40.0, + width: 40.0, + child: Text( + emoj, + style: const TextStyle(fontSize: 24.0), + ), + ), + onTap: () { + handleEmojClick(emoj); + }, + ), + ); + } else { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(5.0), + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(5.0), + height: 68.0, + width: 68.0, + child: Image.asset(emoj), + ), + onTap: () { + handleGIFClick(emoj); + }, + ), + ); + } + }).toList(), + ), + ); + }).toList(), + ), + ), + ), + ]; + } + + // 选择功能列表 + List renderChooseWidget() { + return [ + Expanded( + child: Container( + padding: const EdgeInsets.fromLTRB(30.0, 35.0, 30.0, 15.0), + decoration: BoxDecoration( + color: Colors.grey[200], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + // 横轴元素个数 + crossAxisCount: 4, + // 纵轴间距 + mainAxisSpacing: 30.0, + // 横轴间距 + crossAxisSpacing: 25.0, + // 子组件宽高比例 + childAspectRatio: .8, + ), + children: chooseOptions.map((item) { + return Column( + children: [ + Expanded( + child: Material( + type: MaterialType.transparency, + child: Ink( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + ), + child: InkWell( + borderRadius: BorderRadius.circular(15.0), + child: Image.asset(item['icon'], height: 40.0, fit: BoxFit.cover), + onTap: () { + handleChooseAction(item['key']); + }, + ), + ), + ), + ), + const SizedBox(height: 5.0), + Text( + item['name'], + style: const TextStyle(color: Colors.black87, fontSize: 12.0), + ) + ], + ); + }).toList(), + ), + ), + ), + ]; + } + + /* ---------- { 聊天消息模块 } ---------- */ + // 聊天消息滚动到底部 + // void scrollToBottom() async { + // chatList = await fetchChatList(); + // chatController.animateTo(isNeedScrollBottom ? 0 : chatController.position.maxScrollExtent, + // duration: const Duration(milliseconds: 200), curve: Curves.easeIn); + // } + void scrollToBottom() { + Future.delayed(Duration(milliseconds: 100), () { + if (chatController.hasClients) { + chatController.animateTo( + 0, // reverse: true 时滚动到底部是 offset: 0 + duration: Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + // 点击消息区域 + void handleClickChatArea() { + hideKeyboard(); + setState(() { + toolbarEnable = false; + }); + } + + /* ---------- { 底部Toolbar模块 } ---------- */ + // 光标处插入内容 + void insertTextAtCursor(String html) { + var editorNotifier = editorController.value; // The current value stored in this notifier. + var start = editorNotifier.selection.baseOffset; + var end = editorNotifier.selection.extentOffset; + if (editorNotifier.selection.isValid) { + String newText = ''; + if (editorNotifier.selection.isCollapsed) { + if (end > 0) { + newText += editorNotifier.text.substring(0, end); + } + newText += html; + if (editorNotifier.text.length > end) { + newText += editorNotifier.text.substring(end, editorNotifier.text.length); + } + } else { + newText = editorNotifier.text.replaceRange(start, end, html); + end = start; + } + editorController.value = + editorNotifier.copyWith(text: newText, selection: editorNotifier.selection.copyWith(baseOffset: end + html.length, extentOffset: end + html.length)); + } else { + editorController.value = TextEditingValue( + text: html, + selection: TextSelection.fromPosition(TextPosition(offset: html.length)), + ); + } + } + + // 发送消息队列 + void sendMessage(message) async { + // 待插入的消息 + List messagesToInsert = []; + V2TimMessage? lastRealMsg; + + for (var msg in controller.chatList) { + if (msg.localCustomData != 'time_label') { + lastRealMsg = msg; + break; + } + } + + // 如果有数据,检测时间,是否需要插入伪消息 + if (lastRealMsg != null && + needInsertTimeLabel( + (lastRealMsg.timestamp ?? 0) * 1000, // 转为毫秒级 + DateTime.now().millisecondsSinceEpoch, + )) { + // 消息时间间隔超过3分钟插入伪消息 + final showLabel = Utils().formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000); + final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId); + messagesToInsert.add(resMsg.data); + } else { + // 没数据的时候直接插入伪消息 + final showLabel = Utils().formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000); + + final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId); + messagesToInsert.add(resMsg.data); + } + + // 不需要时间标签 + // final res = await IMMessage().sendText( + // text: message['content'], + // toUserID: arguments.value.userID, + // ); + + // if (res.success && res.data != null) { + // messagesToInsert.insert(0, res.data); // 加入消息本体 + + // controller.chatList.insertAll(0, messagesToInsert); + + // scrollToBottom(); + // print('发送成功'); + // } else { + // print('消息发送失败: ${res.code} - ${res.desc}'); + // } + } + + bool needInsertTimeLabel(int lastTimestamp, int newTimestamp, {int interval = 3 * 60}) { + return (newTimestamp - lastTimestamp) > interval * 1000; + } + + // 隐藏键盘 + void hideKeyboard() { + if (editorFocusNode.hasFocus) { + editorFocusNode.unfocus(); + } + } + + // 表情/选择切换 + void handleEmojChooseState(index) { + hideKeyboard(); + setState(() { + toolbarEnable = true; + toolbarIndex = index; + voiceBtnEnable = false; + }); + scrollToBottom(); + } + + // 表情Tab切换 + void handleEmojTab(index) { + var emols = emoJson; + for (var i = 0, len = emols.length; i < len; i++) { + emols[i]['selected'] = false; + } + emols[index]['selected'] = true; + setState(() { + emoJson = emols; + }); + emojController.jumpTo(0); + } + + // 点击表情 + void handleEmojClick(emoj) { + insertTextAtCursor(emoj); + } + + // 点击Gif大图 + void handleGIFClick(gifpath) { + // 消息队列 + Map message = { + 'id': Utils.uuid(), + 'contentType': 4, + 'isme': true, + 'avatar': 'assets/images/avatar/img11.jpg', + 'author': 'Andy', + 'content': '', + 'image': gifpath, + 'video': '', + }; + sendMessage(message); + } + + // 提交消息 + void handleSubmit() { + if (editorController.text.isEmpty) return; + // 消息队列 + Map message = { + 'id': Utils.uuid(), + 'contentType': 3, + 'isme': true, + 'avatar': 'assets/images/avatar/img11.jpg', + 'author': 'Andy', + 'content': editorController.text, + 'image': '', + 'video': '', + }; + sendMessage(message); + editorController.clear(); + } + + // 选择区操作 + void handleChooseAction(key) { + MyDialog.toast('$key'); + switch (key) { + case 'photo': + // .... + break; + case 'camera': + // .... + break; + case 'redpacket': + sendRedPacketDialog(); + break; + } + } + + /* ---------- { 弹窗功能模块 } ---------- */ + // 红包弹窗 + void receiveRedPacketDialog(data) { + showDialog( + context: context, + builder: (context) { + return Material( + type: MaterialType.transparency, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 50.0), + padding: const EdgeInsets.symmetric(vertical: 50.0), + decoration: const BoxDecoration( + color: Color(0xFFFF7F43), + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5.0), + child: Image.asset(data['avatar'], height: 40.0, width: 40.0, fit: BoxFit.cover), + ), + const SizedBox( + height: 5.0, + ), + Text( + data['author'], + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w600), + ), + Text( + data['content'], + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), + ), + SizedBox( + height: 100.0, + ), + AnimatedBuilder( + animation: animTurns, + builder: (context, child) { + return Transform( + transform: Matrix4.rotationY(animTurns.value), + alignment: Alignment.center, + child: FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(const Color(0xFFFFF9C7)), + padding: WidgetStateProperty.all(EdgeInsets.zero), + minimumSize: WidgetStateProperty.all(const Size(80.0, 80.0)), + shape: WidgetStateProperty.all(const CircleBorder()), + elevation: WidgetStateProperty.all(3.0), + ), + child: Text( + '開', + style: TextStyle(color: Color(0xFF3B3B3B), fontSize: 28.0), + ), + onPressed: () { + // 开始动画 + animController.repeat(); + // 模拟开红包逻辑,1 秒后停止动画 + Future.delayed(Duration(seconds: 1), () { + animController.stop(); + animController.reset(); + Get.back(); + }); + }, + ), + ); + }, + ), + ], + ), + ), + GestureDetector( + child: Container( + margin: const EdgeInsets.only(top: 20.0), + height: 30.0, + width: 30.0, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 1.5), + borderRadius: BorderRadius.circular(50.0), + ), + child: const Icon( + Icons.close_outlined, + color: Colors.white, + size: 18.0, + ), + ), + onTap: () { + Navigator.of(context).pop(); + }, + ) + ], + )); + }); + } + + // 长按消息菜单 + void contextMenuDialog() { + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + backgroundColor: Colors.white, + surfaceTintColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 5.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), + children: [ + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('复制')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('发送给朋友')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('收藏')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('删除')), + onPressed: () {}, + ), + ], + ); + }, + ); + } + + // 发群红包弹窗 + void sendRedPacketDialog() { + showModalBottomSheet( + backgroundColor: Colors.grey[50], + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))), + showDragHandle: true, + clipBehavior: Clip.hardEdge, + isScrollControlled: true, // 屏幕最大高度 + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height - 180, // 自定义最大高度 + ), + context: context, + builder: (context) { + return RedPacket( + flag: true, + ); + }, + ); + } + + /* ---------- { 其它功能模块 } ---------- */ + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + // 页面主体(聊天消息区/底部操作区) + Scaffold( + backgroundColor: Colors.grey[200], + appBar: AppBar( + centerTitle: true, + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios_rounded, + size: 20.0, + ), + onPressed: () { + Get.back(); + }, + ), + titleSpacing: 1.0, + title: Obx(() { + return Text( + // '${arguments['title']}', + '${arguments.value.showName}', + style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'), + ); + }), + flexibleSpace: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFBE4EFF), Color(0xFF1DFFC7)], + )), + ), + actions: [ + IconButton( + icon: const Icon( + Icons.more_horiz, + color: Colors.white, + ), + onPressed: () async { + final paddingTop = MediaQuery.of(Get.context!).padding.top; + + final selected = await showMenu( + context: Get.context!, + position: RelativeRect.fromLTRB( + double.infinity, + kToolbarHeight + paddingTop - 12, + 8, + double.infinity, + ), + color: FStyle.primaryColor, + elevation: 8, + items: [ + PopupMenuItem( + value: 'remark', + child: Row( + children: [ + Icon(Icons.edit, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设置备注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'not', + child: Row( + children: [ + Icon(Icons.do_not_disturb_on, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设为免打扰', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'report', + child: Row( + children: [ + Icon(Icons.report, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '举报', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'block', + child: Row( + children: [ + Icon(Icons.block, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '拉黑', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'foucs', + child: Row( + children: [ + Icon(Icons.person_remove_alt_1, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '取消关注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ); + + if (selected != null) { + switch (selected) { + case 'remark': + print('点击了备注'); + setRemark(); + break; + case 'not': + print('点击了免打扰'); + break; + case 'report': + print('点击了举报'); + break; + case 'block': + print('点击了拉黑'); + break; + case 'foucs': + print('点击了取关'); + break; + } + } + }, + ), + ], + ), + body: Flex( + direction: Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 渲染聊天消息 + Expanded( + child: ScrollConfiguration( + behavior: CustomScrollBehavior(), + child: GestureDetector( + child: Obx(() { + return ListView( + controller: chatController, + reverse: true, + padding: const EdgeInsets.all(10.0), + children: renderChatList(), + ); + }), + onTap: () { + handleClickChatArea(); + }, + ), + ), + ), + + // 底部操作栏 + Container( + color: Colors.grey[100], + child: SafeArea( + bottom: true, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[100], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: Column( + children: [ + // 输入框编辑器模块 + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + children: [ + InkWell( + child: Icon( + voiceBtnEnable ? Icons.keyboard_outlined : Icons.contactless_outlined, + color: const Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + setState(() { + toolbarEnable = false; + if (voiceBtnEnable) { + voiceBtnEnable = false; + editorFocusNode.requestFocus(); + } else { + voiceBtnEnable = true; + editorFocusNode.unfocus(); + } + }); + }, + ), + const SizedBox( + width: 10.0, + ), + Expanded( + child: Container( + constraints: const BoxConstraints(minHeight: 40.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + child: Stack( + children: [ + // 输入框 + Offstage( + offstage: voiceBtnEnable, + child: TextField( + decoration: const InputDecoration( + isDense: true, + hoverColor: Colors.transparent, + contentPadding: EdgeInsets.all(8.0), + border: OutlineInputBorder(borderSide: BorderSide.none), + ), + style: const TextStyle( + fontSize: 16.0, + ), + maxLines: null, + controller: editorController, + focusNode: editorFocusNode, + cursorColor: const Color(0xFF07C160), + onChanged: (value) {}, + ), + ), + // 语音 + Offstage( + offstage: !voiceBtnEnable, + child: GestureDetector( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + alignment: Alignment.center, + height: 40.0, + width: double.infinity, + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(fontSize: 15.0), + ), + ), + onPanStart: (details) { + setState(() { + voiceType = 1; + voicePanelEnable = true; + }); + }, + onPanUpdate: (details) { + Offset pos = details.globalPosition; + double swipeY = MediaQuery.of(context).size.height - 120; + double swipeX = MediaQuery.of(context).size.width / 2 + 50; + setState(() { + if (pos.dy >= swipeY) { + voiceType = 1; // 松开发送 + } else if (pos.dy < swipeY && pos.dx < swipeX) { + voiceType = 2; // 左滑松开取消 + } else if (pos.dy < swipeY && pos.dx >= swipeX) { + voiceType = 3; // 右滑语音转文字 + } + }); + }, + onPanEnd: (details) { + // print('停止录音'); + setState(() { + switch (voiceType) { + case 1: + MyDialog.toast('发送录音文件'); + voicePanelEnable = false; + break; + case 2: + MyDialog.toast('取消发送'); + voicePanelEnable = false; + break; + case 3: + MyDialog.toast('语音转文字'); + voicePanelEnable = true; + voiceToTransfer = true; + break; + } + voiceType = 0; + }); + }, + ), + ), + ], + ), + ), + ), + const SizedBox( + width: 10.0, + ), + InkWell( + child: const Icon( + Icons.add_reaction_rounded, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(0); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: const Icon( + Icons.add, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(1); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: Container( + height: 25.0, + width: 25.0, + decoration: BoxDecoration( + color: const Color(0xFF07C160), + borderRadius: BorderRadius.circular(20.0), + ), + child: const Icon( + Icons.arrow_upward, + color: Colors.white, + size: 20.0, + ), + ), + onTap: () { + handleSubmit(); + }, + ), + ], + ), + ), + + // 表情+选择模块 + Visibility( + visible: toolbarEnable, + child: SizedBox( + height: keyboardHeight, + child: Column( + children: toolbarIndex == 0 ? renderEmojWidget() : renderChooseWidget(), + ), + ), + ) + ], + ), + ), + )) + ], + ), + ), + // 录音主体(按住说话/松开取消/语音转文本) + IgnorePointer( + ignoring: false, + child: Visibility( + visible: voicePanelEnable, + child: Material( + color: const Color(0xDD1B1B1B), + child: Stack( + children: [ + // 取消发送+语音转文字 + Positioned( + bottom: 120, + left: 30, + right: 30, + child: Visibility( + visible: !voiceToTransfer, + child: Column( + crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center, + children: [ + // 语音动画层 + Stack( + alignment: Alignment.bottomCenter, + children: [ + AnimatedContainer( + duration: Duration(milliseconds: 200), + height: 70.0, + width: voiceType == 2 ? 70.0 : 200.0, + decoration: BoxDecoration( + color: voiceType == 2 ? Colors.red : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(15.0), + ), + clipBehavior: Clip.antiAlias, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset('assets/images/voice_waves.gif', height: 23.0, width: voiceType == 2 ? 30.0 : 70.0, fit: BoxFit.cover) + ], + ), + ), + RotatedBox( + quarterTurns: 0, + child: CustomPaint(painter: ArrowShape(arrowColor: voiceType == 2 ? Colors.red : Color(0xFF89E45B), arrowSize: 10.0)), + ) + ], + ), + const SizedBox( + height: 50.0, + ), + // 操作项 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 取消发送 + Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: voiceType == 2 ? Colors.red : Colors.black38, + ), + child: Icon( + Icons.close, + color: Colors.white54, + ), + ), + // 语音转文字 + Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: voiceType == 3 ? Color(0xFF89E45B) : Colors.black38, + ), + child: Icon( + Icons.translate, + color: Colors.white54, + ), + ), + ], + ), + ], + ), + ), + ), + // 语音转文字(识别结果状态) + Positioned( + bottom: 120, + left: 30, + right: 30, + child: Visibility( + visible: voiceToTransfer, + child: Column( + children: [ + // 提示结果 + Stack( + children: [ + Container( + height: 100.0, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(15.0), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outlined, + color: Colors.white, + ), + Text( + '未识别到文字。', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + Positioned( + right: 35.0, + bottom: 1, + child: RotatedBox( + quarterTurns: 0, + child: CustomPaint(painter: ArrowShape(arrowColor: Colors.red, arrowSize: 10.0)), + )), + ], + ), + const SizedBox( + height: 50.0, + ), + // 操作项 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + child: Container( + height: 60.0, + width: 60.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.undo, + color: Colors.white54, + ), + Text( + '取消', + style: TextStyle(color: Colors.white70), + ) + ], + ), + ), + onTap: () { + setState(() { + voicePanelEnable = false; + voiceToTransfer = false; + }); + }, + ), + GestureDetector( + child: Container( + height: 60.0, + width: 100.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.graphic_eq_rounded, + color: Colors.white54, + ), + Text( + '发送原语音', + style: TextStyle(color: Colors.white70), + ) + ], + ), + ), + onTap: () {}, + ), + GestureDetector( + child: Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: Colors.white12, + ), + child: const Icon( + Icons.check, + color: Colors.white12, + ), + ), + onTap: () {}, + ), + ], + ), + ], + ), + ), + ), + // 提示文字(操作状态) + Positioned( + bottom: 120, + left: 0, + width: MediaQuery.of(context).size.width, + child: Visibility( + visible: !voiceToTransfer, + child: Align( + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(color: Colors.white70), + ), + ), + ), + ), + // 背景 + Align( + alignment: Alignment.bottomCenter, + child: Visibility( + visible: !voiceToTransfer, + child: Image.asset('assets/images/voice_bg.webp', width: double.infinity, height: 100.0, fit: BoxFit.fill), + ), + ), + // 背景图标 + Positioned( + bottom: 25, + left: 0, + width: MediaQuery.of(context).size.width, + child: Visibility( + visible: !voiceToTransfer, + child: const Align( + child: Icon( + Icons.graphic_eq_rounded, + color: Colors.black54, + ), + ), + ), + ), + ], + ), + ), + ), + ) + ], + ); + } +} + +// 渲染聊天消息公共部分 +class RenderChatItem extends StatelessWidget { + const RenderChatItem({ + super.key, + required this.data, + required this.child, + }); + final V2TimMessage data; // 消息数据 + final Widget? child; // 消息体 + + // 设置箭头颜色 + // Color arrowColor(data) { + // Color color = Colors.transparent; + // if ([8].contains(data.elemType)) { + // // 红包箭头颜色 + // color = const Color(0xFFFFA52F); + // } else if ([9].contains(data.elemType)) { + // // 位置箭头颜色 + // color = const Color(0xFFFFFFFF); + // } else { + // color = !data['isme'] ? const Color(0xFFFFFFFF) : const Color(0xFF9543FF); + // } + // return color; + // } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + !(data.isSelf ?? false) + ? SizedBox( + height: 35.0, + width: 35.0, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + child: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), + ), + ) + : const SizedBox.shrink(), + Expanded( + child: Padding( + padding: !(data.isSelf ?? false) ? const EdgeInsets.only(left: 10.0, right: 40.0) : const EdgeInsets.only(left: 40.0, right: 10.0), + child: Column( + crossAxisAlignment: !(data.isSelf ?? false) ? CrossAxisAlignment.start : CrossAxisAlignment.end, + children: [ + Text( + data.friendRemark ?? data.nameCard ?? data.nickName ?? '未知昵称', + style: const TextStyle(color: Colors.grey, fontSize: 12.0), + ), + const SizedBox( + height: 3.0, + ), + Stack( + children: [ + // 气泡箭头 + /* Visibility( + // 显示箭头(消息+语音+红包+位置) + visible: [3, 7, 8, 9].contains(data['contentType']), + child: Positioned( + left: !data['isme'] ? 1 : null, + right: data['isme'] ? 1 : null, + top: 20.0, + child: RotatedBox( + quarterTurns: !data['isme'] ? 1 : -1, + child: CustomPaint(painter: ArrowShape(arrowColor: arrowColor(data))), + ) + ), + ), */ + Container( + child: child, + ), + ], + ), + ], + ), + ), + ), + data.isSelf ?? false + ? SizedBox( + height: 35.0, + width: 35.0, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + child: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), + ), + ) + : const SizedBox.shrink(), + ], + ), + ); + } +} + +// 绘制气泡箭头 +class ArrowShape extends CustomPainter { + ArrowShape({ + required this.arrowColor, + this.arrowSize = 7, + }); + + final Color arrowColor; // 箭头颜色 + final double arrowSize; // 箭头大小 + + @override + void paint(Canvas canvas, Size size) { + var paint = Paint()..color = arrowColor; + + var path = Path(); + path.lineTo(-arrowSize, 0); + path.lineTo(0, arrowSize); + path.lineTo(arrowSize, 0); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return false; + } +} diff --git a/lib/pages/chat/chat_no_friend.dart b/lib/pages/chat/chat_no_friend.dart new file mode 100644 index 0000000..d57b3cb --- /dev/null +++ b/lib/pages/chat/chat_no_friend.dart @@ -0,0 +1,2052 @@ +/// 聊天模板 +library; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_detail_controller.dart'; +import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_result.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/components/preview_video.dart'; +import 'package:loopin/models/conversation_type.dart'; +import 'package:loopin/utils/snapshot.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; + +import '../../styles/index.dart'; +import '../../utils/index.dart'; +import './components/redpacket.dart'; +import './components/richtext.dart'; +// import 'mock/chat_json.dart'; +import 'mock/emoj_json.dart'; + +class ChatNoFriend extends StatefulWidget { + const ChatNoFriend({super.key}); + + @override + State createState() => _ChatNoFriendState(); +} + +class _ChatNoFriendState extends State with SingleTickerProviderStateMixin { + late final ChatDetailController controller; + // 接收参数 + late final Rx arguments; + + late String selfUserId; + + // 聊天消息模块 + final bool isNeedScrollBottom = true; + + bool isLoading = false; // 是否在加载中 + bool hasMore = true; // 是否还有更多数据 + final RxBool _throttleFlag = false.obs; // 滚动节流锁 + + // 表情json + List emoJson = emotionData; + + // 底部操作栏模块 + TextEditingController editorController = TextEditingController(); + FocusNode editorFocusNode = FocusNode(); + bool voiceBtnEnable = false; // 语音按钮 + bool voicePanelEnable = false; // 语音操作面板 + bool voiceToTransfer = false; // 语音转文字中 + int voiceType = 0; // 语音操作类型 + Map voiceTypeMap = { + 0: '按住 说话', // 按住说话 + 1: '松开 发送', // 松开发送 + 2: '松开 取消', // 松开取消(左滑) + 3: '语音转文字', // 语音转文字(右滑) + }; + bool toolbarEnable = false; // 显示表情/选择区域 + int toolbarIndex = 0; // 0 表情 1 选择 + double keyboardHeight = 157.6; // 键盘高度 + List chooseOptions = [ + {'key': 'photo', 'name': '相册', 'icon': 'assets/images/icon_photo.webp'}, + {'key': 'camera', 'name': '拍摄', 'icon': 'assets/images/icon_camera.webp'}, + {'key': 'location', 'name': '位置', 'icon': 'assets/images/icon_location.webp'}, + {'key': 'redpacket', 'name': '红包', 'icon': 'assets/images/icon_hb.webp'}, + ]; + + // controller监听 + // ScrollController chatController = ScrollController(); + late ScrollController chatController; + + ScrollController emojController = ScrollController(); + + // 模拟开红包按钮动画 + late AnimationController animController; + + // 创建一个从 0 到 π 的旋转动画 + late Animation animTurns; + + // 初始化状态 + @override + void initState() { + super.initState(); + final arg = Get.arguments as V2TimConversation; + arguments = arg.obs; + controller = Get.find(); + chatController = controller.chatController; + + animController = AnimationController( + vsync: this, + duration: Duration(milliseconds: 500), + ); + animTurns = Tween(begin: 0, end: 3.1415926).animate(animController); + + cleanUnRead(); + + getUserId(); + + isMyFriend(); + + getMsgData(initFlag: true); + + // 编辑框获取焦点 + editorFocusNode.addListener(() { + if (editorFocusNode.hasFocus) { + setState(() { + toolbarEnable = false; + }); + controller.scrollToBottom(); + } + }); + // 滚动监听 + // Future.delayed(Duration(milliseconds: 1000), () { + + // }); + chatController.addListener(() { + if (_throttleFlag.value) return; + if (chatController.position.pixels >= chatController.position.maxScrollExtent - 50) { + // if (chatController.position.pixels <= 50) { + _throttleFlag.value = true; + getMsgData().then((_) { + // 解锁 + Future.delayed(Duration(milliseconds: 1000), () { + _throttleFlag.value = false; + }); + }); + } + }); + } + + @override + void dispose() { + if (Get.isRegistered()) { + Get.delete(); + } + emojController.dispose(); + editorFocusNode.dispose(); + animController.dispose(); + super.dispose(); + } + + // 设置好友备注 + void setRemark() async { + print('不支持'); + } + + void isMyFriend() async { + final isFriendId = arguments.value.userID; + final isfd = await ImService.instance.isMyFriend(isFriendId!, FriendTypeEnum.V2TIM_FRIEND_TYPE_BOTH); + if (isfd.success) { + controller.isFriend.value = isfd.data; + print(isfd.data); + } else { + controller.isFriend.value = false; + print(isfd.desc); + } + } + + bool checkSend() { + if (controller.isFriend.value) { + // 是好友直接发 + return true; + } else { + // 不是好友 + if (arguments.value.lastMessage != null) { + // 最后一条消息是否我发的 + // final isSelf = arguments.value.lastMessage!.isSelf; + final isSelf = controller.chatList.first.isSelf; + return isSelf == true ? false : true; + } else { + return true; + } + } + } + + void cleanUnRead() async { + if ((arguments.value.unreadCount ?? 0) > 0) { + final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.value.conversationID); + if (!res.success) { + MyDialog.toast('清理未读异常:${res.desc}', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + print('清理未读异常:${res.desc}'); + } + } + } + + void getUserId() async { + final idRes = await ImService.instance.selfUserId(); + if (idRes.success) { + selfUserId = idRes.data; + } + } + + Future getMsgData({bool initFlag = false}) async { + if (isLoading || !hasMore) return; // 正在加载 or 没有更多了 + + isLoading = true; + + // 获取最旧一条消息作为游标 + V2TimMessage? lastRealMsg; + // for (var msg in controller.chatList.reversed) { + for (var msg in controller.chatList.reversed) { + if (msg.localCustomData != 'time_label') { + lastRealMsg = msg; + break; + } + } + final lastMsg = lastRealMsg ?? arguments.value.lastMessage; // 如果找不到,就用传入的参数 + // print(lastMsg?.toJson()); + + // final lastMsg = controller.chatList.isNotEmpty ? controller.chatList.last : arguments.lastMessage; + + final res = await ImService.instance.getHistoryMessageList( + userID: arguments.value.userID, + lastMsg: lastMsg, + ); + + if (res.success) { + final newMessages = res.data ?? []; + + if (newMessages.isEmpty) { + hasMore = false; + // MyDialog.toast('没有更多了~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + if (initFlag && lastMsg != null) { + newMessages.insert(0, lastMsg); + // controller.scrollToBottom(); + } + controller.updateChatListWithTimeLabels(newMessages); + if (initFlag) { + // 初始化时滚到最底部 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (chatController.hasClients) { + // controller.scrollToBottom(); + // final bottomPadding = MediaQuery.of(context).padding.bottom; // 底部安全区域高度 + // chatController.jumpTo(chatController.position.maxScrollExtent); // 60为底部操作栏高度 + chatController.jumpTo(0); + } + }); + } + + print('聊天数据加载成功'); + } else { + MyDialog.toast("获取聊天记录失败:${res.desc}", icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + + isLoading = false; + } + + // 渲染聊天消息 + List renderChatList() { + List msgtpl = []; + for (var item in controller.chatList) { + // 时间提示,公告提示 + if (item.localCustomData == 'time_label') { + msgtpl.add(Container( + margin: const EdgeInsets.only(bottom: 15.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.customElem!.data ?? '', + style: TextStyle(color: Colors.grey[600], fontSize: 12.0), + ), + ], + ), + )); + } + // 文本消息模板=1 + else if (item.elemType == 1) { + msgtpl.add( + RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? Color(0xFFFFFFFF) : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + child: RichTextUtil.getRichText(item.textElem?.text ?? '', color: !(item.isSelf ?? false) ? Colors.black : Colors.white), // 可自定义解析emoj/网址/电话 + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + ), + ); + } + // gif表情模板=8 + else if (item.elemType == 8) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: Container( + constraints: const BoxConstraints( + maxHeight: 100.0, + maxWidth: 100.0, + ), + // child: Image.asset('assets/images/emotion/${item.faceElem?.data}'), + child: Image.asset('${item.faceElem?.data}'), + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 图片模板=3 + else if (item.elemType == 3) { + // List imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? []; + final originImage = item.imageElem?.imageList?.firstWhere((e) => e?.type == 0 && e?.url != null, orElse: () => null); + List imagePaths = originImage != null ? [originImage.url!] : []; + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: ClipRRect( + borderRadius: BorderRadius.circular(10.0), + // child: ImageGroup( + // images: imagePaths, + // width: 120, + // ), + child: Image.network( + imagePaths.first, + width: 120, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + // controller.scrollToBottom(); + return child; // 加载完成,显示图片 + } + return Container( + width: 120, + height: 240, + color: Colors.grey[300], + alignment: Alignment.center, + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[300], + alignment: Alignment.center, + child: Icon(Icons.broken_image, color: Colors.grey, size: 40), + ); + }, + ), + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 视频模板=5 + else if (item.elemType == 5) { + // print(item.videoElem!.toLogString()); + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: SizedBox( + width: 120.0, + child: Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: Image.network( + fit: BoxFit.cover, + item.videoElem?.snapshotUrl ?? '', + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/pic1.jpg', + height: 60.0, + width: 60.0, + fit: BoxFit.cover, + ); + }, + ), + ), + const Align( + alignment: Alignment.center, + child: Icon( + Icons.play_circle, + color: Colors.white, + size: 30.0, + ), + ), + ], + ), + ), + // onTap: () { + // MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + // }, + onTap: () { + showGeneralDialog( + context: context, + // barrierDismissible: true, + barrierColor: Colors.black.withAlpha((1.0 * 255).round()), + pageBuilder: (_, __, ___) { + return SafeArea( + child: PreviewVideo( + videoUrl: item.videoElem?.videoUrl ?? '', + width: item.videoElem?.snapshotWidth?.toDouble(), + height: item.videoElem?.snapshotHeight?.toDouble(), + ), + ); + }, + transitionBuilder: (_, anim, __, child) { + return FadeTransition(opacity: anim, child: child); + }, + transitionDuration: const Duration(milliseconds: 200), + ); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 语音模板=4 + else if (item.elemType == 4) { + List audiobody = [ + Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? const Color(0xFFFFFFFF) : const Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + constraints: BoxConstraints( + // maxWidth: 120.0, + maxWidth: (item.soundElem?.duration)! / 60 * 230, + ), + child: Row( + mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, + children: !(item.isSelf ?? false) + ? [ + const Icon(Icons.multitrack_audio), + const SizedBox( + width: 5.0, + ), + Text('${item.soundElem?.duration}'), + ] + : [ + Text('${item.soundElem?.duration}'), + const SizedBox( + width: 5.0, + ), + const Icon(Icons.multitrack_audio), + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + const SizedBox( + width: 5.0, + ), + FStyle.badge(0, isdot: true), + ]; + + if (item.isSelf ?? false) { + // 内容反转 + audiobody = audiobody.reversed.toList(); + } else { + audiobody = audiobody; + } + + msgtpl.add(RenderChatItem( + data: item, + child: Row( + mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, + children: audiobody, + ))); + } + // 红包模板=自定义=2; + else if (item.elemType == 2 && item.cloudCustomData == 'hongbao') { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: const Color(0xFFFF7F43), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + constraints: const BoxConstraints( + maxWidth: 210.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + spacing: 10.0, + children: [ + Image.asset( + 'assets/images/hbico.png', + width: 32.0, + fit: BoxFit.contain, + ), + Text(item.customElem?.data ?? '', style: const TextStyle(color: Colors.white, fontSize: 14.0)), + ], + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 10.0), + padding: const EdgeInsets.symmetric(vertical: 5.0), + width: double.infinity, + decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.white30, width: .5))), + child: const Text( + '拼手气红包', + style: TextStyle(color: Colors.white70, fontSize: 11.0), + ), + ), + ], + ), + ), + onTap: () { + receiveRedPacketDialog(item); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 位置模板=7 + else if (item.elemType == 7) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + // splashColor: Colors.transparent, + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + constraints: const BoxConstraints( + maxWidth: 210.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, + vertical: 5.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.locationElem?.desc ?? '位置信息异常', + overflow: TextOverflow.ellipsis, + ), + Text( + "${item.locationElem?.latitude},${item.locationElem?.longitude}", + style: const TextStyle(color: Colors.grey, fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ClipRRect( + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(10.0)), + child: Image.asset('assets/images/map.jpg', width: 210.0, height: 70.0, fit: BoxFit.cover), + ) + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + } + // msgtpl.insert( + // 0, + // SizedBox.shrink(), + // ); + return msgtpl; + } + + // 表情列表集合 + List renderEmojWidget() { + return [ + // Tab切换 + Container( + padding: const EdgeInsets.symmetric(horizontal: 5.0), + child: Row( + children: emoJson.map((item) { + return InkWell( + child: Container( + margin: const EdgeInsets.all(5.0), + alignment: Alignment.center, + height: 40.0, + width: 40.0, + decoration: BoxDecoration(color: item['selected'] ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(5.0)), + child: item['index'] == 0 + ? Text( + item['pathLabel'], + style: const TextStyle(fontSize: 22.0), + ) + : Image.asset(item['pathLabel'], height: 24.0, width: 24.0, fit: BoxFit.cover), + ), + onTap: () { + handleEmojTab(item['index']); + }, + ); + }).toList(), + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.grey[200], + border: const Border(top: BorderSide(color: Colors.black54, width: .1)), + ), + child: ListView( + controller: emojController, + padding: const EdgeInsets.all(10.0), + children: emoJson.map((item) { + return Visibility( + visible: item['selected'], + child: GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + // 横轴元素个数 + crossAxisCount: item['type'] == 'emoj' ? 8 : 5, + // 纵轴间距 + mainAxisSpacing: 5.0, + // 横轴间距 + crossAxisSpacing: 5.0, + // 子组件宽高比例 + childAspectRatio: 1, + ), + children: item['nodes'].map((emoj) { + if (item['type'] == 'emoj') { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(5.0), + child: Container( + alignment: Alignment.center, + height: 40.0, + width: 40.0, + child: Text( + emoj, + style: const TextStyle(fontSize: 24.0), + ), + ), + onTap: () { + handleEmojClick(emoj); + }, + ), + ); + } else { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(5.0), + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(5.0), + height: 68.0, + width: 68.0, + child: Image.asset(emoj), + ), + onTap: () { + handleGIFClick(emoj, item['index']); + }, + ), + ); + } + }).toList(), + ), + ); + }).toList(), + ), + ), + ), + ]; + } + + // 选择功能列表 + List renderChooseWidget() { + return [ + Expanded( + child: Container( + padding: const EdgeInsets.fromLTRB(30.0, 35.0, 30.0, 15.0), + decoration: BoxDecoration( + color: Colors.grey[200], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + // 横轴元素个数 + crossAxisCount: 4, + // 纵轴间距 + mainAxisSpacing: 30.0, + // 横轴间距 + crossAxisSpacing: 25.0, + // 子组件宽高比例 + childAspectRatio: .8, + ), + children: chooseOptions.map((item) { + return Column( + children: [ + Expanded( + child: Material( + type: MaterialType.transparency, + child: Ink( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + ), + child: InkWell( + borderRadius: BorderRadius.circular(15.0), + child: Image.asset(item['icon'], height: 40.0, fit: BoxFit.cover), + onTap: () { + handleChooseAction(item['key']); + }, + ), + ), + ), + ), + const SizedBox(height: 5.0), + Text( + item['name'], + style: const TextStyle(color: Colors.black87, fontSize: 12.0), + ) + ], + ); + }).toList(), + ), + ), + ), + ]; + } + + /* ---------- { 聊天消息模块 } ---------- */ + // 聊天消息滚动到底部 + // void scrollToBottom() async { + // chatList = await fetchChatList(); + // chatController.animateTo(isNeedScrollBottom ? 0 : chatController.position.maxScrollExtent, + // duration: const Duration(milliseconds: 200), curve: Curves.easeIn); + // } + + // void scrollToBottom() { + // Future.delayed(Duration(milliseconds: 300), () { + // if (chatController.hasClients) { + // chatController.animateTo( + // // 0, // reverse: true 时滚动到底部是 offset: 0 + // chatController.position.maxScrollExtent, + // duration: Duration(milliseconds: 300), + // curve: Curves.easeOut, + // ); + // } + // }); + // } + + // 点击消息区域 + void handleClickChatArea() { + hideKeyboard(); + setState(() { + toolbarEnable = false; + }); + } + + /* ---------- { 底部Toolbar模块 } ---------- */ + // 光标处插入内容 + void insertTextAtCursor(String html) { + var editorNotifier = editorController.value; // The current value stored in this notifier. + var start = editorNotifier.selection.baseOffset; + var end = editorNotifier.selection.extentOffset; + if (editorNotifier.selection.isValid) { + String newText = ''; + if (editorNotifier.selection.isCollapsed) { + if (end > 0) { + newText += editorNotifier.text.substring(0, end); + } + newText += html; + if (editorNotifier.text.length > end) { + newText += editorNotifier.text.substring(end, editorNotifier.text.length); + } + } else { + newText = editorNotifier.text.replaceRange(start, end, html); + end = start; + } + editorController.value = + editorNotifier.copyWith(text: newText, selection: editorNotifier.selection.copyWith(baseOffset: end + html.length, extentOffset: end + html.length)); + } else { + editorController.value = TextEditingValue( + text: html, + selection: TextSelection.fromPosition(TextPosition(offset: html.length)), + ); + } + } + + // 发送消息队列 + void sendMessage(message) async { + final canSend = checkSend(); + if (!canSend) { + final baseStyle = MyDialog.theme.toastStyle?.top(); + MyDialog.toast( + '对方未回关或回复,只能发送一条消息', + icon: const Icon(Icons.check_circle), + duration: Duration(milliseconds: 5000), + style: baseStyle?.copyWith( + backgroundColor: Colors.red.withAlpha(200), + ), + ); + + print('禁止发送$canSend'); + return; + } + // 待插入的消息 + List messagesToInsert = []; + V2TimMessage? lastRealMsg; + + for (var msg in controller.chatList) { + if (msg.localCustomData != 'time_label') { + lastRealMsg = msg; + break; + } + } + + // 如果有数据,检测时间,是否需要插入伪消息 + if (lastRealMsg != null && + needInsertTimeLabel( + (lastRealMsg.timestamp ?? 0) * 1000, // 转为毫秒级 + DateTime.now().millisecondsSinceEpoch, + )) { + // 消息时间间隔超过3分钟插入伪消息 + final showLabel = Utils().formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000); + final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId); + messagesToInsert.add(resMsg.data); + } else { + // 没数据的时候直接插入伪消息 + final showLabel = Utils().formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000); + + final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId); + messagesToInsert.add(resMsg.data); + } + + // 不需要时间标签 + // 消息类型,message['type'] + late final ImResult res; + res = await IMMessage().sendMessage( + msg: message, + toUserID: arguments.value.userID, + cloudCustomData: canSend == true ? ConversationType.noFriend.name : '', + ); + + if (res.success && res.data != null) { + messagesToInsert.insert(0, res.data); // 加入消息本体 + // messagesToInsert.add(res.data); // 加入消息本体 + + controller.chatList.insertAll(0, messagesToInsert); + // controller.chatList.addAll(messagesToInsert); + + controller.scrollToBottom(); + print('发送成功'); + } else { + print('消息发送失败: ${res.code} - ${res.desc}'); + } + } + + bool needInsertTimeLabel(int lastTimestamp, int newTimestamp, {int interval = 3 * 60}) { + return (newTimestamp - lastTimestamp) > interval * 1000; + } + + // 隐藏键盘 + void hideKeyboard() { + if (editorFocusNode.hasFocus) { + editorFocusNode.unfocus(); + } + } + + // 表情/选择切换 + void handleEmojChooseState(index) { + hideKeyboard(); + setState(() { + toolbarEnable = true; + toolbarIndex = index; + voiceBtnEnable = false; + }); + controller.scrollToBottom(); + } + + // 表情Tab切换 + void handleEmojTab(index) { + var emols = emoJson; + for (var i = 0, len = emols.length; i < len; i++) { + emols[i]['selected'] = false; + } + emols[index]['selected'] = true; + setState(() { + emoJson = emols; + }); + emojController.jumpTo(0); + } + + // 点击表情插入到输入框 + void handleEmojClick(emoj) { + insertTextAtCursor(emoj); + } + + // 点击Gif大图发送=8 + void handleGIFClick(gifpath, index) async { + // 消息队列 + // Map message = { + // 'contentType': 8, + // 'content': gifpath, + // }; + final res = await IMMessage().createFaceMessage(data: gifpath, index: index); + if (res.success) { + sendMessage(res.data?.messageInfo); + } + } + + // 发送文本消息=1 + void handleSubmit() async { + if (editorController.text.isEmpty) return; + // 消息队列 + // Map message = { + // 'contentType': 1, + // 'content': editorController.text, + // }; + final res = await IMMessage().createTextMessage(text: editorController.text); + if (res.success) { + sendMessage(res.data?.messageInfo); + editorController.clear(); + } + } + + // 发送图片消息=3 + void sendImage(imgPath) async { + final resImg = await IMMessage().createImageMessage(imagePath: imgPath); + if (resImg.success) { + sendMessage(resImg.data?.messageInfo); + } + } + + // 发送视频消息=5 + void sendVideo(videoFilePath, type, duration, snapshotPath) async { + final resImg = await IMMessage().createVideoMessage( + videoFilePath: videoFilePath, + type: type, + duration: duration, + snapshotPath: snapshotPath, + ); + if (resImg.success) { + sendMessage(resImg.data?.messageInfo); + } + } + + // 底部操作蓝选择区操作 + void handleChooseAction(key) { + MyDialog.toast('$key'); + switch (key) { + case 'photo': + // .... + pickFile(context); + break; + case 'camera': + // .... + break; + case 'redpacket': + sendRedPacketDialog(); + break; + } + } + + ///从相册选取图片/视频 + void pickFile(BuildContext context) async { + final pickedAssets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + textDelegate: const AssetPickerTextDelegate(), + pathNameBuilder: (AssetPathEntity album) { + return Utils.translateAlbumName(album); + }, + maxAssets: 5, + requestType: RequestType.common, + filterOptions: FilterOptionGroup( + imageOption: const FilterOption(), + videoOption: const FilterOption( + durationConstraint: DurationConstraint( + max: Duration(seconds: 120), + ), + ), + ), + ), + ); + + if (pickedAssets != null && pickedAssets.isNotEmpty) { + for (final asset in pickedAssets) { + switch (asset.type) { + case AssetType.image: + print("选中了图片:${asset.title}"); + var file = await asset.file; + if (file != null) { + var fileSizeInBytes = await file.length(); + var sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 28) { + MyDialog.toast('图片大小不能超过28MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + // 执行发送逻辑 + sendImage(file.path); + } + } + + break; + case AssetType.video: + print("选中了视频:${asset.title}"); + var file = await asset.file; + if (file != null) { + var fileSizeInBytes = await file.length(); + var sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 100) { + MyDialog.toast('视频大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("视频合法,大小:$sizeInMB MB"); + // 执行发送逻辑 + var snapshot = await generateVideoThumbnail(file.path); + String? mimeType = await asset.mimeTypeAsync; + String vdType = mimeType?.split('/').last ?? 'mp4'; + print(vdType); + sendVideo(file.path, vdType, asset.duration, snapshot); + } + } + break; + default: + print("不支持的类型:${asset.type}"); + } + } + // final asset = pickedAssets.first; + // final file = await asset.file; // 获取实际文件 + // if (file != null) { + // final fileSizeInBytes = await file.length(); + // final sizeInMB = fileSizeInBytes / (1024 * 1024); + // if (sizeInMB > 100) { + // MyDialog.toast('图片大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + // } else { + // print("图片合法,大小:$sizeInMB MB"); + // //走upload(file)上传图片拿到url地址 + // // file; + // } + // } + } + } + + /* ---------- { 弹窗功能模块 } ---------- */ + // 红包弹窗 + void receiveRedPacketDialog(data) { + showDialog( + context: context, + builder: (context) { + return Material( + type: MaterialType.transparency, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 50.0), + padding: const EdgeInsets.symmetric(vertical: 50.0), + decoration: const BoxDecoration( + color: Color(0xFFFF7F43), + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5.0), + child: Image.asset(data['avatar'], height: 40.0, width: 40.0, fit: BoxFit.cover), + ), + const SizedBox( + height: 5.0, + ), + Text( + data['author'], + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w600), + ), + Text( + data['content'], + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), + ), + SizedBox( + height: 100.0, + ), + AnimatedBuilder( + animation: animTurns, + builder: (context, child) { + return Transform( + transform: Matrix4.rotationY(animTurns.value), + alignment: Alignment.center, + child: FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(const Color(0xFFFFF9C7)), + padding: WidgetStateProperty.all(EdgeInsets.zero), + minimumSize: WidgetStateProperty.all(const Size(80.0, 80.0)), + shape: WidgetStateProperty.all(const CircleBorder()), + elevation: WidgetStateProperty.all(3.0), + ), + child: Text( + '開', + style: TextStyle(color: Color(0xFF3B3B3B), fontSize: 28.0), + ), + onPressed: () { + // 开始动画 + animController.repeat(); + // 模拟开红包逻辑,1 秒后停止动画 + Future.delayed(Duration(seconds: 1), () { + animController.stop(); + animController.reset(); + Get.back(); + }); + }, + ), + ); + }, + ), + ], + ), + ), + GestureDetector( + child: Container( + margin: const EdgeInsets.only(top: 20.0), + height: 30.0, + width: 30.0, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 1.5), + borderRadius: BorderRadius.circular(50.0), + ), + child: const Icon( + Icons.close_outlined, + color: Colors.white, + size: 18.0, + ), + ), + onTap: () { + Navigator.of(context).pop(); + }, + ) + ], + )); + }); + } + + // 长按消息菜单 + void contextMenuDialog() { + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + backgroundColor: Colors.white, + surfaceTintColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 5.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), + children: [ + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('复制')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('发送给朋友')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('收藏')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('删除')), + onPressed: () {}, + ), + ], + ); + }, + ); + } + + // 发群红包弹窗 + void sendRedPacketDialog() { + showModalBottomSheet( + backgroundColor: Colors.grey[50], + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))), + showDragHandle: true, + clipBehavior: Clip.hardEdge, + isScrollControlled: true, // 屏幕最大高度 + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height - 180, // 自定义最大高度 + ), + context: context, + builder: (context) { + return RedPacket( + flag: false, + ); + }, + ); + } + + /* ---------- { 其它功能模块 } ---------- */ + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + // 页面主体(聊天消息区/底部操作区) + Scaffold( + backgroundColor: Colors.grey[200], + resizeToAvoidBottomInset: true, // 启用键盘自动避让 + appBar: AppBar( + centerTitle: true, + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios_rounded, + size: 20.0, + ), + onPressed: () { + Get.back(); + }, + ), + titleSpacing: 1.0, + title: Obx(() { + return Text( + // '${arguments['title']}', + '${arguments.value.showName}', + style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'), + ); + }), + flexibleSpace: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFBE4EFF), Color(0xFF1DFFC7)], + )), + ), + actions: [ + IconButton( + icon: const Icon( + Icons.more_horiz, + color: Colors.white, + ), + onPressed: () async { + final paddingTop = MediaQuery.of(Get.context!).padding.top; + + final selected = await showMenu( + context: Get.context!, + position: RelativeRect.fromLTRB( + double.infinity, + kToolbarHeight + paddingTop - 12, + 8, + double.infinity, + ), + color: FStyle.primaryColor, + elevation: 8, + items: [ + PopupMenuItem( + value: 'remark', + child: Row( + children: [ + Icon(Icons.edit, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设置备注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'not', + child: Row( + children: [ + Icon(Icons.do_not_disturb_on, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设为免打扰', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'report', + child: Row( + children: [ + Icon(Icons.report, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '举报', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'block', + child: Row( + children: [ + Icon(Icons.block, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '拉黑', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'foucs', + child: Row( + children: [ + Icon(Icons.person_remove_alt_1, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '取消关注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ); + + if (selected != null) { + switch (selected) { + case 'remark': + print('点击了备注'); + setRemark(); + break; + case 'not': + print('点击了免打扰'); + break; + case 'report': + print('点击了举报'); + break; + case 'block': + print('点击了拉黑'); + break; + case 'foucs': + print('点击了取关'); + break; + } + } + }, + ), + ], + ), + body: Flex( + direction: Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 渲染聊天消息 + // Expanded( + // child: ScrollConfiguration( + // behavior: CustomScrollBehavior(), + // child: GestureDetector( + // child: Obx(() { + // return ListView( + // reverse: true, + // controller: chatController, + // padding: const EdgeInsets.all(10.0), + // children: renderChatList(), + // ); + // }), + // onTap: () { + // handleClickChatArea(); + // }, + // ), + // ), + // ), + + // 聊天内容 + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: handleClickChatArea, + child: LayoutBuilder( + builder: (context, constraints) { + return Obx(() { + final msgWidgets = renderChatList().reversed.toList(); + + return ListView( + controller: chatController, + reverse: true, + padding: const EdgeInsets.all(10.0), + children: [ + ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: msgWidgets, + ), + ), + ], + ); + }); + }, + ), + ), + ), + + // 底部操作栏 + Container( + color: Colors.grey[100], + child: SafeArea( + bottom: true, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[100], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: Column( + children: [ + // 输入框编辑器模块 + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + children: [ + InkWell( + child: Icon( + voiceBtnEnable ? Icons.keyboard_outlined : Icons.contactless_outlined, + color: const Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + setState(() { + toolbarEnable = false; + if (voiceBtnEnable) { + voiceBtnEnable = false; + editorFocusNode.requestFocus(); + } else { + voiceBtnEnable = true; + editorFocusNode.unfocus(); + } + }); + }, + ), + const SizedBox( + width: 10.0, + ), + Expanded( + child: Container( + constraints: const BoxConstraints(minHeight: 40.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + child: Stack( + children: [ + // 输入框 + Offstage( + offstage: voiceBtnEnable, + child: TextField( + decoration: const InputDecoration( + isDense: true, + hoverColor: Colors.transparent, + contentPadding: EdgeInsets.all(8.0), + border: OutlineInputBorder(borderSide: BorderSide.none), + ), + style: const TextStyle( + fontSize: 16.0, + ), + maxLines: null, + controller: editorController, + focusNode: editorFocusNode, + cursorColor: const Color(0xFF07C160), + onChanged: (value) {}, + ), + ), + // 语音 + Offstage( + offstage: !voiceBtnEnable, + child: GestureDetector( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + alignment: Alignment.center, + height: 40.0, + width: double.infinity, + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(fontSize: 15.0), + ), + ), + onPanStart: (details) { + setState(() { + voiceType = 1; + voicePanelEnable = true; + }); + }, + onPanUpdate: (details) { + Offset pos = details.globalPosition; + double swipeY = MediaQuery.of(context).size.height - 120; + double swipeX = MediaQuery.of(context).size.width / 2 + 50; + setState(() { + if (pos.dy >= swipeY) { + voiceType = 1; // 松开发送 + } else if (pos.dy < swipeY && pos.dx < swipeX) { + voiceType = 2; // 左滑松开取消 + } else if (pos.dy < swipeY && pos.dx >= swipeX) { + voiceType = 3; // 右滑语音转文字 + } + }); + }, + onPanEnd: (details) { + // print('停止录音'); + setState(() { + switch (voiceType) { + case 1: + MyDialog.toast('发送录音文件'); + voicePanelEnable = false; + break; + case 2: + MyDialog.toast('取消发送'); + voicePanelEnable = false; + break; + case 3: + MyDialog.toast('语音转文字'); + voicePanelEnable = true; + voiceToTransfer = true; + break; + } + voiceType = 0; + }); + }, + ), + ), + ], + ), + ), + ), + const SizedBox( + width: 10.0, + ), + InkWell( + child: const Icon( + Icons.add_reaction_rounded, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(0); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: const Icon( + Icons.add, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(1); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: Container( + height: 25.0, + width: 25.0, + decoration: BoxDecoration( + color: const Color(0xFF07C160), + borderRadius: BorderRadius.circular(20.0), + ), + child: const Icon( + Icons.arrow_upward, + color: Colors.white, + size: 20.0, + ), + ), + onTap: () { + handleSubmit(); + }, + ), + ], + ), + ), + + // 表情+选择模块 + Visibility( + visible: toolbarEnable, + child: SizedBox( + height: keyboardHeight, + child: Column( + children: toolbarIndex == 0 ? renderEmojWidget() : renderChooseWidget(), + ), + ), + ) + ], + ), + ), + ), + ) + ], + ), + ), + // 录音主体(按住说话/松开取消/语音转文本) + IgnorePointer( + ignoring: false, + child: Visibility( + visible: voicePanelEnable, + child: Material( + color: const Color(0xDD1B1B1B), + child: Stack( + children: [ + // 取消发送+语音转文字 + Positioned( + bottom: 120, + left: 30, + right: 30, + child: Visibility( + visible: !voiceToTransfer, + child: Column( + crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center, + children: [ + // 语音动画层 + Stack( + alignment: Alignment.bottomCenter, + children: [ + AnimatedContainer( + duration: Duration(milliseconds: 200), + height: 70.0, + width: voiceType == 2 ? 70.0 : 200.0, + decoration: BoxDecoration( + color: voiceType == 2 ? Colors.red : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(15.0), + ), + clipBehavior: Clip.antiAlias, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset('assets/images/voice_waves.gif', height: 23.0, width: voiceType == 2 ? 30.0 : 70.0, fit: BoxFit.cover) + ], + ), + ), + RotatedBox( + quarterTurns: 0, + child: CustomPaint(painter: ArrowShape(arrowColor: voiceType == 2 ? Colors.red : Color(0xFF89E45B), arrowSize: 10.0)), + ) + ], + ), + const SizedBox( + height: 50.0, + ), + // 操作项 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 取消发送 + Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: voiceType == 2 ? Colors.red : Colors.black38, + ), + child: Icon( + Icons.close, + color: Colors.white54, + ), + ), + // 语音转文字 + Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: voiceType == 3 ? Color(0xFF89E45B) : Colors.black38, + ), + child: Icon( + Icons.translate, + color: Colors.white54, + ), + ), + ], + ), + ], + ), + ), + ), + // 语音转文字(识别结果状态) + Positioned( + bottom: 120, + left: 30, + right: 30, + child: Visibility( + visible: voiceToTransfer, + child: Column( + children: [ + // 提示结果 + Stack( + children: [ + Container( + height: 100.0, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(15.0), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outlined, + color: Colors.white, + ), + Text( + '未识别到文字。', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + Positioned( + right: 35.0, + bottom: 1, + child: RotatedBox( + quarterTurns: 0, + child: CustomPaint(painter: ArrowShape(arrowColor: Colors.red, arrowSize: 10.0)), + )), + ], + ), + const SizedBox( + height: 50.0, + ), + // 操作项 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + child: Container( + height: 60.0, + width: 60.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.undo, + color: Colors.white54, + ), + Text( + '取消', + style: TextStyle(color: Colors.white70), + ) + ], + ), + ), + onTap: () { + setState(() { + voicePanelEnable = false; + voiceToTransfer = false; + }); + }, + ), + GestureDetector( + child: Container( + height: 60.0, + width: 100.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.graphic_eq_rounded, + color: Colors.white54, + ), + Text( + '发送原语音', + style: TextStyle(color: Colors.white70), + ) + ], + ), + ), + onTap: () {}, + ), + GestureDetector( + child: Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: Colors.white12, + ), + child: const Icon( + Icons.check, + color: Colors.white12, + ), + ), + onTap: () {}, + ), + ], + ), + ], + ), + ), + ), + // 提示文字(操作状态) + Positioned( + bottom: 120, + left: 0, + width: MediaQuery.of(context).size.width, + child: Visibility( + visible: !voiceToTransfer, + child: Align( + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(color: Colors.white70), + ), + ), + ), + ), + // 背景 + Align( + alignment: Alignment.bottomCenter, + child: Visibility( + visible: !voiceToTransfer, + child: Image.asset('assets/images/voice_bg.webp', width: double.infinity, height: 100.0, fit: BoxFit.fill), + ), + ), + // 背景图标 + Positioned( + bottom: 25, + left: 0, + width: MediaQuery.of(context).size.width, + child: Visibility( + visible: !voiceToTransfer, + child: const Align( + child: Icon( + Icons.graphic_eq_rounded, + color: Colors.black54, + ), + ), + ), + ), + ], + ), + ), + ), + ) + ], + ); + } +} + +// 渲染聊天消息公共部分 +class RenderChatItem extends StatelessWidget { + const RenderChatItem({ + super.key, + required this.data, + required this.child, + }); + final V2TimMessage data; // 消息数据 + final Widget? child; // 消息体 + + // 设置箭头颜色 + // Color arrowColor(data) { + // Color color = Colors.transparent; + // if ([8].contains(data.elemType)) { + // // 红包箭头颜色 + // color = const Color(0xFFFFA52F); + // } else if ([9].contains(data.elemType)) { + // // 位置箭头颜色 + // color = const Color(0xFFFFFFFF); + // } else { + // color = !data['isme'] ? const Color(0xFFFFFFFF) : const Color(0xFF9543FF); + // } + // return color; + // } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + !(data.isSelf ?? false) + ? SizedBox( + height: 35.0, + width: 35.0, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + child: Image.network( + data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png', + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/pic1.jpg', + height: 60.0, + width: 60.0, + fit: BoxFit.cover, + ); + }, + ), + ), + ) + : const SizedBox.shrink(), + Expanded( + child: Padding( + padding: !(data.isSelf ?? false) ? const EdgeInsets.only(left: 10.0, right: 40.0) : const EdgeInsets.only(left: 40.0, right: 10.0), + child: Column( + crossAxisAlignment: !(data.isSelf ?? false) ? CrossAxisAlignment.start : CrossAxisAlignment.end, + children: [ + Text( + data.friendRemark ?? data.nameCard ?? data.nickName ?? '未知昵称', + style: const TextStyle(color: Colors.grey, fontSize: 12.0), + ), + const SizedBox( + height: 3.0, + ), + Stack( + children: [ + // 气泡箭头 + /* Visibility( + // 显示箭头(消息+语音+红包+位置) + visible: [3, 7, 8, 9].contains(data['contentType']), + child: Positioned( + left: !data['isme'] ? 1 : null, + right: data['isme'] ? 1 : null, + top: 20.0, + child: RotatedBox( + quarterTurns: !data['isme'] ? 1 : -1, + child: CustomPaint(painter: ArrowShape(arrowColor: arrowColor(data))), + ) + ), + ), */ + Container( + child: child, + ), + ], + ), + ], + ), + ), + ), + data.isSelf ?? false + ? SizedBox( + height: 35.0, + width: 35.0, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + child: Image.network( + data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png', + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/pic1.jpg', + height: 60.0, + width: 60.0, + fit: BoxFit.cover, + ); + }, + ), + ), + ) + : const SizedBox.shrink(), + ], + ), + ); + } +} + +// 绘制气泡箭头 +class ArrowShape extends CustomPainter { + ArrowShape({ + required this.arrowColor, + this.arrowSize = 7, + }); + + final Color arrowColor; // 箭头颜色 + final double arrowSize; // 箭头大小 + + @override + void paint(Canvas canvas, Size size) { + var paint = Paint()..color = arrowColor; + + var path = Path(); + path.lineTo(-arrowSize, 0); + path.lineTo(0, arrowSize); + path.lineTo(arrowSize, 0); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return false; + } +} diff --git a/lib/pages/chat/components/redpacket.dart b/lib/pages/chat/components/redpacket.dart index 4764edf..26e46a6 100644 --- a/lib/pages/chat/components/redpacket.dart +++ b/lib/pages/chat/components/redpacket.dart @@ -2,20 +2,41 @@ library; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; class RedPacket extends StatefulWidget { - const RedPacket({super.key}); + final bool flag; // true=群,false=单 + final void Function(Map)? onSend; + final int? maxNum; // 红包最大数量 + const RedPacket({super.key, required this.flag, this.onSend, this.maxNum}); - @override - State createState() => _RedPacketState(); + @override + State createState() => _RedPacketState(); } class _RedPacketState extends State { - String amount = '0.00'; + final TextEditingController _amountController = TextEditingController(); + final TextEditingController _maxNumController = TextEditingController(); + final TextEditingController _remarkController = TextEditingController(); - @override - Widget build(BuildContext context) { - return Material( + String amount = '0.00'; + String remark = '恭喜发财,大吉大利'; + // 限制只能输入数字和小数点,且最多两位小数 + final List _decimalInputFormatters = [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')), + ]; + @override + void dispose() { + _amountController.dispose(); + _maxNumController.dispose(); + _remarkController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( type: MaterialType.transparency, child: Column( children: [ @@ -23,55 +44,67 @@ class _RedPacketState extends State { shrinkWrap: true, padding: const EdgeInsets.only(bottom: 50.0), children: [ - const SizedBox(height: 10.0), - Container( - margin: const EdgeInsets.symmetric(horizontal: 15.0), - padding: const EdgeInsets.symmetric(horizontal: 10.0), - decoration: BoxDecoration( - color: Colors.white, borderRadius: BorderRadius.circular(10.0), - ), - child: Row( - children: [ - const Text('红包个数'), - Expanded( - child: TextField( - textAlign: TextAlign.right, - decoration: const InputDecoration( - hintText: "填写个数", - isDense: true, - hintStyle: TextStyle(fontSize: 14.0), - border: OutlineInputBorder(borderSide: BorderSide.none) + if (widget.flag) const SizedBox(height: 10.0), + if (widget.flag) + Container( + margin: const EdgeInsets.symmetric(horizontal: 15.0), + padding: const EdgeInsets.symmetric(horizontal: 10.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10.0), + ), + child: Row( + children: [ + const Text('红包个数'), + Expanded( + child: TextField( + maxLength: widget.maxNum, + controller: _maxNumController, + textAlign: TextAlign.right, + buildCounter: (_, {required currentLength, maxLength, required isFocused}) => null, // 隐藏计数器 + decoration: const InputDecoration( + hintText: "填写个数", isDense: true, hintStyle: TextStyle(fontSize: 14.0), border: OutlineInputBorder(borderSide: BorderSide.none)), + onChanged: (value) { + // 输入的红包个数 + setState(() { + remark = value; + }); + }, ), - onChanged: (value) {}, ), - ), - const Text('个'), - ], + const Text('个'), + ], + ), ), - ), const SizedBox(height: 10.0), Container( margin: const EdgeInsets.symmetric(horizontal: 15.0), padding: const EdgeInsets.symmetric(horizontal: 10.0), decoration: BoxDecoration( - color: Colors.white, borderRadius: BorderRadius.circular(10.0), + color: Colors.white, + borderRadius: BorderRadius.circular(10.0), ), child: Row( children: [ const Text('总金额'), Expanded( child: TextField( + controller: _amountController, keyboardType: const TextInputType.numberWithOptions(decimal: true), textAlign: TextAlign.right, + inputFormatters: _decimalInputFormatters, + maxLength: 6, + buildCounter: (_, {required currentLength, maxLength, required isFocused}) => null, // 隐藏计数器 decoration: const InputDecoration( - hintText: "¥0.00", - isDense: true, - hintStyle: TextStyle(fontSize: 14.0), - border: OutlineInputBorder(borderSide: BorderSide.none) - ), + hintText: "¥0.00", isDense: true, hintStyle: TextStyle(fontSize: 14.0), border: OutlineInputBorder(borderSide: BorderSide.none)), onChanged: (value) { + double val = double.tryParse(value) ?? 0.0; + if (val > 200) { + _amountController.text = '200'; + val = 200; + } setState(() { - amount = value != '' ? value : '0.00'; + amount = val.toStringAsFixed(2); }); }, ), @@ -85,7 +118,8 @@ class _RedPacketState extends State { margin: const EdgeInsets.symmetric(horizontal: 15.0), padding: const EdgeInsets.symmetric(horizontal: 10.0), decoration: BoxDecoration( - color: Colors.white, borderRadius: BorderRadius.circular(10.0), + color: Colors.white, + borderRadius: BorderRadius.circular(10.0), ), child: Row( children: [ @@ -93,15 +127,26 @@ class _RedPacketState extends State { Expanded( child: TextField( maxLines: null, + maxLength: 16, + controller: _remarkController, keyboardType: TextInputType.multiline, textAlign: TextAlign.right, + buildCounter: (_, {required currentLength, maxLength, required isFocused}) => null, // 隐藏计数器 decoration: const InputDecoration( - hintText: "恭喜发财,大吉大利", - isDense: true, - hintStyle: TextStyle(fontSize: 14.0), - border: OutlineInputBorder(borderSide: BorderSide.none) - ), - onChanged: (value) {}, + hintText: "恭喜发财,大吉大利", + isDense: true, + hintStyle: TextStyle(fontSize: 14.0), + border: OutlineInputBorder(borderSide: BorderSide.none)), + onChanged: (value) { + // 留言内容 + setState(() { + if (value.isEmpty) { + remark = '恭喜发财,大吉大利'; + } else { + remark = value; + } + }); + }, ), ), ], @@ -109,12 +154,11 @@ class _RedPacketState extends State { ), const SizedBox(height: 30.0), Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('¥', style: TextStyle(fontSize: 24.0)), Text(amount, style: const TextStyle(fontSize: 36.0)) - ] + mainAxisAlignment: MainAxisAlignment.center, + children: [const Text('¥', style: TextStyle(fontSize: 24.0)), Text(amount, style: const TextStyle(fontSize: 36.0))]), + const SizedBox( + height: 20.0, ), - const SizedBox(height: 20.0,), UnconstrainedBox( constrainedAxis: Axis.vertical, child: FilledButton( @@ -122,23 +166,51 @@ class _RedPacketState extends State { backgroundColor: WidgetStateProperty.all(Color(0xFFFF7F43)), padding: WidgetStateProperty.all(EdgeInsets.zero), minimumSize: WidgetStateProperty.all(const Size(180.0, 45.0)), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)) - ), + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0))), + ), + onPressed: () { + double amountDouble = double.tryParse(amount) ?? 0.0; + if (amountDouble > 0) { + //发送红包 + widget.onSend!( + { + 'maxNum': widget.maxNum ?? 1, + 'amount': amount, + 'remark': remark, + }, + ); + } else { + final baseStyle = MyDialog.theme.toastStyle?.top(); + MyDialog.toast( + '未输入金额', + icon: const Icon(Icons.check_circle), + duration: Duration(milliseconds: 5000), + style: baseStyle?.copyWith( + backgroundColor: Colors.red.withAlpha(200), + ), + ); + } + }, + child: const Text( + '塞钱进红包', + style: TextStyle(fontSize: 16.0), ), - onPressed: () {}, - child: const Text('塞钱进红包', style: TextStyle(fontSize: 16.0),), ), ), - const SizedBox(height: 10.0,), + const SizedBox( + height: 10.0, + ), const Align( alignment: Alignment.center, - child: Text('未领取的红包,将于24小时后发起退款', style: TextStyle(color: Colors.grey, fontSize: 12.0),), + child: Text( + '未领取的红包,将于24小时后发起退款', + style: TextStyle(color: Colors.grey, fontSize: 12.0), + ), ), ], ), ], ), ); - } + } } diff --git a/lib/pages/chat/index.dart b/lib/pages/chat/index.dart index ab47c61..9bb40e8 100644 --- a/lib/pages/chat/index.dart +++ b/lib/pages/chat/index.dart @@ -8,6 +8,7 @@ import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/global_badge.dart'; import 'package:loopin/IM/im_service.dart'; import 'package:loopin/components/scan_util.dart'; +import 'package:loopin/models/conversation_view_model.dart'; import 'package:loopin/utils/parse_message_summary.dart'; import 'package:shirne_dialog/shirne_dialog.dart'; @@ -30,6 +31,14 @@ class ChatPageState extends State { controller = Get.find(); } + void deletConv(context, ConversationViewModel item) async { + final res = await ImService.instance.deleteConversation(conversationID: item.conversation.conversationID); + if (res.success) { + Navigator.of(context).pop(); + controller.chatList.remove(item); + } + } + // 长按坐标点 double posDX = 0.0; double posDY = 0.0; @@ -41,7 +50,7 @@ class ChatPageState extends State { } // 长按菜单 - void showContextMenu(BuildContext context) { + void showContextMenu(BuildContext context, ConversationViewModel item) { bool isLeft = posDX > MediaQuery.of(context).size.width / 2 ? false : true; bool isTop = posDY > MediaQuery.of(context).size.height / 2 ? false : true; @@ -92,7 +101,9 @@ class ChatPageState extends State { style: TextStyle(color: Colors.black87, fontSize: 14.0), ), dense: true, - onTap: () {}, + onTap: () { + deletConv(context, item); + }, ) ], ), @@ -306,6 +317,7 @@ class ChatPageState extends State { onRefresh: handleRefresh, child: Obx(() { final chatList = controller.chatList; + return ListView.builder( shrinkWrap: true, physics: BouncingScrollPhysics(), @@ -322,11 +334,35 @@ class ChatPageState extends State { children: [ // 头图 ClipOval( - child: Image.network( - chatList[index].faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png', - height: 50.0, - width: 50.0, - fit: BoxFit.cover, + child: Builder( + builder: (context) { + final faceUrl = chatList[index].faceUrl; + final isNetwork = + faceUrl != null && faceUrl.isNotEmpty && (faceUrl.startsWith('http://') || faceUrl.startsWith('https://')); + if (isNetwork) { + return Image.network( + faceUrl, + height: 50.0, + width: 50.0, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/pic1.jpg', + height: 50.0, + width: 50.0, + fit: BoxFit.cover, + ); + }, + ); + } else { + return Image.asset( + (faceUrl != null && faceUrl.isNotEmpty) ? faceUrl : 'assets/images/pic1.jpg', + height: 50.0, + width: 50.0, + fit: BoxFit.cover, + ); + } + }, ), ), @@ -335,9 +371,17 @@ class ChatPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Text( + // chatList[index].conversation.showName ?? '', + // style: const TextStyle(fontSize: 16.0), + // ), Text( chatList[index].conversation.showName ?? '', - style: const TextStyle(fontSize: 16.0), + style: TextStyle( + fontSize: (chatList[index].conversation.conversationGroupList?.isNotEmpty ?? false) ? 20 : 16, + fontWeight: + (chatList[index].conversation.conversationGroupList?.isNotEmpty ?? false) ? FontWeight.bold : FontWeight.normal, + ), ), const SizedBox(height: 2.0), Text( @@ -351,6 +395,7 @@ class ChatPageState extends State { ), ), // 右侧 + Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -396,7 +441,7 @@ class ChatPageState extends State { posDY = details.globalPosition.dy; }, onLongPress: () { - showContextMenu(context); + showContextMenu(context, chatList[index]); }, ), ); diff --git a/lib/pages/goods/detail.dart b/lib/pages/goods/detail.dart index baf8857..5c00772 100644 --- a/lib/pages/goods/detail.dart +++ b/lib/pages/goods/detail.dart @@ -1,9 +1,24 @@ /// 商品详情页 library; +import 'dart:convert'; + import 'package:card_swiper/card_swiper.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; +import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/api/shop_api.dart'; +import 'package:loopin/components/my_toast.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; +import 'package:loopin/models/summary_type.dart'; +import 'package:loopin/service/http.dart'; +import 'package:loopin/utils/index.dart'; +import 'package:loopin/utils/wxsdk.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; import '../../behavior/custom_scroll_behavior.dart'; import '../../components/backtop.dart'; @@ -16,18 +31,29 @@ class Goods extends StatefulWidget { } class _GoodsState extends State { + // late int shopId; //商品id + dynamic shopObj; late ScrollController scrollController = ScrollController(); + final ChatController chatController = Get.find(); // 滚动位置 double scrollOffset = 0; + // 分享列表 + List shareList = [ + {'icon': 'assets/images/share-wx.png', 'label': '好友'}, + {'icon': 'assets/images/share-wx.png', 'label': '微信'}, + {'icon': 'assets/images/share-pyq.png', 'label': '朋友圈'}, + ]; @override void initState() { super.initState(); + final shopId = Get.arguments; scrollController.addListener(() { setState(() { scrollOffset = scrollController.offset; }); }); + shopDetail(shopId); } @override @@ -36,8 +62,199 @@ class _GoodsState extends State { super.dispose(); } + ///商品详情 + void shopDetail(shopId) async { + try { + final res = await Http.get('${ShopApi.shopDetail}/$shopId'); + logger.e(res['data']); + setState(() { + shopObj = res['data']; // 注意取 data 部分 + }); + } catch (e) { + logger.e(e); + Get.back(); + } + } + + void handleShareClick(int index) { + final description = shopObj['describe']; // 商品描述 + if (index == 1) { + // 好友 + Wxsdk.shareToFriend(title: '快看看我分享的商品', description: description, webpageUrl: 'https://baidu.com'); + } else if (index == 2) { + // 朋友圈 + Wxsdk.shareToTimeline(title: '快看看我分享的商品', webpageUrl: 'https://baidu.com'); + } + } + + void handlCoverClick(V2TimConversation conv) async { + // 发送VideoMsg,获取当前视频信息 + final userId = conv.userID; + //price,title,url,sell + final makeJson = jsonEncode({ + "price": shopObj['price'], + "title": shopObj['describe'], + "url": shopObj['pic'], + "sell": Utils().graceNumber(int.parse(shopObj['sales'] ?? '0')), + }); + final res = await IMMessage().createCustomMessage( + data: makeJson, + ); + if (res.success) { + final sendRes = await IMMessage().sendMessage(msg: res.data!.messageInfo!, toUserID: userId, cloudCustomData: SummaryType.shareTuangou); + if (sendRes.success) { + MyToast().tip( + title: '分享成功', + position: 'center', + type: 'success', + ); + Get.back(); + } else { + logger.e(res.desc); + } + } else { + logger.e(res.desc); + } + } + + // 分享弹框 + void handleShare() { + if (chatController.chatList.isNotEmpty) { + chatController.getConversationList(); + } + showModalBottomSheet( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(15.0)), + ), + clipBehavior: Clip.antiAlias, + context: context, + isScrollControlled: true, + builder: (context) { + return Material( + color: Colors.white, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 分享列表 + SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: shareList.length, + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + return GestureDetector( + onTap: () => handleShareClick(index), + child: Container( + width: 64, + margin: EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('${shareList[index]['icon']}', width: 48.0), + SizedBox(height: 5), + Text( + '${shareList[index]['label']}', + style: TextStyle(fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + ), + + // 会话列表 + Obx(() { + // 这里过滤掉有分组的会话 + final filteredList = chatController.chatList.where((item) => item.conversation.conversationGroupList?.isEmpty == true).toList(); + if (filteredList.isEmpty) return SizedBox.shrink(); + return SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: filteredList.length, + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + return GestureDetector( + // 点击分享 + onTap: () => handlCoverClick(filteredList[index].conversation), + child: Container( + width: 64, + margin: EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Image.asset('${chatController.chatList[index].faceUrl}', width: 48.0), + NetworkOrAssetImage( + imageUrl: filteredList[index].faceUrl, + width: 48.0, + height: 48.0, + ), + SizedBox(height: 5), + Text( + '${filteredList[index].conversation.showName}', + style: TextStyle(fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + ); + }), + + // 取消按钮 + SafeArea( + top: false, + child: InkWell( + onTap: () => Get.back(), + child: Container( + alignment: Alignment.center, + width: double.infinity, + height: 50.0, + color: Colors.grey[50], + child: Text( + '取消', + style: TextStyle(color: Colors.black87), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { + if (shopObj == null) { + return Center(child: CircularProgressIndicator()); + } + String swiperInfo = shopObj['albumPics'] ?? ""; + List swiperList; + if (swiperInfo.isNotEmpty) { + swiperList = swiperInfo.split(','); // 商品详情轮播图 + } else { + swiperList = []; + } + dynamic attr = shopObj['productAttr']; //json数据 + List attrList = []; + if (!Utils.isEmpty(attr)) { + attrList = jsonDecode(attr); + } + logger.e(attrList); + return Scaffold( backgroundColor: Colors.grey[50], body: CustomScrollView( @@ -63,32 +280,26 @@ class _GoodsState extends State { }, ), actions: [ - IconButton( - icon: Icon( - Icons.search, - size: 20.0, - ), - onPressed: () {}, - ), - IconButton( - icon: Icon( - Icons.favorite_border, - size: 20.0, - ), - onPressed: () {}, - ), IconButton( icon: Icon( Icons.share, size: 20.0, ), - onPressed: () {}, + onPressed: () { + // 分享 + handleShare(); + }, ), ], // 自定义伸缩区域(轮播图) flexibleSpace: Container( - decoration: - BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF5000), Color(0xFFFFAA00)])), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF5000), Color(0xFFFFAA00)], + ), + ), child: FlexibleSpaceBar( background: ScrollConfiguration( behavior: CustomScrollBehavior(), @@ -99,20 +310,7 @@ class _GoodsState extends State { activeColor: Colors.white, )), indicatorLayout: PageIndicatorLayout.SCALE, - children: [ - Image.network( - 'https://img13.360buyimg.com/n1/jfs/t1/263909/5/4187/123220/676eb220F3e481086/0cee829b1894fc4c.jpg', - fit: BoxFit.cover, - ), - Image.network( - 'https://img13.360buyimg.com/n1/jfs/t1/245928/34/24374/150795/673b0a0cFdb8831f9/9235d1ed7654aa44.jpg', - fit: BoxFit.cover, - ), - Image.network( - 'https://img30.360buyimg.com/n1/jfs/t1/240005/25/26374/136411/6756e1f9Fb685b2ec/3be83b3e1a08169d.jpg', - fit: BoxFit.cover, - ), - ], + children: swiperList.map((sw) => NetworkOrAssetImage(imageUrl: sw)).toList(), ), ), ), @@ -126,7 +324,12 @@ class _GoodsState extends State { Container( padding: EdgeInsets.fromLTRB(15.0, 10.0, 15.0, 25.0), decoration: BoxDecoration( - gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF5000), Color(0xFFFFAA00)])), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF5000), Color(0xFFFFAA00)], + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 5.0, @@ -134,16 +337,17 @@ class _GoodsState extends State { Row( spacing: 5.0, children: [ - Text( - '¥3838', - style: TextStyle( - color: Colors.white, - fontSize: 16.0, - decoration: TextDecoration.lineThrough, - decorationColor: Colors.black, - decorationThickness: 1.5, - ), - ), + // 原价 + // Text( + // '¥${shopObj['price']}', + // style: TextStyle( + // color: Colors.white, + // fontSize: 16.0, + // decoration: TextDecoration.lineThrough, + // decorationColor: Colors.black, + // decorationThickness: 1.5, + // ), + // ), Container( padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 3.0), decoration: BoxDecoration( @@ -151,21 +355,23 @@ class _GoodsState extends State { borderRadius: BorderRadius.circular(50.0), ), child: Text( - '现价¥3800', + '¥${shopObj['price']}', style: TextStyle(color: Colors.red, fontSize: 12.0), ), ), + Text( + // '已售${Utils().graceNumber(shopObj['sales'] ?? 0)}', + '已售${Utils().graceNumber(int.tryParse(shopObj['sales']?.toString() ?? '0') ?? 0)}', + style: TextStyle(color: Colors.white, fontSize: 12.0), + ), ], ), - Text( - '已售1.1w', - style: TextStyle(color: Colors.white, fontSize: 12.0), - ), ], ), ), Container( padding: EdgeInsets.fromLTRB(10.0, 10.0, 10.0, 0), + width: double.infinity, decoration: BoxDecoration( color: Color(0xFFFAFAFA), borderRadius: BorderRadius.vertical(top: Radius.circular(15.0)), @@ -176,18 +382,45 @@ class _GoodsState extends State { // 标题 Container( padding: EdgeInsets.all(5.0), - child: Text.rich( - TextSpan(children: [ - TextSpan(text: ' 年货节 ', style: TextStyle(fontSize: 11.0, backgroundColor: const Color(0xFFFF5000), color: Colors.white)), + child: Align( + alignment: Alignment.centerLeft, + child: Text.rich( TextSpan( - text: ' 茅台(MOUTAI)飞天 53度 酱香型白酒 500ml*2 海外版送礼袋年货送礼', - style: TextStyle(fontSize: 14.0, fontWeight: FontWeight.w700), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFFF5000), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + shopObj['productCategoryName'] ?? '未知分类名称', + style: const TextStyle( + fontSize: 12.0, + color: Colors.white, + ), + ), + ), + ), + const WidgetSpan(child: SizedBox(width: 4)), + TextSpan( + text: '${shopObj['describe'] ?? ''}', + style: TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w700, + ), + ), + ], ), - ]), - maxLines: 2, - overflow: TextOverflow.ellipsis, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + ), ), ), + // 规格 Container( margin: EdgeInsets.only(top: 10.0), @@ -196,84 +429,90 @@ class _GoodsState extends State { color: Colors.white, borderRadius: BorderRadius.circular(15.0), ), + // child: Column( + // spacing: 10.0, + // children: [ + // Row( + // spacing: 5.0, + // children: [ + // Icon( + // Icons.timer, + // size: 16.0, + // ), + // Expanded( + // child: Text( + // '本商品请于2025.01.25前进行核销', + // style: TextStyle(fontSize: 12.0), + // ), + // ), + // ], + // ), + // Row( + // spacing: 5.0, + // children: [ + // Icon( + // Icons.house_outlined, + // size: 16.0, + // ), + // Expanded( + // child: Text( + // '营业时间:7x24', + // style: TextStyle(fontSize: 12.0), + // ), + // ), + // ], + // ), + // Row( + // spacing: 5.0, + // children: [ + // Icon( + // Icons.location_on, + // size: 16.0, + // ), + // Expanded( + // child: Text( + // '河北省唐山市玉田县', + // style: TextStyle(fontSize: 12.0), + // ), + // ), + // ], + // ), + // ], + // ), child: Column( - spacing: 10.0, - children: [ - Row( - spacing: 5.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: attrList.map((attr) { + final attrName = attr['name'] ?? ''; + final options = attr['options'] as List? ?? []; + final optionNames = options.map((o) => o['name']).join(' / '); + return Row( children: [ - Icon( - Icons.timer, - size: 16.0, + Text( + '$attrName: ', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ), Expanded( child: Text( - '本商品请于2025.01.25前进行核销', - style: TextStyle(fontSize: 12.0), + optionNames, + style: TextStyle(fontSize: 12), ), ), ], - ), - Row( - spacing: 5.0, - children: [ - Icon( - Icons.house_outlined, - size: 16.0, - ), - Expanded( - child: Text( - '营业时间:7x24', - style: TextStyle(fontSize: 12.0), - ), - ), - ], - ), - Row( - spacing: 5.0, - children: [ - Icon( - Icons.location_on, - size: 16.0, - ), - Expanded( - child: Text( - '河北省唐山市玉田县', - style: TextStyle(fontSize: 12.0), - ), - ), - ], - ), - ], + ); + }).toList(), ), ), // 详情 Container( - margin: EdgeInsets.only(top: 10.0), - padding: EdgeInsets.all(10.0), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(15.0), - ), - child: Column( - spacing: 10.0, - children: [ - Text('【飞天茅台】传承悠久,酱香型白酒典范,四大名酒之一。 【爆款直降】纯粮酿造,固态发酵。 【精髓制作】工艺精湛,入口绵、落口甜、饮后余香。'), - Image.network( - 'https://img30.360buyimg.com/n1/jfs/t1/187328/18/54595/115429/6756e1c7F126ab0d4/fe96f6fd5dfe125d.jpg', - fit: BoxFit.contain, - ), - Image.network( - 'https://img30.360buyimg.com/n1/jfs/t1/240005/25/26374/136411/6756e1f9Fb685b2ec/3be83b3e1a08169d.jpg', - fit: BoxFit.contain, - ), - Image.network( - 'https://img30.360buyimg.com/n1/jfs/t1/247398/2/28177/97778/6756da95F518f621c/746dc23032c171ca.jpg', - fit: BoxFit.contain, - ), - ], - ), - ), + margin: EdgeInsets.only(top: 10.0), + padding: EdgeInsets.all(10.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + ), + child: Html( + data: shopObj['detailMobileHtml'] ?? '暂无', + )), ], ), ), @@ -296,50 +535,66 @@ class _GoodsState extends State { child: Row( spacing: 15.0, children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.store, - color: Color(0xFFFF5000), - size: 18.0, - ), - Text( - '店铺', - style: TextStyle(fontSize: 12.0), - ) - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.child_care_outlined, - size: 18.0, - ), - Text( - '客服', - style: TextStyle(fontSize: 12.0), - ) - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Badge.count( - backgroundColor: Color(0xFFFF5000), - count: 6, - child: Icon( - Icons.shopping_cart_outlined, + // Column( + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // Icon( + // Icons.store, + // color: Color(0xFFFF5000), + // size: 18.0, + // ), + // Text( + // '店铺', + // style: TextStyle(fontSize: 12.0), + // ) + // ], + // ), + GestureDetector( + onTap: () async { + // 可以在这里打开聊天、拨打电话等 + logger.i('联系客服'); + final res = await ImService.instance.getConversation(conversationID: 'c2c_${shopObj['shoperId']}'); + V2TimConversation conversation = res.data; + logger.i(conversation.toLogString()); + if (res.success) { + // 客服聊天不用检测关注关系 + conversation.showName = conversation.showName ?? shopObj['storeName']; + Get.toNamed('/chat', arguments: conversation); + } else { + MyDialog.toast(res.desc, icon: const Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.child_care_outlined, size: 18.0, ), - ), - Text( - '购物车', - style: TextStyle(fontSize: 12.0), - ) - ], - ), + Text( + '联系商家', + style: TextStyle(fontSize: 12.0), + ) + ], + ), + ) + // Column( + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // Badge.count( + // backgroundColor: Color(0xFFFF5000), + // count: 6, + // child: Icon( + // Icons.shopping_cart_outlined, + // size: 18.0, + // ), + // ), + // Text( + // '购物车', + // style: TextStyle(fontSize: 12.0), + // ) + // ], + // ), ], ), ), @@ -353,19 +608,19 @@ class _GoodsState extends State { ), child: Row( children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 10.0), - child: Text( - '加入购物车', - style: TextStyle(color: Color(0xFFFF5000), fontSize: 14.0), - ), - ), + // Padding( + // padding: EdgeInsets.symmetric(horizontal: 10.0), + // child: Text( + // '加入购物车', + // style: TextStyle(color: Color(0xFFFF5000), fontSize: 14.0), + // ), + // ), Container( alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 20.0), color: Color(0xFFFF5000), child: Text( - '领券购买', + '立即购买', style: TextStyle(color: Colors.white, fontSize: 14.0), ), ), diff --git a/lib/pages/index/index.dart b/lib/pages/index/index.dart index da2cd54..f04c58d 100644 --- a/lib/pages/index/index.dart +++ b/lib/pages/index/index.dart @@ -1,18 +1,15 @@ /// 首页模板 library; -import 'dart:ui'; - import 'package:card_swiper/card_swiper.dart'; +import 'package:easy_refresh/easy_refresh.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:get/get.dart'; -import 'package:loopin/IM/im_message.dart'; -import 'package:loopin/components/custom_sticky_header.dart'; - -import '../../behavior/custom_scroll_behavior.dart'; -import '../../components/backtop.dart'; -import '../../components/loading.dart'; +import 'package:loopin/components/backtop.dart'; +import 'package:loopin/components/loading.dart'; +import 'package:loopin/controller/shop_index_controller.dart'; +import 'package:loopin/utils/index.dart'; class IndexPage extends StatefulWidget { const IndexPage({super.key}); @@ -23,133 +20,25 @@ class IndexPage extends StatefulWidget { class _IndexPageState extends State with SingleTickerProviderStateMixin { // 分类列表 - List cateList = [ - { - 'id': 1, - 'list': [ - { - 'icon': 'order.svg', - 'label': '我的订单', - }, - { - 'icon': 'chongzhi.svg', - 'label': '充值中心', - }, - {'icon': 'qianbao.svg', 'label': '余额'}, - {'icon': 'comment.svg', 'label': '评价中心'} - ] - } - ]; - - List tabList = ['推荐', '美食', '娱乐', '文旅', '医疗', '房产']; - - // 瀑布流列表 - List waterfallData = [ - { - 'price': 199.00, - 'title': '韩料界的萨莉亚!', - 'shop': '萨莉亚专卖店', - 'image': 'https://qcloud.dpfile.com/pc/1c3egbzM_ICz90dhi6MAiTsazjxWYQcHCd-sbpD1Wqtph2eIJA04NCRvoGqL4_opG45IiB1YIyNuDTtqzVRwesm_qA1Pf8rFcayTY-n-rG8.jpg', - 'saleNum': '2.1万' - }, - { - 'price': 1499.90, - 'title': '茅台(MOUTAI)飞天 53%vol 500ml 贵州茅台酒(带杯)', - 'shop': '茅台京东自营旗舰店', - 'image': 'https://img13.360buyimg.com/n1/jfs/t1/97097/12/15694/245806/5e7373e6Ec4d1b0ac/9d8c13728cc2544d.jpg', - 'saleNum': '1254' - }, - { - 'price': 18.90, - 'title': '上海街头苹果糖!一口一个不吱声', - 'shop': '芝洛洛自营旗舰店', - 'image': 'https://p0.meituan.net/coverpic/f0eefdfa02619fb09ca53eacd4d97231123115.jpg', - 'saleNum': '1.2万' - }, - { - 'price': 59.00, - 'title': '谁懂,就是这个菜,尝了第一口,立马决定加单了,真正的咸甜永动机啊🍬 去过云南的朋友都知道,当地的乳扇真的很好吃。', - 'shop': '薄荷牛舌卷旗舰店', - 'image': 'https://qcloud.dpfile.com/pc/UcW-v6AN1TxVTt9--5Kaw2-t4W55jUhEG_pM5S-w_AQ4IP3z9WxHzwJ9fOthIjEYY0q73sB2DyQcgmKUxZFQtw.jpg', - 'saleNum': '1639' - }, - { - 'price': 2499.00, - 'title': '小米 REDMI K80 国家补贴 第三代骁龙 8 6550mAh大电池 澎湃OS 玄夜黑 12GB+256GB 红米5G至尊手机', - 'shop': '小米京东自营旗舰店', - 'image': 'https://img10.360buyimg.com/n1/s450x450_jfs/t1/264409/38/13856/102861/678dcfdaFb723c58f/5b97cf154bbba96c.jpg', - 'saleNum': '9726' - }, - { - 'price': 1.00, - 'title': '圣菲尔伯爵法国红酒Saintfilcount干红葡萄酒珍藏13.5度单瓶送礼红酒 一元试饮', - 'shop': '小森葡萄酒专营店', - 'image': 'https://img10.360buyimg.com/n7/jfs/t1/226168/23/3411/118733/65537e5fF2db2d109/7d1d11a8013d6e8f.jpg', - 'saleNum': '9.9万' - }, - { - 'price': 42.00, - 'title': '美的(Midea)LED便携充电小台灯书桌学习阅读灯学生宿舍卧室床头灯学习台灯', - 'shop': '美的(Midea)旗舰店', - 'image': 'https://img14.360buyimg.com/mobilecms/s360x360_jfs/t1/226233/4/10194/156936/658e8f88Fcfc9cb40/cea4a48783f11a7a.jpg', - 'saleNum': '5106' - }, - { - 'price': 22.90, - 'title': '蒙都 羊杂500g 加热即食 京东超市肉干肉脯及礼包11.11真便宜', - 'shop': '蒙都旗舰店', - 'image': 'https://img10.360buyimg.com/n7/jfs/t1/155306/32/25324/231912/62d22fb8E4ffab855/c6001ee702fb240a.jpg', - 'saleNum': '1.6万' - }, - { - 'price': 19.90, - 'title': '『 江西炒米粉 』本次最佳😋香就一个字话。锅气的香🔥干辣椒的焦香🌶️油的润香🐷蔬菜混合的清香🥬', - 'shop': '去月球野餐嗎', - 'image': 'https://qcloud.dpfile.com/pc/pOAOL-DQRBWfkVZIWYVoy0mMQf6_UutNlOpEpGkT_nz3b1n7ZbpikPgtXMhMsjXNY0q73sB2DyQcgmKUxZFQtw.jpg', - 'saleNum': '3.2万' - }, - { - 'price': 109.00, - 'title': '附近新开业的,作为江西人当然要去试试。点了几个家常菜。', - 'shop': '辣评新开江西菜', - 'image': 'https://qcloud.dpfile.com/pc/HePD48CFNnS0kMZyf3Q391wxaW_zVgHimctthH__J6UI54HLPUkNt5e3qtP4Nl2G_aW_B6sGElzX-tSmYRvRnQxxxek7cKy7_R0W-KdxWUk.jpg', - 'saleNum': '8764' - }, - ]; - // 列表 - List dataList = []; - // 是否加载中 - bool isLoading = false; - - late ScrollController scrollController = ScrollController(); - late TabController tabController = TabController(initialIndex: 0, length: tabList.length, vsync: this); - final PageController pageController = PageController(); - // 滚动位置 - double scrollOffset = 0; - - // 加载更多 - loadMoreData() async { - setState(() { - isLoading = true; - }); - await Future.delayed(Duration(seconds: 1)); - setState(() { - dataList.addAll(waterfallData); - isLoading = false; - }); - } - - // 下拉刷新 - Future handleRefresh() async { - await Future.delayed(Duration(seconds: 1)); - dataList.clear(); - for (int i = 0; i < waterfallData.length; i++) { - dataList.add(waterfallData[i]); - } - if (mounted) { - setState(() {}); - } - } + // List cateList = [ + // { + // 'id': 1, + // 'list': [ + // { + // 'icon': 'order.svg', + // 'label': '我的订单', + // }, + // { + // 'icon': 'chongzhi.svg', + // 'label': '充值中心', + // }, + // {'icon': 'qianbao.svg', 'label': '余额'}, + // {'icon': 'comment.svg', 'label': '评价中心'} + // ] + // } + // ]; + final ScrollController pageScrollController = ScrollController(); + final ShopIndexController controller = Get.put(ShopIndexController()); // 瀑布流卡片 Widget cardList(item) { @@ -166,7 +55,7 @@ class _IndexPageState extends State with SingleTickerProviderStateMix ]), child: Column( children: [ - Image.network('${item['image']}'), + Image.network('${item['pic']}'), Container( padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), child: Column( @@ -174,7 +63,7 @@ class _IndexPageState extends State with SingleTickerProviderStateMix spacing: 5.0, children: [ Text( - '${item['title']}', + '${item['name']}', style: TextStyle(fontSize: 14.0, height: 1.2), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -193,13 +82,13 @@ class _IndexPageState extends State with SingleTickerProviderStateMix ]), ), Text( - '已售${item['saleNum']}件', + '已售${Utils().graceNumber(int.parse(item['sales'] ?? '0'))}件', style: TextStyle(color: Colors.grey, fontSize: 10.0), ), ], ), Text( - '${item['shop']}', + '${item['storeName']}', style: TextStyle(color: Colors.grey, fontSize: 12.0), ), ], @@ -209,7 +98,7 @@ class _IndexPageState extends State with SingleTickerProviderStateMix ), ), onTap: () { - Get.toNamed('/goods'); + Get.toNamed('/goods', arguments: item['id']); }, ); } @@ -217,282 +106,194 @@ class _IndexPageState extends State with SingleTickerProviderStateMix @override void initState() { super.initState(); - scrollController.addListener(() { - setState(() { - scrollOffset = scrollController.offset; - }); - if (scrollController.position.pixels == scrollController.position.maxScrollExtent) { - debugPrint('[index]滚动到底部'); - if (!isLoading) { - loadMoreData(); - } - } - }); - // 初始化加载 - handleRefresh(); - } - - @override - void dispose() { - scrollController.dispose(); - tabController.dispose(); - pageController.dispose(); - super.dispose(); + controller.initTabs(); } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[50], - body: ScrollConfiguration( - behavior: CustomScrollBehavior().copyWith(scrollbars: false), - child: CustomScrollView( - scrollBehavior: CustomScrollBehavior().copyWith(scrollbars: false), - controller: scrollController, - slivers: [ - SliverAppBar( - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - pinned: true, - expandedHeight: 200.0, - titleSpacing: 10.0, - // 搜索框(高斯模糊背景) - title: ClipRRect( - borderRadius: BorderRadius.circular(30.0), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), - child: Container( - height: 45.0, - decoration: BoxDecoration( - color: Colors.white.withAlpha(200), - ), - child: TextField( - decoration: InputDecoration( - isDense: true, - hintText: "2025百亿补贴", - hintStyle: TextStyle(fontSize: 15.0), - prefixIcon: Icon( - Icons.search, - color: Colors.black38, - size: 21.0, - ), - suffixIcon: Container( - padding: EdgeInsets.only(right: 15.0), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 10.0, - children: [ - Icon( - Icons.keyboard_voice, - color: Colors.black45, - size: 21.0, - ), - Icon( - Icons.camera_alt_outlined, - color: Colors.black45, - size: 21.0, - ), - ], - ), - ), - contentPadding: EdgeInsets.symmetric(vertical: 0, horizontal: 10.0), - border: OutlineInputBorder(borderSide: BorderSide.none, borderRadius: BorderRadius.circular(30.0))), - cursorColor: Colors.black, - onChanged: (val) { - debugPrint(val); - }, - ), - ), - ), + return Obx(() { + final tabIndex = controller.currentTabIndex.value; + final currentTab = controller.tabs[tabIndex]; + + return Scaffold( + backgroundColor: Colors.grey[50], + body: Column( + children: [ + // 顶部固定区域(轮播图 + TabBar) + _buildTopSection(), + + // 内容区域 + Expanded( + child: TabBarView( + controller: controller.tabController, + children: controller.tabList.asMap().entries.map((entry) { + final index = entry.key; + return _buildTabContent(index); + }).toList(), ), - actions: [ - IconButton( - icon: Icon(Icons.shopping_cart_outlined), - onPressed: () {}, - ), - ], - // 自定义伸缩区域(轮播图) - flexibleSpace: Container( - decoration: BoxDecoration( - gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF5000), Color(0xFFfcaec4)])), - child: FlexibleSpaceBar( - background: Swiper.children( - pagination: SwiperPagination( - builder: DotSwiperPaginationBuilder( + ), + ], + ), + floatingActionButton: currentTab != null + ? Backtop( + controller: currentTab.scrollController, + offset: currentTab.scrollOffset.value, + ) + : null, + ); + }); + } + + // 构建顶部固定区域 + Widget _buildTopSection() { + double screenWidth = MediaQuery.of(context).size.width; + int tabCount = controller.tabList.length; + // 每个 Tab 的最小宽度 + double minTabWidth = 80; + // 是否可滚动 + bool isScrollable = tabCount * minTabWidth > screenWidth; + return Column( + children: [ + // 轮播图 + Container( + width: double.infinity, + height: 240, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF5000), Color(0xFFfcaec4)], + ), + ), + child: controller.swiperData.length <= 1 + ? (controller.swiperData.isNotEmpty + ? Image.network( + controller.swiperData.first['images'] ?? '', + fit: BoxFit.fill, + ) + : const SizedBox.shrink()) + : Swiper( + itemCount: controller.swiperData.length, + autoplay: true, + loop: true, + pagination: SwiperPagination( + builder: DotSwiperPaginationBuilder( color: Colors.white70, activeColor: Colors.white, - )), - indicatorLayout: PageIndicatorLayout.SCALE, - children: [ - Image.network( - 'https://m.360buyimg.com/babel/jfs/t20271217/224114/35/38178/150060/6760d559Fd654f946/968c156726b6e822.png', - fit: BoxFit.fill, - ), - Image.network( - 'https://m.360buyimg.com/babel/jfs/t20280117/88832/5/48468/139826/6789cbcfF4e0b2a3d/9dc54355b6f65c40.jpg', - fit: BoxFit.fill, - ), - Image.network( - 'https://m.360buyimg.com/babel/jfs/t20280108/255505/29/10540/137372/677ddbc1F6cdbbed0/bc477fadedef22a8.jpg', - fit: BoxFit.fill, - ), - ], - ), - ), - ), - ), - - // 分类 - // SliverToBoxAdapter( - // child: Container( - // margin: EdgeInsets.all(10.0), - // padding: EdgeInsets.symmetric(vertical: 10.0), - // height: 90.0, - // clipBehavior: Clip.antiAlias, - // decoration: BoxDecoration( - // color: Colors.white, - // borderRadius: BorderRadius.circular(15.0), - // ), - // child: Column( - // children: [ - // Expanded( - // child: PageView.builder( - // controller: pageController, - // itemCount: cateList.length, - // itemBuilder: (context, index) { - // final item = cateList[index]; - // return GridView.builder( - // shrinkWrap: true, - // padding: EdgeInsets.zero, - // physics: NeverScrollableScrollPhysics(), - // gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - // crossAxisCount: 4, - // ), - // itemCount: item['list'].length, - // itemBuilder: (BuildContext context, int index) { - // final citem = item['list'][index]; - // // return Column( - // // spacing: 3.0, - // // children: [ - // // if (citem['icon'] != null) - // // SvgPicture.asset( - // // 'assets/images/svg/${citem['icon']}', - // // height: 30.0, - // // width: 30.0, - // // ), - // // Text(citem['label']), - // // ], - // // ); - // return GestureDetector( - // onTap: () { - // logger.i('点击了$index'); - // // 跳转逻辑,用你自己的目标路由替换 - // Get.toNamed('/order'); - // }, - // child: Column( - // mainAxisSize: MainAxisSize.min, - // children: [ - // if (citem['icon'] != null) - // SvgPicture.asset( - // 'assets/images/svg/${citem['icon']}', - // height: 30.0, - // width: 30.0, - // ), - // Text(citem['label']), - // ], - // ), - // ); - // }, - // ); - // }, - // ), - // ), - // CustomPageViewIndicator( - // controller: pageController, - // count: cateList.length, - // color: Color(0xFFCECECE), - // activeColor: Color(0xFFFF5000), - // ), - // ], - // )), - // ), - - // tabbar列表 - SliverPersistentHeader( - pinned: true, - delegate: CustomStickyHeader( - child: PreferredSize( - preferredSize: Size.fromHeight(45.0), - child: Container( - color: Colors.white, - height: 45.0, - child: TabBar( - controller: tabController, - onTap: (index) { - logger.i('点击了第 $index 个 tab'); - }, - tabs: tabList.map((v) => Tab(text: v)).toList(), - isScrollable: false, - overlayColor: WidgetStateProperty.all(Colors.transparent), - unselectedLabelColor: Colors.black87, - labelColor: Color(0xFFFF5000), - indicatorColor: Color(0xFFFF5000), - indicatorSize: TabBarIndicatorSize.tab, - unselectedLabelStyle: TextStyle(fontSize: 15.0, fontFamily: 'Microsoft YaHei'), - labelStyle: TextStyle(fontSize: 15.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.w700), - dividerHeight: 0, - padding: EdgeInsets.symmetric(horizontal: 10.0), - labelPadding: EdgeInsets.symmetric(horizontal: 7.5), - indicatorPadding: EdgeInsets.symmetric(horizontal: 15.0, vertical: 5.0), ), ), + itemBuilder: (context, index) { + final imageUrl = controller.swiperData[index]['images'] ?? ''; + return imageUrl.isNotEmpty ? Image.network(imageUrl, fit: BoxFit.fill) : const SizedBox.shrink(); + }, ), - ), - ), + ), - // 瀑布流列表 + // TabBar + Container( + color: Colors.white, + child: TabBar( + controller: controller.tabController, + tabs: controller.tabList.map((item) { + return Tab( + child: Text(item['name'], style: const TextStyle(fontWeight: FontWeight.bold)), + ); + }).toList(), + isScrollable: isScrollable, + overlayColor: WidgetStateProperty.all(Colors.transparent), + unselectedLabelColor: Colors.black87, + labelColor: Color.fromARGB(255, 236, 108, 49), + indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Color.fromARGB(255, 236, 108, 49), width: 2.0)), + unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), + labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), + dividerHeight: 0, + ), + ), + ], + ); + } + + // 构建标签页内容 + Widget _buildTabContent(int index) { + final tabState = controller.tabs[index]!; + + return Obx(() { + if (tabState.dataList.isEmpty && tabState.isLoading.value) { + return Center( + child: RefreshProgressIndicator( + backgroundColor: Colors.white, + color: Color(0xFFFF5000), + ), + ); + } + + // 添加 下拉刷新 + return EasyRefresh( + onRefresh: () async { + await controller.refreshData(index); + }, + header: ClassicHeader( + dragText: '下拉刷新', + armedText: '释放刷新', + readyText: '刷新中...', + processingText: '刷新完成', + messageText: '最后更新于 %T', + ), + child: CustomScrollView( + controller: tabState.scrollController, + key: PageStorageKey('tab_$index'), + physics: const AlwaysScrollableScrollPhysics(), // 确保可下拉 + slivers: [ + SliverPadding( + padding: EdgeInsets.fromLTRB(10, 10, 10, 10), + sliver: tabState.dataList.isEmpty + ? SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).size.height - 500, // 给个足够高度让下拉触发 + child: Center(child: _emptyTip('暂无数据')), + ), + ) + : SliverMasonryGrid.count( + crossAxisCount: 2, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + childCount: tabState.dataList.length, + itemBuilder: (context, idx) => cardList(tabState.dataList[idx]), + ), + ), SliverToBoxAdapter( - child: Container( - padding: EdgeInsets.all(10.0), - child: Column( - children: [ - dataList.isEmpty - ? - // 初始loading提示 - Column( - children: [ - RefreshProgressIndicator( - backgroundColor: Colors.white, - color: Color(0xFFFF5000), - ), - ], - ) - : MasonryGridView.count( - shrinkWrap: true, - padding: EdgeInsets.zero, - physics: NeverScrollableScrollPhysics(), - crossAxisCount: 2, - mainAxisSpacing: 10.0, - crossAxisSpacing: 10.0, - itemCount: dataList.length + (isLoading ? 1 : 0), - itemBuilder: (BuildContext context, int index) { - if (index < dataList.length) { - return cardList(dataList[index]); - } else { - return SizedBox.shrink(); - } - }, - ), - Opacity(opacity: dataList.isNotEmpty && isLoading ? 1 : 0, child: Loading(title: 'loading...')), - ], + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: tabState.isLoading.value + ? const Loading(title: 'loading...') + : (tabState.dataList.isNotEmpty ? const Text('没有更多数据了') : const SizedBox.shrink()), ), ), ), ], ), + ); + }); + } + + // 空状态提示 + Widget _emptyTip(String text) { + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 50), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/images/empty.png', width: 100), + const SizedBox(height: 8), + Text( + text, + style: const TextStyle(color: Colors.grey, fontSize: 13), + ), + ], + ), ), - // 返回顶部 - floatingActionButton: Backtop(controller: scrollController, offset: scrollOffset), ); } } diff --git a/lib/pages/index/indexcopy.dart b/lib/pages/index/indexcopy.dart new file mode 100644 index 0000000..5d0eb6a --- /dev/null +++ b/lib/pages/index/indexcopy.dart @@ -0,0 +1,497 @@ +/// 首页模板 +library; + +import 'dart:ui'; + +import 'package:card_swiper/card_swiper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:get/get.dart'; +import 'package:loopin/behavior/custom_scroll_behavior.dart'; +import 'package:loopin/components/backtop.dart'; +import 'package:loopin/components/custom_sticky_header.dart'; +import 'package:loopin/components/loading.dart'; +import 'package:loopin/components/only_down_scroll_physics.dart'; +import 'package:loopin/controller/shop_index_controller.dart'; + +class IndexPage extends StatefulWidget { + const IndexPage({super.key}); + + @override + State createState() => _IndexPageState(); +} + +class _IndexPageState extends State with SingleTickerProviderStateMixin { + // 分类列表 + // List cateList = [ + // { + // 'id': 1, + // 'list': [ + // { + // 'icon': 'order.svg', + // 'label': '我的订单', + // }, + // { + // 'icon': 'chongzhi.svg', + // 'label': '充值中心', + // }, + // {'icon': 'qianbao.svg', 'label': '余额'}, + // {'icon': 'comment.svg', 'label': '评价中心'} + // ] + // } + // ]; + final ScrollController pageScrollController = ScrollController(); + final ShopIndexController controller = Get.put(ShopIndexController()); + + // 下拉刷新初始化 + Future handleRefresh() async {} + + ///商品详情 + void shopDetail() async { + // final res = await Http.get('${ShopApi.shopDetail}/1938137499482869762'); + // logger.e(res['data']); + } + + // 瀑布流卡片 + Widget cardList(item) { + return GestureDetector( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15.0), boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(5), + offset: Offset(0.0, 1.0), + blurRadius: 1.0, + spreadRadius: 0.0, + ), + ]), + child: Column( + children: [ + Image.network('${item['pic']}'), + Container( + padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5.0, + children: [ + Text( + '${item['name']}', + style: TextStyle(fontSize: 14.0, height: 1.2), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Row( + spacing: 5.0, + children: [ + Text.rich( + TextSpan(style: TextStyle(color: Colors.red, fontSize: 12.0, fontWeight: FontWeight.w700, fontFamily: 'Arial'), children: [ + TextSpan(text: '¥'), + TextSpan( + text: '${item['price']}', + style: TextStyle( + fontSize: 16.0, + )), + ]), + ), + Text( + '已售${item['sales']}件', + style: TextStyle(color: Colors.grey, fontSize: 10.0), + ), + ], + ), + Text( + '${item['shop']}', + style: TextStyle(color: Colors.grey, fontSize: 12.0), + ), + ], + ), + ) + ], + ), + ), + onTap: () { + Get.toNamed('/goods'); + }, + ); + } + + @override + void initState() { + super.initState(); + controller.initTabs(); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + final tabIndex = controller.currentTabIndex.value; + final scrollController = controller.tabs[tabIndex]?.scrollController; + final pagesView = controller.tabs[tabIndex]; + return Scaffold( + backgroundColor: Colors.grey[50], + + body: ScrollConfiguration( + behavior: CustomScrollBehavior().copyWith(scrollbars: false), + child: CustomScrollView( + scrollBehavior: CustomScrollBehavior().copyWith(scrollbars: false), + controller: scrollController, + slivers: [ + SliverAppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + pinned: true, + expandedHeight: 200.0, + titleSpacing: 10.0, + // 搜索框(高斯模糊背景) + // title: ClipRRect( + // borderRadius: BorderRadius.circular(30.0), + // child: BackdropFilter( + // filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + // child: Container( + // height: 45.0, + // decoration: BoxDecoration( + // color: Colors.white.withAlpha(200), + // ), + // child: TextField( + // decoration: InputDecoration( + // isDense: true, + // hintText: "2025百亿补贴", + // hintStyle: TextStyle(fontSize: 15.0), + // prefixIcon: Icon( + // Icons.search, + // color: Colors.black38, + // size: 21.0, + // ), + // suffixIcon: Container( + // padding: EdgeInsets.only(right: 15.0), + // child: Row( + // mainAxisSize: MainAxisSize.min, + // spacing: 10.0, + // children: [ + // Icon( + // Icons.keyboard_voice, + // color: Colors.black45, + // size: 21.0, + // ), + // Icon( + // Icons.camera_alt_outlined, + // color: Colors.black45, + // size: 21.0, + // ), + // ], + // ), + // ), + // contentPadding: EdgeInsets.symmetric(vertical: 0, horizontal: 10.0), + // border: OutlineInputBorder(borderSide: BorderSide.none, borderRadius: BorderRadius.circular(30.0))), + // cursorColor: Colors.black, + // onChanged: (val) { + // debugPrint(val); + // }, + // ), + // ), + // ), + // ), + // actions: [ + // IconButton( + // icon: Icon(Icons.shopping_cart_outlined), + // onPressed: () {}, + // ), + // ], + // 自定义伸缩区域(轮播图) + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF5000), Color(0xFFfcaec4)])), + child: FlexibleSpaceBar( + background: Swiper.children( + pagination: SwiperPagination( + builder: DotSwiperPaginationBuilder( + color: Colors.white70, + activeColor: Colors.white, + ), + ), + indicatorLayout: PageIndicatorLayout.SCALE, + children: [ + ...controller.swiperData.map((item) { + final imageUrl = item['images'] ?? ''; + return imageUrl.isNotEmpty + ? Image.network( + imageUrl, + fit: BoxFit.fill, + ) + : SizedBox.shrink(); + }), + ], + ), + ), + ), + ), + + // tabbar列表 + SliverPersistentHeader( + pinned: true, + delegate: CustomStickyHeader( + child: PreferredSize( + preferredSize: Size.fromHeight(45.0), + child: Container( + color: Colors.white, + height: 45.0, + child: TabBar( + controller: controller.tabController, + onTap: (index) { + print('点击了第 $index 个 tab'); + }, + tabs: controller.tabList.map((v) => Tab(text: v['name'])).toList(), + isScrollable: true, + overlayColor: WidgetStateProperty.all(Colors.transparent), + unselectedLabelColor: Colors.black87, + labelColor: Color(0xFFFF5000), + indicatorColor: Color(0xFFFF5000), + indicatorSize: TabBarIndicatorSize.tab, + unselectedLabelStyle: TextStyle(fontSize: 15.0, fontFamily: 'Microsoft YaHei'), + labelStyle: TextStyle(fontSize: 15.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.w700), + dividerHeight: 0, + padding: EdgeInsets.symmetric(horizontal: 10.0), + labelPadding: EdgeInsets.symmetric(horizontal: 7.5), + indicatorPadding: EdgeInsets.symmetric(horizontal: 15.0, vertical: 5.0), + ), + ), + ), + ), + ), + + // 瀑布流列表 + SliverToBoxAdapter( + child: Container( + padding: EdgeInsets.all(10.0), + child: Column( + children: [ + pagesView?.dataList.isEmpty ?? true + ? + // 初始loading提示 + Column( + children: [ + RefreshProgressIndicator( + backgroundColor: Colors.white, + color: Color(0xFFFF5000), + ), + ], + ) + : MasonryGridView.count( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + itemCount: ((pagesView?.dataList.length ?? 0) + (pagesView?.isLoading.value == true ? 1 : 0)), + itemBuilder: (BuildContext context, int index) { + final dataList = pagesView?.dataList ?? []; + if (index < dataList.length) { + return cardList(dataList[index]); + } else { + return SizedBox.shrink(); + } + }, + ), + Opacity( + opacity: ((pagesView?.dataList.isNotEmpty ?? false) && (pagesView?.isLoading.value ?? false)) ? 1 : 0, + child: Loading(title: 'loading...'), + ), + ], + ), + ), + ), + ], + ), + ), + // 返回顶部 + floatingActionButton: pagesView != null + ? Backtop( + controller: pagesView.scrollController, + offset: pagesView.scrollOffset.value, + ) + : null, + ); + }); + } + + // @override + // Widget build(BuildContext context) { + // return Obx(() { + // final tabIndex = controller.currentTabIndex.value; + // final pagesView = controller.tabs[tabIndex]; + // return Scaffold( + // backgroundColor: Colors.grey[50], + // body: NestedScrollViewPlus( + // // controller: pageScrollController, + // overscrollBehavior: OverscrollBehavior.outer, + // physics: (pagesView!.dataList.length > 4 && pagesView.currentPage > 1) + // ? const OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) + // : const AlwaysScrollableScrollPhysics(), + // headerSliverBuilder: (context, innerBoxIsScrolled) { + // return [ + // SliverAppBar( + // backgroundColor: Colors.transparent, + // foregroundColor: Colors.white, + // pinned: true, + // stretch: false, + // onStretchTrigger: () async { + // print('触发 stretch 拉伸'); + // // 加载刷新逻辑 + // }, + // expandedHeight: 180.0, + // // collapsedHeight: kToolbarHeight, + // collapsedHeight: 180.0, + // // 自定义伸缩区域(轮播图) + // flexibleSpace: Container( + // decoration: BoxDecoration( + // gradient: LinearGradient( + // begin: Alignment.topLeft, + // end: Alignment.bottomRight, + // colors: [ + // Color(0xFFFF5000), + // Color(0xFFfcaec4), + // ], + // ), + // ), + // child: FlexibleSpaceBar( + // background: Swiper.children( + // pagination: SwiperPagination( + // builder: DotSwiperPaginationBuilder( + // color: Colors.white70, + // activeColor: Colors.white, + // ), + // ), + // indicatorLayout: PageIndicatorLayout.SCALE, + // children: [ + // ...controller.swiperData.map((item) { + // final imageUrl = item['images'] ?? ''; + // return imageUrl.isNotEmpty + // ? Image.network( + // imageUrl, + // fit: BoxFit.fill, + // ) + // : SizedBox.shrink(); + // }), + // ], + // ), + // ), + // ), + // ), + // // tab吸顶 + // SliverPersistentHeader( + // pinned: true, + // delegate: CustomStickyHeader( + // child: PreferredSize( + // preferredSize: const Size.fromHeight(48.0), + // child: Container( + // color: Colors.white, + // child: TabBar( + // controller: controller.tabController, + // tabs: controller.tabList.map((item) { + // return Tab( + // child: Text(item['name'], style: TextStyle(fontWeight: FontWeight.bold)), + // ); + // }).toList(), + // isScrollable: false, + // overlayColor: WidgetStateProperty.all(Colors.transparent), + // unselectedLabelColor: Colors.black87, + // labelColor: const Color(0xFFFF5000), + // indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Color(0xFFFF5000), width: 2.0)), + // indicatorSize: TabBarIndicatorSize.tab, + // unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), + // labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), + // dividerHeight: 0, + // padding: const EdgeInsets.symmetric(horizontal: 10.0), + // labelPadding: const EdgeInsets.symmetric(horizontal: 15.0), + // ), + // ), + // ), + // ), + // ), + // ]; + // }, + // body: TabBarView( + // controller: controller.tabController, + // children: controller.tabList.map((tabItem) { + // final index = controller.tabList.indexOf(tabItem); + // return buildTabContent(index); + // }).toList(), + // ), + // ), + // ); + // }); + // } + + //子view + Widget buildTabContent(int index) { + final tabState = controller.tabs[index]!; + + return Obx(() { + if (tabState.dataList.isEmpty && tabState.isLoading.value) { + return const Center( + child: RefreshProgressIndicator( + backgroundColor: Colors.white, + color: Color(0xFFFF5000), + ), + ); + } + + return CustomScrollView( + primary: false, + controller: tabState.scrollController, + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(10.0), + sliver: SliverMasonryGrid.count( + crossAxisCount: 2, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + childCount: tabState.dataList.length, + itemBuilder: (context, idx) => cardList(tabState.dataList[idx]), + ), + ), + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: tabState.isLoading.value ? const Loading(title: 'loading...') : const Text('没有更多数据了'), + ), + ), + ), + ], + ); + }); + } + + // + // 空状态提示 + Widget emptyTip(String text) { + return CustomScrollView( + physics: const OnlyDownScrollPhysics(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 50.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/images/empty.png', width: 100.0), + const SizedBox(height: 8.0), + Text( + text, + style: const TextStyle(color: Colors.grey, fontSize: 13.0), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/my/des.dart b/lib/pages/my/des.dart new file mode 100644 index 0000000..13a9273 --- /dev/null +++ b/lib/pages/my/des.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; + +class Des extends StatefulWidget { + const Des({super.key}); + + @override + State createState() => _DesState(); +} + +class _DesState extends State { + final _formKey = GlobalKey(); + final userInfoController = Get.find(); + + void _save() async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final signature = _formKey.currentState?.fields['signature']?.value; + final result = await userInfoController.updateSignature(signature); + if (result) { + Get.back(); + } + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + title: const Text('修改简介'), + actions: [ + TextButton( + onPressed: _save, + child: const Text( + '保存', + style: TextStyle(color: Colors.red, fontSize: 16), + ), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: FormBuilder( + key: _formKey, + child: Obx(() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '简介', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + FormBuilderTextField( + name: 'signature', + initialValue: userInfoController.signature.value, + maxLines: 6, // 最多显示6行 + minLines: 3, // 最少显示3行 + decoration: const InputDecoration( + hintText: '请输入内容', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + maxLength: 100, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: '内容不能为空'), + FormBuilderValidators.maxLength(100, errorText: '内容不能超过100个字符'), + ]), + ), + const SizedBox(height: 8), + const Text( + '最长支持100个字符,请文明用语', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ); + })), + ), + ), + ); + } +} diff --git a/lib/pages/my/index.dart b/lib/pages/my/index.dart index a118cf1..eed230a 100644 --- a/lib/pages/my/index.dart +++ b/lib/pages/my/index.dart @@ -3,12 +3,17 @@ import 'package:flutter/services.dart'; import 'package:flutter_svg/svg.dart'; import 'package:get/get.dart'; import 'package:get/get_rx/src/rx_typedefs/rx_typedefs.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/api/video_api.dart'; import 'package:loopin/components/custom_sticky_header.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; import 'package:loopin/components/only_down_scroll_physics.dart'; import 'package:loopin/controller/video_module_controller.dart'; +import 'package:loopin/service/http.dart'; import 'package:nested_scroll_view_plus/nested_scroll_view_plus.dart'; import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_info.dart'; import '../../utils/common.dart'; @@ -17,13 +22,26 @@ class PageParams { int pageSize; bool isLoading; bool hasMore; + int total; + bool isInitLoading; PageParams({ this.page = 1, this.pageSize = 10, this.isLoading = false, this.hasMore = true, + this.total = 0, + this.isInitLoading = true, }); + + void init() { + page = 1; + pageSize = 10; + isLoading = false; + hasMore = true; + total = 0; + isInitLoading = true; + } } class MyPage extends StatefulWidget { @@ -37,28 +55,38 @@ class MyPageState extends State with SingleTickerProviderStateMixin { late RxInt currentTabIndex = 0.obs; late RxList items = [].obs; late RxList favoriteItems = [].obs; - late RxMap userInfo = {}.obs; + RxBool isLogin = Common.isLogin().obs; + //用户基本信息 + // late Rx userInfo = Rx(null); + + ImUserInfoController? imUserInfoController; + + // 关注,互关,粉丝数量 + late Rx followInfo = Rx(null); RxBool get shouldFixHeader => (currentTabIndex.value == 0 && items.isEmpty) || (currentTabIndex.value == 1 && favoriteItems.isEmpty) ? true.obs : false.obs; List tabList = [ - {'name': "作品", 'badge': 99}, + {'name': "作品"}, {'name': "喜欢"}, ]; - late PageParams itemsParams; - late PageParams favoriteParams; + PageParams itemsParams = PageParams(); + PageParams favoriteParams = PageParams(); late TabController tabController; late ScrollController scrollController; + RxDouble positions = 0.0.obs; + late Callback tabListener; late Callback scrollListener; + RxBool isPinned = false.obs; // 是否吸顶 + @override void initState() { super.initState(); - itemsParams = PageParams(); - favoriteParams = PageParams(); + initControllers(); scrollListener = () { @@ -77,16 +105,17 @@ class MyPageState extends State with SingleTickerProviderStateMixin { tabListener = () { currentTabIndex.value = tabController.index; - scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeIn); if (tabController.index == 0 && items.isEmpty) { loadData(0); + scrollInnerList(); } else if (tabController.index == 1 && favoriteItems.isEmpty) { loadData(1); + scrollInnerList(); } }; tabController.addListener(tabListener); - loadData(0); + // loadData(0); } @override @@ -99,87 +128,121 @@ class MyPageState extends State with SingleTickerProviderStateMixin { super.dispose(); } - void loadData([int? tabIndex]) async { + // 添加控制子列表滚动的方法 + void scrollInnerList([double? offset]) async { + if (isPinned.value) { + // 如果已经吸顶,先给父滚动权限 + WidgetsBinding.instance.addPostFrameCallback((_) { + isPinned.value = false; + // 直接滚动到指定位置 + scrollController.jumpTo(positions.value); + // 重置滚动位置 + positions.value = 0.0; + }); + } + } + + Future loadData([int? tabIndex]) async { final index = tabIndex ?? currentTabIndex.value; if (index == 0) { if (itemsParams.isLoading || !itemsParams.hasMore) return; itemsParams.isLoading = true; + // itemsParams.isInitLoading = true; - await Future.delayed(const Duration(seconds: 1)); + try { + final res = await Http.post(VideoApi.myPublicList, data: { + "userId": imUserInfoController?.userID.value, + "yesOrNo": 0, + "current": itemsParams.page, + "size": itemsParams.pageSize, + }); + final obj = res['data']; + final total = obj['total']; + final row = obj['rows']; + logger.i(res['data']); + // 判断是否还有更多数据 + if (items.length >= total) { + itemsParams.hasMore = false; + } + // 添加新数据,触发响应式更新 + items.addAll(row); - // 模拟生成新数据 - List newItems = List.generate( - itemsParams.pageSize, - (i) => '作品 ${(itemsParams.page - 1) * itemsParams.pageSize + i + 1}', - ); - - // 模拟判断是否还有更多数据 - if (itemsParams.page >= 2) { - itemsParams.hasMore = false; + // 页码加一 + itemsParams.page++; + } finally { + itemsParams.isLoading = false; + itemsParams.isInitLoading = false; } - - // 添加新数据,触发响应式更新 - items.addAll(newItems); - - // 页码加一 - itemsParams.page++; - - itemsParams.isLoading = false; } else if (index == 1) { - // 喜欢列表同理 if (favoriteParams.isLoading || !favoriteParams.hasMore) return; favoriteParams.isLoading = true; + // favoriteParams.isInitLoading = true; - await Future.delayed(const Duration(seconds: 1)); + try { + final res = await Http.post(VideoApi.myPublicList, data: { + "userId": imUserInfoController?.userID.value, + "yesOrNo": 0, + "current": itemsParams.page, + "size": itemsParams.pageSize, + }); + final obj = res['data']; + final total = obj['total']; + final row = obj['rows']; - List newFavorites = List.generate( - favoriteParams.pageSize, - (i) => '喜欢 ${(favoriteParams.page - 1) * favoriteParams.pageSize + i + 1}', - ); + if (favoriteItems.length >= total) { + itemsParams.hasMore = false; + } - if (favoriteParams.page >= 2) { - favoriteParams.hasMore = false; + favoriteItems.addAll(row); + favoriteParams.page++; + } finally { + favoriteParams.isLoading = false; + favoriteParams.isInitLoading = false; } - - favoriteItems.addAll(newFavorites); - - favoriteParams.page++; - - favoriteParams.isLoading = false; } } void initControllers() { tabController = TabController(initialIndex: 0, length: tabList.length, vsync: this); scrollController = ScrollController(); + if (Common.isLogin()) { + imUserInfoController = Get.find(); + } } // 初始化页面数据 - void refreshData() { + void refreshData([int? tabIndex]) async { if (!mounted) { logger.i('未挂载'); return; } + isLogin.value = Common.isLogin(); if (!Common.isLogin()) return; - itemsParams = PageParams(); - favoriteParams = PageParams(); - currentTabIndex.value = 0; + final idx = tabIndex ?? currentTabIndex.value; + // 恢复位置 + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollInnerList(); + }); items.clear(); favoriteItems.clear(); - scrollController.animateTo(0, duration: const Duration(milliseconds: 100), curve: Curves.easeIn); + itemsParams.init(); + favoriteParams.init(); + // currentTabIndex.value = 0; selfInfo(); - loadData(); + loadData(idx); } // 获取当前登录用户基本信息 void selfInfo() async { - final resIm = await ImService.instance.selfInfo(); - if (resIm.success) { - for (var user in resIm.data ?? []) { - logger.i(user.toLogString()); - } + // imUserInfoController = Get.find(); + final res = await ImService.instance.getUserFollowInfo(userIDList: [imUserInfoController!.userID.value]); + if (res.success) { + //这里少个点赞,从服务端获取 + // followersCount粉丝,多少人关注了我,mutualFollowersCount互关,followingCount我关注了多少人 + followInfo.value = res.data!.first; + logger.i(followInfo.value!.toJson()); } } @@ -260,103 +323,118 @@ class MyPageState extends State with SingleTickerProviderStateMixin { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFFAF6F9), - body: Obx(() { - return NestedScrollViewPlus( - controller: scrollController, - physics: shouldFixHeader.value ? const OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) : const AlwaysScrollableScrollPhysics(), - overscrollBehavior: OverscrollBehavior.outer, - headerSliverBuilder: (context, innerBoxIsScrolled) { - return [ - SliverAppBar( - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - expandedHeight: 180.0, - collapsedHeight: 120.0, - pinned: true, - stretch: true, - onStretchTrigger: () async { - logger.i('触发 stretch 拉伸'); - // 加载刷新逻辑 - }, - actions: [ - _buildIcon('assets/images/svg/service.svg', () { - logger.i('点击客服按钮'); - }), - const SizedBox(width: 8.0), - _buildIcon('assets/images/svg/setting.svg', () { - logger.i('点击设置按钮'); - }), - const SizedBox(width: 10.0), - ], - flexibleSpace: _buildFlexibleSpace(), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(10.0), - child: Column( - children: [ - _buildStatsCard(), - const SizedBox(height: 10.0), - _buildOrderCard(context), - const SizedBox(height: 10.0), + // 如果没登录,直接返回一个登录提示页面 + return Obx(() { + if (!isLogin.value) { + return SizedBox(); + } else { + return Scaffold( + backgroundColor: const Color(0xFFFAF6F9), + body: Obx(() { + return NestedScrollViewPlus( + controller: scrollController, + physics: shouldFixHeader.value + ? OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) + : isPinned.value + ? NeverScrollableScrollPhysics() + : AlwaysScrollableScrollPhysics(), + // physics: shouldFixHeader.value ? OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) : AlwaysScrollableScrollPhysics(), + overscrollBehavior: OverscrollBehavior.outer, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverAppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + expandedHeight: 180.0, + collapsedHeight: 120.0, + pinned: true, + stretch: true, + onStretchTrigger: () async { + logger.i('触发 stretch 拉伸'); + // 加载刷新逻辑 + }, + actions: [ + // _buildIcon('assets/images/svg/service.svg', () { + // logger.i('点击客服按钮'); + // }), + const SizedBox(width: 8.0), + _buildIcon('assets/images/svg/setting.svg', () { + logger.i('点击设置按钮'); + Get.toNamed('/setting'); + }), + const SizedBox(width: 10.0), ], + flexibleSpace: _buildFlexibleSpace(), ), - ), - ), - SliverPersistentHeader( - pinned: true, - delegate: CustomStickyHeader( - child: PreferredSize( - preferredSize: const Size.fromHeight(48.0), - child: Container( - color: Colors.white, - child: TabBar( - controller: tabController, - tabs: tabList.map((item) { - return Tab( - child: Badge.count( - backgroundColor: Colors.red, - count: item['badge'] ?? 0, - isLabelVisible: item['badge'] != null, - alignment: Alignment.topRight, - offset: const Offset(14, -6), - child: Text(item['name'], style: const TextStyle(fontWeight: FontWeight.bold)), - ), - ); - }).toList(), - isScrollable: false, - overlayColor: WidgetStateProperty.all(Colors.transparent), - unselectedLabelColor: Colors.black87, - labelColor: const Color(0xFFFF5000), - indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Color(0xFFFF5000), width: 2.0)), - indicatorSize: TabBarIndicatorSize.tab, - unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), - labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), - dividerHeight: 0, - padding: const EdgeInsets.symmetric(horizontal: 10.0), - labelPadding: const EdgeInsets.symmetric(horizontal: 15.0), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + Obx(() => _buildStatsCard()), + const SizedBox(height: 10.0), + _buildOrderCard(context), + const SizedBox(height: 10.0), + ], ), ), ), - ), - ), - ]; - }, - body: TabBarView( - controller: tabController, - children: [ - // Tab 1: - Obx(() => _buildGridTab(0)), + SliverPersistentHeader( + pinned: true, + delegate: CustomStickyHeader( + isPinned: isPinned, + positions: positions, + child: PreferredSize( + preferredSize: const Size.fromHeight(48.0), + child: Container( + color: Colors.white, + child: TabBar( + controller: tabController, + tabs: tabList.map((item) { + return Tab( + child: Badge.count( + backgroundColor: Colors.red, + count: item['badge'] ?? 0, + isLabelVisible: item['badge'] != null, + alignment: Alignment.topRight, + offset: const Offset(14, -6), + child: Text(item['name'], style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ); + }).toList(), + isScrollable: false, + overlayColor: WidgetStateProperty.all(Colors.transparent), + unselectedLabelColor: Colors.black87, + labelColor: const Color(0xFFFF5000), + indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Color(0xFFFF5000), width: 2.0)), + indicatorSize: TabBarIndicatorSize.tab, + unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), + labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), + dividerHeight: 0, + padding: const EdgeInsets.symmetric(horizontal: 10.0), + labelPadding: const EdgeInsets.symmetric(horizontal: 15.0), + ), + ), + ), + ), + ), + ]; + }, + body: TabBarView( + controller: tabController, + children: [ + // Tab 1: + _buildGridTab(0), - // Tab 2: - Obx(() => _buildGridTab(1)) - ], - ), + // Tab 2: + _buildGridTab(1) + ], + ), + ); + }), ); - }), - ); + } + }); } // 空状态提示 @@ -390,47 +468,134 @@ class MyPageState extends State with SingleTickerProviderStateMixin { Widget _buildGridTab(int tabIndex) { final listToShow = tabIndex == 0 ? items : favoriteItems; - final params = tabIndex == 0 ? itemsParams : favoriteParams; - + PageParams params = tabIndex == 0 ? itemsParams : favoriteParams; + if (params.isInitLoading) { + return Center(child: CircularProgressIndicator()); + } if (listToShow.isEmpty) { return emptyTip('暂无相关数据'); } - return CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(10.0), - sliver: SliverGrid( - delegate: SliverChildBuilderDelegate( - (context, index) { - return Container( - decoration: BoxDecoration( - color: Colors.blue[100 * ((index % 8) + 1)], - borderRadius: BorderRadius.circular(10.0), + return Obx(() { + return CustomScrollView( + // physics: !isPinned.value ? NeverScrollableScrollPhysics() : AlwaysScrollableScrollPhysics(), + // physics: AlwaysScrollableScrollPhysics(), + key: PageStorageKey('myindex_$tabIndex'), + slivers: [ + SliverPadding( + padding: EdgeInsets.all(10.0), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Container( + decoration: BoxDecoration( + color: Colors.blue[100 * ((index % 8) + 1)], + borderRadius: BorderRadius.circular(10.0), + ), + alignment: Alignment.center, + child: _buildVdCard(listToShow[index]), + ); + }, + childCount: listToShow.length, + ), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10.0, + mainAxisSpacing: 10.0, + childAspectRatio: 0.6, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: params.hasMore ? const CircularProgressIndicator() : const Text('没有更多数据了'), + ), + ), + ), + ], + ); + }); + } + + Widget _buildVdCard(item) { + return InkWell( + onTap: () { + //去视频详情 + }, + onLongPress: () { + showModalBottomSheet( + context: Get.context!, + backgroundColor: Colors.black.withOpacity(0.8), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.lock, color: Colors.white), + title: const Text('设为私密', style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + // TODO: 修改为私密逻辑 + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.redAccent), + title: const Text('删除视频', style: TextStyle(color: Colors.redAccent)), + onTap: () { + Navigator.pop(context); + // TODO: 删除逻辑 + }, + ), + ], + ); + }, + ); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: Colors.grey[900], + ), + child: Stack( + children: [ + /// 视频缩略图 + ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: Image.network( + item['cover'] ?? item['firstFrameImg'], + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + + /// 右下角的点赞数 + Positioned( + right: 8, + bottom: 8, + child: Row( + children: [ + const Icon(Icons.favorite, color: Colors.white, size: 16), + const SizedBox(width: 4), + Text( + '${item['likeCounts'] ?? 0}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), ), - alignment: Alignment.center, - child: Text(listToShow[index], style: const TextStyle(fontWeight: FontWeight.bold)), - ); - }, - childCount: listToShow.length, + ], + ), ), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 10.0, - mainAxisSpacing: 10.0, - childAspectRatio: 1.0, - ), - ), + ], ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 20.0), - child: Center( - child: params.hasMore ? const CircularProgressIndicator() : const Text('没有更多数据了'), - ), - ), - ), - ], + ), ); } @@ -449,7 +614,7 @@ class MyPageState extends State with SingleTickerProviderStateMixin { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final double maxHeight = 180.0; - final double minHeight = 100.0; + final double minHeight = 120.0; final double currentHeight = constraints.maxHeight; double ratio = (currentHeight - minHeight) / (maxHeight - minHeight); ratio = ratio.clamp(0.0, 1.0); @@ -457,7 +622,12 @@ class MyPageState extends State with SingleTickerProviderStateMixin { return Stack( fit: StackFit.expand, children: [ - Positioned.fill(child: Opacity(opacity: 1.0, child: Image.asset('assets/images/pic2.jpg', fit: BoxFit.cover))), + Positioned.fill( + child: Opacity( + opacity: 1.0, + child: NetworkOrAssetImage(imageUrl: imUserInfoController?.customInfo['coverBg'], placeholderAsset: 'assets/images/bk.jpg'), + ), + ), Positioned( left: 15.0, bottom: 0, @@ -467,7 +637,19 @@ class MyPageState extends State with SingleTickerProviderStateMixin { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - ClipOval(child: Image.asset('assets/images/avatar/img11.jpg', height: 60.0, width: 60.0, fit: BoxFit.cover)), + ClipOval( + child: Obx(() { + final faceUrl = imUserInfoController?.faceUrl.value; + return ClipRRect( + borderRadius: BorderRadius.circular(30), + child: NetworkOrAssetImage( + imageUrl: faceUrl, + width: 80, + height: 80, + ), + ); + }), + ), const SizedBox(width: 15.0), Expanded( child: Column( @@ -476,13 +658,14 @@ class MyPageState extends State with SingleTickerProviderStateMixin { Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), - decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), - child: const Text( - '新用户2025', - style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold, fontFamily: 'Arial', color: Colors.white), - ), - ), + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), + child: Obx( + () => Text( + imUserInfoController!.nickname.value.isNotEmpty == true ? imUserInfoController!.nickname.value : '昵称', + style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold, fontFamily: 'Arial', color: Colors.white), + ), + )), const SizedBox(width: 8.0), InkWell( onTap: () { @@ -502,11 +685,11 @@ class MyPageState extends State with SingleTickerProviderStateMixin { decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), child: InkWell( onTap: () { - logger.i('点击个人简介'); - Clipboard.setData(const ClipboardData(text: '1234')); + logger.i('点击id'); + Clipboard.setData(ClipboardData(text: imUserInfoController!.userID.value)); MyDialog.toast('ID已复制', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.green.withAlpha(200))); }, - child: const Text('ID:32938293892839232', style: TextStyle(fontSize: 12.0, color: Colors.white)), + child: Text('ID:${imUserInfoController!.userID.value}', style: TextStyle(fontSize: 12.0, color: Colors.white)), ), ), ], @@ -531,17 +714,28 @@ class MyPageState extends State with SingleTickerProviderStateMixin { ), clipBehavior: Clip.antiAlias, child: Padding( - padding: const EdgeInsets.all(10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Column(children: const [Text('9999', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('获赞')]), - Column(children: const [Text('25', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('互关')]), - Column(children: const [Text('11', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('关注')]), - Column(children: const [Text('10', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('粉丝')]), - ], - ), - ), + padding: const EdgeInsets.all(10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column(children: [Text('9999', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('获赞')]), + Column(children: [ + Text('${followInfo.value?.mutualFollowersCount ?? 0}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('互关') + ]), + Column(children: [ + Text('${followInfo.value?.followingCount ?? 0}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('关注') + ]), + Column(children: [ + Text('${followInfo.value?.followersCount ?? 0}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('粉丝') + ]), + ], + )), ); } @@ -583,10 +777,12 @@ class MyPageState extends State with SingleTickerProviderStateMixin { _buildOrderIcon('assets/images/ico_order.png', '订单', () { Get.toNamed('/order'); }), - _buildOrderIcon('assets/images/ico_dhx.png', '余额', () { + _buildOrderIcon('assets/images/ico_dhx.png', '余额logout', () { showLogoutDialog(context); }), - _buildOrderIcon('assets/images/ico_sh.png', '提现', () {}), + _buildOrderIcon('assets/images/ico_sh.png', '提现vloger', () { + Get.toNamed('/vloger'); + }), _buildOrderIcon('assets/images/ico_tgm.png', '推广码', () {}), ], ), diff --git a/lib/pages/my/nick_name.dart b/lib/pages/my/nick_name.dart new file mode 100644 index 0000000..d2e2338 --- /dev/null +++ b/lib/pages/my/nick_name.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; + +class NickName extends StatefulWidget { + const NickName({super.key}); + + @override + State createState() => _NickNameState(); +} + +class _NickNameState extends State { + final _formKey = GlobalKey(); + final userInfoController = Get.find(); + + void _save() async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final nickname = _formKey.currentState?.fields['nickname']?.value; + final result = await userInfoController.updateNickname(nickname); + if (result) { + Get.back(); + } + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + title: const Text('修改昵称'), + actions: [ + TextButton( + onPressed: _save, + child: const Text( + '保存', + style: TextStyle(color: Colors.red, fontSize: 16), + ), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: FormBuilder( + key: _formKey, + child: Obx(() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '我的昵称', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + FormBuilderTextField( + name: 'nickname', + initialValue: userInfoController.nickname.value, + decoration: const InputDecoration( + hintText: '请输入昵称', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + maxLength: 20, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: '昵称不能为空'), + FormBuilderValidators.maxLength(20, errorText: '昵称不能超过20个字符'), + ]), + ), + const SizedBox(height: 8), + const Text( + '昵称最长支持20个字符,请文明用语', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ); + })), + ), + ), + ); + } +} diff --git a/lib/pages/my/setting.dart b/lib/pages/my/setting.dart new file mode 100644 index 0000000..a110511 --- /dev/null +++ b/lib/pages/my/setting.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/styles/index.dart'; + +class Setting extends StatelessWidget { + const Setting({super.key}); + + @override + Widget build(BuildContext context) { + final List> settings = [ + { + 'icon': Icons.person, + 'title': '账号设置', + 'onTap': () => Get.toNamed('/userInfo'), + }, + { + 'icon': Icons.notifications, + 'title': '通知设置', + 'onTap': () => Get.toNamed('/notifications'), + }, + { + 'icon': Icons.lock, + 'title': '隐私', + 'onTap': () => Get.toNamed('/privacy'), + }, + { + 'icon': Icons.info, + 'title': '关于我们', + 'onTap': () => Get.toNamed('/about'), + }, + ]; + + return Scaffold( + appBar: AppBar(title: const Text('设置')), + backgroundColor: Colors.grey[200], + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + color: Colors.white, + child: Column( + children: settings.map((item) { + return Column( + children: [ + ListTile( + leading: Icon(item['icon'] as IconData, color: FStyle.c999), + title: Text(item['title']), + trailing: const Icon( + Icons.chevron_right, + color: FStyle.c999, + ), + onTap: item['onTap'], + ), + if (item != settings.last) const Divider(height: 1, indent: 16, endIndent: 16), + ], + ); + }).toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/my/user_info.dart b/lib/pages/my/user_info.dart new file mode 100644 index 0000000..20e2de5 --- /dev/null +++ b/lib/pages/my/user_info.dart @@ -0,0 +1,490 @@ +import 'package:bottom_picker/bottom_picker.dart'; +import 'package:city_pickers/city_pickers.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; +import 'package:loopin/api/common_api.dart'; +import 'package:loopin/service/http.dart'; +import 'package:loopin/styles/index.dart'; +import 'package:loopin/utils/index.dart'; +import 'package:loopin/utils/wxsdk.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; + +class UserInfo extends StatefulWidget { + const UserInfo({super.key}); + + @override + State createState() => _UserInfoState(); +} + +class _UserInfoState extends State { + final userInfoController = Get.find(); + late List> items; + + @override + void initState() { + super.initState(); + items = [ + { + 'title': '昵称', + 'value': userInfoController.nickname, + 'onTap': () => Get.toNamed('/nickName'), + }, + { + 'title': '简介', + 'value': userInfoController.signature, + 'onTap': () => Get.toNamed('/des'), + }, + { + 'title': '性别', + 'value': userInfoController.gender, + 'onTap': () => setGender(context), + }, + { + 'title': '生日', + 'value': userInfoController.birthday, + 'onTap': () => selectDate(context), + }, + { + 'title': '区域', + 'value': userInfoController.customInfo, + 'onTap': () => pickCity(), + }, + { + 'title': '绑定微信', + 'value': userInfoController.customInfo, + 'onTap': () => wechatLogin(), + }, + ]; + } + + /// 微信授权 + Future wechatLogin() async { + await Wxsdk.login(); + } + + /// 选性别 + void setGender(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return SafeArea( + child: Container( + height: 300, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ListTile( + title: Center(child: Text('男')), + onTap: () { + userInfoController.gender.value = 1; + userInfoController.updateGender(); + Get.back(); + }, + ), + Divider(), + ListTile( + title: Center(child: Text('女')), + onTap: () { + userInfoController.gender.value = 2; + userInfoController.updateGender(); + Get.back(); + }), + Divider(), + ListTile( + title: Center(child: Text('保密')), + onTap: () { + userInfoController.gender.value = 0; + userInfoController.updateGender(); + Get.back(); + }, + ), + Container( + height: 10, + color: Colors.grey[200], + ), + ], + ), + ), + ListTile( + title: Center(child: Text('取消')), + onTap: () => Get.back(), + ), + ], + ), + ), + ); + }, + ); + } + + /// 选生日 + void selectDate(BuildContext context) { + DateTime selectedDate = DateTime.now(); // 初始值设为当前时间 + BottomPicker.date( + pickerTitle: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + Get.back(); + }, + child: Text('取消'), + ), + TextButton( + onPressed: () { + // 获取选择结果逻辑 + final dateStr = "${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}"; + print(dateStr); + DateTime birthdayDate = DateTime(selectedDate.year, selectedDate.month, selectedDate.day); + userInfoController.birthday.value = birthdayDate.millisecondsSinceEpoch ~/ 1000; //秒级时间戳 + userInfoController.updateBirthday(); + Get.back(); + }, + child: Text('确认'), + ), + ], + ), + displaySubmitButton: false, + displayCloseIcon: false, + dismissable: true, // 允许点击空白关闭 + initialDateTime: DateTime.now(), + minDateTime: DateTime(1900), + maxDateTime: DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day, + 23, + 59, + 59, + ), + dateOrder: DatePickerDateOrder.ymd, + pickerTextStyle: const TextStyle(fontSize: 16, color: Colors.black87), + height: 300, + onChange: (date) { + selectedDate = date as DateTime; + }, + ).show(context); + } + + ///选所在地 + void pickCity() async { + final result = await CityPickers.showCityPicker( + context: context, + showType: ShowType.pca, // 显示省市区 + height: 300.0, + borderRadius: 16.0, + barrierDismissible: true, + theme: Theme.of(context).copyWith( + scaffoldBackgroundColor: Colors.white, + ), + ); + if (result != null) { + final areaName = '${result.provinceName}-${result.cityName}-${result.areaName}'; + print(result.toString()); + print('${result.provinceName}-${result.cityName}-${result.areaName}-${result.areaId}'); + //修改 + userInfoController.customInfo['area'] = areaName; + userInfoController.customInfo['areaCode'] = '${result.areaId}'; + userInfoController.updateArea(); + userInfoController.customInfo.refresh(); + } + } + + ///选背景 + void pickCover(BuildContext context) async { + final pickedAssets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + textDelegate: const AssetPickerTextDelegate(), + pathNameBuilder: (AssetPathEntity album) { + return Utils.translateAlbumName(album); + }, + maxAssets: 1, + requestType: RequestType.image, + filterOptions: FilterOptionGroup( + imageOption: const FilterOption(), + ), + ), + ); + + if (pickedAssets != null && pickedAssets.isNotEmpty) { + final asset = pickedAssets.first; + final file = await asset.file; // 获取实际文件 + if (file != null) { + final fileSizeInBytes = await file.length(); + final sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 100) { + MyDialog.toast('图片大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + //走upload(file)上传图片拿到url地址 + final istance = MyDialog.loading('上传中'); + final res = await Http.upload(CommonApi.uploadFile, filePath: file.path); + userInfoController.customInfo['coverBg'] = res['data']['url']; + userInfoController.updateCover(); + userInfoController.customInfo.refresh(); + istance.close(); + } + } + } + } + + ///选头像 + void pickFaceUrl(BuildContext context) async { + final pickedAssets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + textDelegate: const AssetPickerTextDelegate(), + pathNameBuilder: (AssetPathEntity album) { + return Utils.translateAlbumName(album); + }, + maxAssets: 1, + requestType: RequestType.image, + filterOptions: FilterOptionGroup( + imageOption: const FilterOption(), + ), + ), + ); + + if (pickedAssets != null && pickedAssets.isNotEmpty) { + final asset = pickedAssets.first; + final file = await asset.file; // 获取实际文件 + if (file != null) { + final fileSizeInBytes = await file.length(); + final sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 100) { + MyDialog.toast('图片大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + //走upload(file)上传图片拿到url地址 + final istance = MyDialog.loading('上传中'); + final res = await Http.upload(CommonApi.uploadFile, filePath: file.path); + userInfoController.faceUrl.value = res['data']['url']; + userInfoController.updateFaceUrl(); + userInfoController.customInfo.refresh(); + istance.close(); + } + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12.0), + child: GestureDetector( + onTap: () => pickCover(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + decoration: BoxDecoration( + color: Colors.black.withAlpha(100), + borderRadius: BorderRadius.circular(20.0), + ), + child: Row( + children: const [ + Icon(Icons.camera_alt, color: Colors.white, size: 16), + SizedBox(width: 4), + Text('更换封面', style: TextStyle(color: Colors.white, fontSize: 14)), + ], + ), + ), + ), + ), + ], + ), + body: SizedBox.expand( + child: Stack( + children: [ + // 封面图 + SizedBox( + height: 240, + width: double.infinity, + child: Obx(() { + final imageUrl = userInfoController.customInfo['coverBg']; + return GestureDetector( + onTap: () => pickCover(context), + child: Image( + image: (imageUrl != null && imageUrl.isNotEmpty) ? NetworkImage(imageUrl) : const AssetImage('assets/images/pic2.jpg') as ImageProvider, + fit: BoxFit.cover, + ), + ); + }), + ), + + // 白色内容容器 + Positioned( + top: 220, + left: 0, + right: 0, + bottom: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(30), + topRight: Radius.circular(30), + ), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(30), + topRight: Radius.circular(30), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 60, bottom: 40), + child: Column( + children: [ + const SizedBox(height: 0), + const SizedBox(height: 20), + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + color: Colors.white, + child: Column( + children: items.map((item) { + return Column( + children: [ + ListTile( + title: Row( + children: [ + Text( + item['title'], + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 50), + Expanded(child: Obx(() { + final val = item['value']; + if (val is RxString) { + return Text( + val.value, + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } else if (val is RxInt) { + String displayText; + if (item['title'] == '性别') { + displayText = val.value == 0 + ? '保密' + : val.value == 1 + ? '男' + : val.value == 2 + ? '女' + : ''; + } else { + // 生日 + // displayText = val.value == 0 ? '' : val.value.toString(); + if (val.value == 0) { + displayText = ''; + } else { + final date = DateTime.fromMillisecondsSinceEpoch(val.value * 1000); + displayText = "${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}"; + } + } + return Text( + displayText, + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } else if (val is String) { + return Text( + val, + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } else if (val is Map) { + if (item['title'] == '区域') { + return Text( + val['area'] ?? '', + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } else { + String wxText = val['unionId'] == null || val['unionId'] == '' ? '未授权' : '已授权'; + return Row( + children: [ + Spacer(), + Text( + wxText, + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ); + } + } else { + return const SizedBox.shrink(); + } + })) + ], + ), + trailing: const Icon( + Icons.chevron_right, + color: FStyle.c999, + ), + onTap: item['onTap'], + ), + ], + ); + }).toList(), + ), + ) + ], + ), + ), + ), + ), + ), + + // 头像层级最高,放在Stack最后面,覆盖白色容器和封面图 + Positioned( + top: 220 - 60, // 封面图高度减去头像半径,使头像中线对齐封面底线 + left: 0, + right: 0, + child: Obx(() { + final avatar = userInfoController.faceUrl.value; + return Center( + child: GestureDetector( + onTap: () => pickFaceUrl(context), + child: CircleAvatar( + radius: 60, + backgroundColor: Colors.white, + child: CircleAvatar( + radius: 57, + backgroundImage: avatar.isNotEmpty ? NetworkImage(avatar) : const AssetImage('assets/images/avatar/img11.jpg') as ImageProvider, + ), + ), + ), + ); + }), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/my/vloger.dart b/lib/pages/my/vloger.dart new file mode 100644 index 0000000..873db9e --- /dev/null +++ b/lib/pages/my/vloger.dart @@ -0,0 +1,648 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:get/get_rx/src/rx_typedefs/rx_typedefs.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/components/custom_sticky_header.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; +import 'package:loopin/components/only_down_scroll_physics.dart'; +import 'package:loopin/styles/index.dart'; +import 'package:nested_scroll_view_plus/nested_scroll_view_plus.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart'; + +class PageParams { + int page; + int pageSize; + bool isLoading; + bool hasMore; + + PageParams({ + this.page = 1, + this.pageSize = 10, + this.isLoading = false, + this.hasMore = true, + }); +} + +class Vloger extends StatefulWidget { + const Vloger({super.key}); + + @override + State createState() => MyPageState(); +} + +class MyPageState extends State with SingleTickerProviderStateMixin { + late dynamic args; + late RxInt currentTabIndex = 0.obs; + late RxList items = [].obs; + late RxList favoriteItems = [].obs; + late final Rx userInfo = Rx(V2TimUserFullInfo( + userID: '', + nickName: '', + faceUrl: '', + selfSignature: '', + gender: 0, + customInfo: { + 'coverBg': '', + }, + role: 0, + )); + + late RxInt followed = 0.obs; // 是否关注 + // followersCount粉丝,多少人关注了我,mutualFollowersCount互关,followingCount我关注了多少人 + late final Rx followInfo = Rx(V2TimFollowInfo( + followersCount: 0, + followingCount: 0, + )); + + RxBool get shouldFixHeader => (currentTabIndex.value == 0 && items.isEmpty) || (currentTabIndex.value == 1 && favoriteItems.isEmpty) ? true.obs : false.obs; + + List tabList = [ + {'name': "作品"}, + ]; + + late PageParams itemsParams; + late PageParams favoriteParams; + + late TabController tabController; + late ScrollController scrollController; + + late Callback tabListener; + late Callback scrollListener; + + @override + void initState() { + super.initState(); + args = Get.arguments ?? {}; + itemsParams = PageParams(); + favoriteParams = PageParams(); + selfInfo(); + flowInfo(); + checkFollowType(); + initControllers(); + + scrollListener = () { + final pos = scrollController.position; + final isNearBottom = pos.pixels >= pos.maxScrollExtent - 100; + + if (!isNearBottom) return; + + if (currentTabIndex.value == 0 && !itemsParams.isLoading && itemsParams.hasMore) { + loadData(0); + } else if (currentTabIndex.value == 1 && !favoriteParams.isLoading && favoriteParams.hasMore) { + loadData(1); + } + }; + scrollController.addListener(scrollListener); + + tabListener = () { + currentTabIndex.value = tabController.index; + scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeIn); + loadData(0); + }; + tabController.addListener(tabListener); + + loadData(0); + } + + @override + void dispose() { + tabController.removeListener(tabListener); + scrollController.removeListener(scrollListener); + + tabController.dispose(); + scrollController.dispose(); + super.dispose(); + } + + void loadData([int? tabIndex]) async { + final index = tabIndex ?? currentTabIndex.value; + if (index == 0) { + if (itemsParams.isLoading || !itemsParams.hasMore) return; + + itemsParams.isLoading = true; + + await Future.delayed(const Duration(seconds: 1)); + + // 模拟生成新数据 + List newItems = List.generate( + itemsParams.pageSize, + (i) => '作品 ${(itemsParams.page - 1) * itemsParams.pageSize + i + 1}', + ); + + // 模拟判断是否还有更多数据 + if (itemsParams.page >= 2) { + itemsParams.hasMore = false; + } + + // 添加新数据,触发响应式更新 + items.addAll(newItems); + + // 页码加一 + itemsParams.page++; + + itemsParams.isLoading = false; + } + } + + void initControllers() { + tabController = TabController(initialIndex: 0, length: tabList.length, vsync: this); + scrollController = ScrollController(); + } + + // 获取当前博主基本信息 + void selfInfo() async { + final resIm = await ImService.instance.otherInfo(args['memberId']); + if (resIm.success && resIm.data != null) { + userInfo.value = resIm.data!; + logger.i(userInfo.value.toLogString()); + } else { + logger.e(resIm.desc); + } + } + + // 博主的关注与粉丝 + void flowInfo() async { + logger.w(args.toString()); + final res = await ImService.instance.getUserFollowInfo(userIDList: [args['memberId']]); + if (res.success && res.data?.first != null) { + //这里少个点赞,从服务端获取 + // followersCount粉丝,多少人关注了我,mutualFollowersCount互关,followingCount我关注了多少人 + followInfo.value = res.data!.first; + logger.i(followInfo.value.toJson()); + } else { + logger.e(res.desc); + } + } + + // 检测当前用户是否关注博主 + void checkFollowType() async { + /// 0:不是好友也没有关注 + /// 1:你关注了对方(单向) + /// 2:对方关注了你(单向) + /// 3:互相关注(双向好友) + final res = await ImService.instance.checkFollowType(userIDList: [args['memberId']]); + if (res.success) { + final followType = res.data?.first.followType ?? 0; + logger.i(res.data?.first.toJson()); + followed.value = followType; + logger.i(followed.value); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFAF6F9), + body: Obx(() { + return NestedScrollViewPlus( + controller: scrollController, + physics: shouldFixHeader.value ? const OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) : const AlwaysScrollableScrollPhysics(), + overscrollBehavior: OverscrollBehavior.outer, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverAppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + expandedHeight: 180.0, + collapsedHeight: 120.0, + pinned: true, + stretch: true, + onStretchTrigger: () async { + logger.i('触发 stretch 拉伸'); + // 加载刷新逻辑 + }, + flexibleSpace: Obx(() { + userInfo.value; + return _buildFlexibleSpace(); + }), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + Obx(() => _buildStatsCard()), + const SizedBox(height: 10.0), + Obx(() => _buildInfoDesc(context)), + const SizedBox(height: 10.0), + Obx(() => _buildFoucsButton(context)), + ], + ), + ), + ), + SliverPersistentHeader( + pinned: true, + delegate: CustomStickyHeader( + child: PreferredSize( + preferredSize: const Size.fromHeight(48.0), + child: Container( + color: Colors.white, + child: TabBar( + controller: tabController, + tabs: tabList.map((item) { + return Tab( + child: Badge.count( + backgroundColor: Colors.red, + count: item['badge'] ?? 0, + isLabelVisible: item['badge'] != null, + alignment: Alignment.topRight, + offset: const Offset(14, -6), + child: Text(item['name'], style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ); + }).toList(), + isScrollable: true, //禁止左右滑动 + tabAlignment: TabAlignment.start, + overlayColor: WidgetStateProperty.all(Colors.transparent), + unselectedLabelColor: Colors.black87, + labelColor: Colors.black, + indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Colors.transparent, width: 2.0)), + indicatorSize: TabBarIndicatorSize.label, + unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), + labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), + dividerHeight: 0, + padding: const EdgeInsets.symmetric(horizontal: 10.0), + labelPadding: const EdgeInsets.symmetric(horizontal: 15.0), + ), + ), + ), + ), + ), + ]; + }, + body: TabBarView( + controller: tabController, + children: [ + // Tab 1: + Obx(() => _buildGridTab(0)), + ], + ), + ); + }), + ); + } + + // 空状态提示 + Widget emptyTip(String text) { + return CustomScrollView( + physics: const OnlyDownScrollPhysics(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 50.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/images/empty.png', width: 100.0), + const SizedBox(height: 8.0), + Text( + text, + style: const TextStyle(color: Colors.grey, fontSize: 13.0), + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildGridTab(int tabIndex) { + final listToShow = tabIndex == 0 ? items : favoriteItems; + final params = tabIndex == 0 ? itemsParams : favoriteParams; + + if (listToShow.isEmpty) { + return emptyTip('暂无相关数据'); + } + + return CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(10.0), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Container( + decoration: BoxDecoration( + color: Colors.blue[100 * ((index % 8) + 1)], + borderRadius: BorderRadius.circular(10.0), + ), + alignment: Alignment.center, + child: Text(listToShow[index], style: const TextStyle(fontWeight: FontWeight.bold)), + ); + }, + childCount: listToShow.length, + ), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10.0, + mainAxisSpacing: 10.0, + childAspectRatio: 1.0, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: params.hasMore ? const CircularProgressIndicator() : const Text('没有更多数据了'), + ), + ), + ), + ], + ); + } + + Widget _buildFlexibleSpace() { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double maxHeight = 180.0; + final double minHeight = 100.0; + final double currentHeight = constraints.maxHeight; + double ratio = (currentHeight - minHeight) / (maxHeight - minHeight); + ratio = ratio.clamp(0.0, 1.0); + String coverBg = userInfo.value.customInfo?['coverBg'] ?? ''; + coverBg = coverBg.isEmpty ? 'assets/images/pic2.jpg' : coverBg; + logger.w(coverBg); + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Opacity( + opacity: 1.0, + child: Image.asset( + coverBg, + fit: BoxFit.cover, + ))), + Positioned( + left: 15.0, + bottom: 0, + right: 15.0, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipOval( + child: NetworkOrAssetImage(imageUrl: userInfo.value.faceUrl), + // child: Image.asset( + // userInfo.value.faceUrl ?? 'assets/images/pic1.jpg', + // height: 60.0, + // width: 60.0, + // fit: BoxFit.cover, + // errorBuilder: (context, error, stackTrace) { + // return Image.asset( + // 'assets/images/pic1.jpg', + // height: 60.0, + // width: 60.0, + // fit: BoxFit.cover, + // ); + // }, + // ), + ), + const SizedBox(width: 15.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), + child: Text( + userInfo.value.nickName ?? '未知', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold, + fontFamily: 'Arial', + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 8.0), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), + child: InkWell( + onTap: () { + logger.i('点击个ID'); + Clipboard.setData(ClipboardData(text: '${userInfo.value.userID}')); + MyDialog.toast('ID已复制', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.green.withAlpha(200))); + }, + child: Text('ID:${userInfo.value.userID}', style: TextStyle(fontSize: 12.0, color: Colors.white)), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildStatsCard() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + boxShadow: [BoxShadow(color: Colors.black.withAlpha(10), offset: const Offset(0.0, 1.0), blurRadius: 2.0, spreadRadius: 0.0)], + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column(children: [Text('9999', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('获赞')]), + Column(children: [ + Text('${followInfo.value.followingCount}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('关注') + ]), + Column(children: [ + Text('${followInfo.value.followersCount}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('粉丝') + ]), + ], + ), + ), + ); + } + + Widget _buildInfoDesc(BuildContext context) { + final tx = userInfo.value.selfSignature; + if (tx == null || tx.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + boxShadow: [BoxShadow(color: Colors.black.withAlpha(10), offset: const Offset(0.0, 1.0), blurRadius: 2.0, spreadRadius: 0.0)], + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${userInfo.value.selfSignature}', + style: const TextStyle(fontSize: 16), + ), + ), + ], + )); + } + + /// 关注按钮 + Widget _buildFoucsButton(BuildContext context) { + // final vlogerId = '1943510443312078850'; // 18832510385,后面改回博主的id + final vlogerId = args['vlogerId']; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + final offsetAnimation = Tween( + begin: Offset((followed.value == 0) ? -1 : 1, 0), + end: Offset.zero, + ).animate(animation); + + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, + child: [1, 3].contains(followed.value) + ? Row( + key: const ValueKey('followed'), + children: [ + Expanded( + child: ElevatedButton( + onPressed: () async { + print('点击已关注'); + final res = await ImService.instance.unfollowUser(userIDList: [vlogerId]); + if (res.success) { + // 如果为1那么状态置为0,为3则置为2 + followed.value = followed.value == 1 ? 0 : 2; + // 取关后不需重置陌生人消息group + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[300], + foregroundColor: Colors.black87, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text(followed.value == 1 + ? '已关注' + : followed.value == 3 + ? '互关' + : '未知状态'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () async { + print('私信'); + // 获取指定会话 + final res = await ImService.instance.getConversation(conversationID: 'c2c_$vlogerId'); + V2TimConversation conversation = res.data; + logger.i(conversation.toLogString()); + if (res.success) { + // final isFriend = await ImService.instance.isMyFriend(vlogerId, FriendTypeEnum.V2TIM_FRIEND_TYPE_BOTH); + // 这里需要注意处理取关后重新关注逻辑 + // 是否互相关注 + if (followed.value == 3) { + Get.toNamed('/chat', arguments: conversation); + } else { + logger.i('对方没关注我'); + logger.i(conversation.toLogString()); + conversation.showName = conversation.showName ?? userInfo.value.nickName; + Get.toNamed('/chatNoFriend', arguments: conversation); + } + } else { + MyDialog.toast(res.desc, icon: const Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[300], + foregroundColor: Colors.black87, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('私信'), + ), + ), + ], + ) + : SizedBox( + key: const ValueKey('not_followed'), + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () async { + // 0没关系,2对方关注了我 + print('点击关注'); + final res = await ImService.instance.followUser(userIDList: [vlogerId]); + if (res.success) { + followed.value = followed.value == 0 ? 1 : 3; + if (followed.value == 3) { + // 修改后若为3,我将此会话移除noFriend会话组 + final res = await ImService.instance.getConversation(conversationID: 'c2c_$vlogerId'); + if (res.success) { + V2TimConversation conversation = res.data; + if (conversation.conversationGroupList?.isNotEmpty == true) { + //移除陌生人会话 + await ImService.instance.deleteConversationsFromGroup( + groupName: conversation.conversationGroupList!.first!, + conversationIDList: [conversation.conversationID], + ); + //重新构建会话 + } + } + } + } + }, + icon: const Icon(Icons.add), + label: const Text('关注'), + style: ElevatedButton.styleFrom( + backgroundColor: FStyle.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/video/index.dart b/lib/pages/video/index.dart index d3859d3..0ca9d6e 100644 --- a/lib/pages/video/index.dart +++ b/lib/pages/video/index.dart @@ -80,20 +80,20 @@ class _VideoPageState extends State with SingleTickerProviderStateMix backgroundColor: ![0, 1, 2].contains(videoModuleController.videoTabIndex.value) ? null : Colors.transparent, foregroundColor: ![0, 1, 2].contains(videoModuleController.videoTabIndex.value) ? Colors.black : Colors.white, titleSpacing: 1.0, - leading: Obx(() => IconButton( - icon: Badge.count( - backgroundColor: Colors.red, - count: 6, - child: Icon( - Icons.sort_rounded, - color: tabColor(), - ), - ), - onPressed: () { - // 自定义打开右侧drawer - scaffoldKey.currentState?.openDrawer(); - }, - )), + // leading: Obx(() => IconButton( + // icon: Badge.count( + // backgroundColor: Colors.red, + // count: 6, + // child: Icon( + // Icons.sort_rounded, + // color: tabColor(), + // ), + // ), + // onPressed: () { + // // 自定义打开右侧drawer + // scaffoldKey.currentState?.openDrawer(); + // }, + // )), title: Obx(() { return ScrollConfiguration( behavior: CustomScrollBehavior().copyWith(scrollbars: false), @@ -160,30 +160,30 @@ class _VideoPageState extends State with SingleTickerProviderStateMix ), ), // 侧边栏 - drawer: Drawer( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(right: Radius.circular(15.0))), - clipBehavior: Clip.antiAlias, - width: 300, - child: Container( - color: Colors.grey[50], - child: Column( - children: [ - SizedBox( - height: 80.0, - ), - Icon( - Icons.tips_and_updates_outlined, - color: Colors.grey, - size: 50.0, - ), - Text( - '自定义侧边栏~', - style: TextStyle(color: Colors.grey, fontSize: 12.0), - ) - ], - ), - ), - ), + // drawer: Drawer( + // shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(right: Radius.circular(15.0))), + // clipBehavior: Clip.antiAlias, + // width: 300, + // child: Container( + // color: Colors.grey[50], + // child: Column( + // children: [ + // SizedBox( + // height: 80.0, + // ), + // Icon( + // Icons.tips_and_updates_outlined, + // color: Colors.grey, + // size: 50.0, + // ), + // Text( + // '自定义侧边栏~', + // style: TextStyle(color: Colors.grey, fontSize: 12.0), + // ) + // ], + // ), + // ), + // ), ); } } diff --git a/lib/pages/video/module/recommend.dart b/lib/pages/video/module/recommend.dart index 82f3647..2b7fbfd 100644 --- a/lib/pages/video/module/recommend.dart +++ b/lib/pages/video/module/recommend.dart @@ -2,16 +2,24 @@ library; import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/im_core.dart'; +import 'package:loopin/IM/im_message.dart'; import 'package:loopin/api/video_api.dart'; +import 'package:loopin/components/my_toast.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; +import 'package:loopin/models/summary_type.dart'; import 'package:loopin/service/http.dart'; +import 'package:loopin/utils/wxsdk.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; import '../../../behavior/custom_scroll_behavior.dart'; import '../../../controller/video_module_controller.dart'; @@ -40,6 +48,7 @@ class RecommendModule extends StatefulWidget { class _RecommendModuleState extends State { VideoModuleController videoModuleController = Get.put(VideoModuleController()); + final ChatController chatController = Get.find(); // class _RecommendModuleState extends State with AutomaticKeepAliveClientMixin { // @override @@ -80,10 +89,16 @@ class _RecommendModuleState extends State { ]; // 分享列表 List shareList = [ + {'icon': 'assets/images/share-wx.png', 'label': '好友'}, {'icon': 'assets/images/share-wx.png', 'label': '微信'}, {'icon': 'assets/images/share-pyq.png', 'label': '朋友圈'}, {'icon': 'assets/images/share-link.png', 'label': '复制链接'}, {'icon': 'assets/images/share-download.png', 'label': '下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载下载下载下载下载下载下载下载下载下载下载下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载下载下载下载下载下载下载下载下载下载下载下载'}, ]; @override @@ -178,10 +193,12 @@ class _RecommendModuleState extends State { if (data['rows'] is List) { List videos = data['rows']; - // for (var item in videos) { - // print("喜欢:${item['likeCounts']}"); - // print("评论:${item['commentsCounts']}"); - // } + for (var item in videos) { + // print("喜欢:${item['likeCounts']}"); + // print("评论:${item['commentsCounts']}"); + // logger.i(item); + item['expanded'] = false; + } setState(() { if (page == 1) { @@ -221,6 +238,8 @@ class _RecommendModuleState extends State { // 评论弹框 void handleComment(index) { + //获取评论数据 + showModalBottomSheet( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))), @@ -423,59 +442,114 @@ class _RecommendModuleState extends State { // 分享弹框 void handleShare(index) { + if (chatController.chatList.isNotEmpty) { + chatController.getConversationList(); + } showModalBottomSheet( backgroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(15.0)), + ), clipBehavior: Clip.antiAlias, context: context, + isScrollControlled: true, builder: (context) { return Material( color: Colors.white, - child: SizedBox( - height: 170, - width: double.infinity, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: ScrollConfiguration( - behavior: CustomScrollBehavior().copyWith(scrollbars: false), - child: ListView.builder( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - padding: EdgeInsets.symmetric(vertical: 20.0, horizontal: 10.0), - itemCount: shareList.length, - itemBuilder: (context, index) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 12.0), + // 分享列表 + SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: shareList.length, + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + return GestureDetector( + onTap: () => handleShareClick(index), + child: Container( + width: 64, + margin: EdgeInsets.symmetric(horizontal: 8.0), child: Column( - spacing: 5.0, + mainAxisSize: MainAxisSize.min, children: [ Image.asset('${shareList[index]['icon']}', width: 48.0), + SizedBox(height: 5), Text( '${shareList[index]['label']}', style: TextStyle(fontSize: 12.0), - ) + overflow: TextOverflow.ellipsis, + ), ], ), + ), + ); + }, + ), + ), + + // 会话列表 + Obx(() { + // 这里过滤掉有分组的会话 + final filteredList = chatController.chatList.where((item) => item.conversation.conversationGroupList?.isEmpty == true).toList(); + if (filteredList.isEmpty) return SizedBox.shrink(); + return SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: filteredList.length, + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + return GestureDetector( + // 点击分享 + onTap: () => handlCoverClick(filteredList[index].conversation), + child: Container( + width: 64, + margin: EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Image.asset('${chatController.chatList[index].faceUrl}', width: 48.0), + NetworkOrAssetImage( + imageUrl: filteredList[index].faceUrl, + width: 48.0, + height: 48.0, + ), + SizedBox(height: 5), + Text( + '${filteredList[index].conversation.showName}', + style: TextStyle(fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), ); }, ), - ), - ), - InkWell( - child: Container( - alignment: Alignment.center, - width: double.infinity, - height: 50.0, - color: Colors.grey[50], - child: Text( - '取消', - style: TextStyle(color: Colors.black87), + ); + }), + + // 取消按钮 + SafeArea( + top: false, + child: InkWell( + onTap: () => Get.back(), + child: Container( + alignment: Alignment.center, + width: double.infinity, + height: 50.0, + color: Colors.grey[50], + child: Text( + '取消', + style: TextStyle(color: Colors.black87), + ), ), ), - onTap: () { - Get.back(); - }, ), ], ), @@ -485,6 +559,52 @@ class _RecommendModuleState extends State { ); } + void handleShareClick(int index) { + print("分享项 $index 被点击"); + final description = videoList[videoModuleController.videoPlayIndex.value]['title'] ?? '获取title失败'; + + if (index == 1) { + // 好友 + Wxsdk.shareToFriend(title: '快来看看这个视频', description: description, webpageUrl: 'https://baidu.com'); + } else if (index == 2) { + // 朋友圈 + Wxsdk.shareToTimeline(title: '快来看看这个视频', webpageUrl: 'https://baidu.com'); + } + } + + void handlCoverClick(V2TimConversation conv) async { + // 发送VideoMsg,获取当前视频信息 + final userId = conv.userID; + final String url = videoList[videoModuleController.videoPlayIndex.value]['url']; + final img = videoList[videoModuleController.videoPlayIndex.value]['firstFrameImg']; + final width = videoList[videoModuleController.videoPlayIndex.value]['width']; + final height = videoList[videoModuleController.videoPlayIndex.value]['height']; + final makeJson = jsonEncode({ + "width": width, + "height": height, + "imgUrl": img, + "videoUrl": url, + }); + final res = await IMMessage().createCustomMessage( + data: makeJson, + ); + if (res.success) { + final sendRes = await IMMessage().sendMessage(msg: res.data!.messageInfo!, toUserID: userId, cloudCustomData: SummaryType.shareVideo); + if (sendRes.success) { + MyToast().tip( + title: '分享成功', + position: 'center', + type: 'success', + ); + Get.back(); + } else { + logger.e(res.desc); + } + } else { + logger.e(res.desc); + } + } + @override Widget build(BuildContext context) { // super.build(context); @@ -601,18 +721,25 @@ class _RecommendModuleState extends State { SizedBox( height: 55.0, width: 48.0, - child: UnconstrainedBox( - alignment: Alignment.topCenter, - child: Container( - height: 48.0, - width: 48.0, - decoration: BoxDecoration( - border: Border.all(color: Colors.white, width: 2.0), - borderRadius: BorderRadius.circular(100.0), - ), - child: ClipOval( - child: - Image.network(videoList[index]['vlogerFace'] ?? 'https://wuzhongjie.com.cn/download/logo.png', fit: BoxFit.cover), + child: GestureDetector( + onTap: () { + player.pause(); + Get.toNamed('/vloger', arguments: videoList[videoModuleController.videoPlayIndex.value]); + }, + child: UnconstrainedBox( + alignment: Alignment.topCenter, + child: Container( + height: 48.0, + width: 48.0, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2.0), + borderRadius: BorderRadius.circular(100.0), + ), + child: ClipOval( + child: NetworkOrAssetImage( + imageUrl: videoList[index]['avatar'], + ), + ), ), ), ), @@ -643,6 +770,7 @@ class _RecommendModuleState extends State { ), ], ), + // 点赞 GestureDetector( child: Column( children: [ @@ -664,6 +792,7 @@ class _RecommendModuleState extends State { }); }, ), + // 评论 GestureDetector( child: Column( children: [ @@ -697,6 +826,7 @@ class _RecommendModuleState extends State { // ), // ], // ), + // 转发 GestureDetector( child: Column( children: [ @@ -706,39 +836,96 @@ class _RecommendModuleState extends State { height: 40.0, width: 40.0, ), - // Text( - // '${videoList[index]['shareNum']}', - // style: TextStyle(color: Colors.white, fontSize: 12.0), - // ), ], ), onTap: () { handleShare(index); }, ), + //举报 + GestureDetector( + child: Column( + children: [ + SvgPicture.asset( + 'assets/images/svg/report.svg', + colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), + height: 40.0, + width: 40.0, + ), + ], + ), + onTap: () { + // 举报 + }, + ), ], ), ), // 底部信息区域 Positioned( - bottom: 15.0, - left: 10.0, - right: 80.0, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 5.0, - children: [ - Text( - '@${videoList[index]['vlogerName'] ?? '未知'}', - style: TextStyle(color: Colors.white, fontSize: 16.0), - ), - Text( - '${videoList[index]['content'] ?? '未知'}', - style: TextStyle(color: Colors.white, fontSize: 14.0), - ), - ], - ), - ), + bottom: 15.0, + left: 10.0, + right: 80.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '@${videoList[videoModuleController.videoPlayIndex.value]['nickname'] ?? '未知'}', + style: const TextStyle(color: Colors.white, fontSize: 16.0), + ), + LayoutBuilder( + builder: (context, constraints) { + final text = videoList[videoModuleController.videoPlayIndex.value]['title'] ?? '未知'; + // 先用 TextPainter 判断是否超过 3 行 + final span = TextSpan( + text: text, + style: const TextStyle(color: Colors.white, fontSize: 14.0), + ); + final tp = TextPainter( + text: span, + maxLines: 3, + textDirection: TextDirection.ltr, + ); + tp.layout(maxWidth: constraints.maxWidth); + final isOverflow = tp.didExceedMaxLines; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + maxLines: videoList[videoModuleController.videoPlayIndex.value]['expanded'] ? null : 3, + overflow: + videoList[videoModuleController.videoPlayIndex.value]['expanded'] ? TextOverflow.visible : TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 14.0), + ), + if (isOverflow) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: GestureDetector( + onTap: () { + setState(() { + videoList[videoModuleController.videoPlayIndex.value]['expanded'] = + !videoList[videoModuleController.videoPlayIndex.value]['expanded']; + }); + }, + child: Text( + videoList[videoModuleController.videoPlayIndex.value]['expanded'] ? '收起' : '展开更多', + textAlign: TextAlign.right, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ); + }, + ), + ], + )), // mini播放进度条 Positioned( bottom: 0.0, diff --git a/lib/router/index.dart b/lib/router/index.dart index 5e438e0..f1b894b 100644 --- a/lib/router/index.dart +++ b/lib/router/index.dart @@ -3,7 +3,15 @@ library; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:loopin/bings/chat_binding.dart'; import 'package:loopin/pages/chat/chat.dart'; +import 'package:loopin/pages/chat/chat_group.dart'; +import 'package:loopin/pages/chat/chat_no_friend.dart'; +import 'package:loopin/pages/my/des.dart'; +import 'package:loopin/pages/my/nick_name.dart'; +import 'package:loopin/pages/my/setting.dart'; +import 'package:loopin/pages/my/user_info.dart'; +import 'package:loopin/pages/my/vloger.dart'; import '../layouts/index.dart'; /* 引入路由页面 */ @@ -19,25 +27,61 @@ import '../utils/common.dart'; // 路由地址集合 final Map routes = { '/': const Layout(), - // '/upload': const UploadVideoPage(), '/goods': const Goods(), - '/chat': const Chat(), + // '/chat': const Chat(), + // '/chatNoFriend': const ChatNoFriend(), + // '/chatGroup': const ChatGroup(), '/order': const Order(), '/order/detail': const OrderDetail(), + '/vloger': const Vloger(), + //settins + '/setting': const Setting(), + '/userInfo': const UserInfo(), + '/notifications': const Setting(), + '/privacy': const Setting(), + '/about': const Setting(), + '/des': const Des(), + '/nickName': const NickName(), }; final List routeList = routes.entries .map((e) => GetPage( name: e.key, // 路由名称 page: () => e.value, // 路由页面 - transition: Transition.rightToLeftWithFade, // 跳转路由动画 + // transition: Transition.rightToLeftWithFade, // 跳转路由动画 + transition: Transition.rightToLeft, // 跳转路由动画 middlewares: [RouteMiddleware()], // 路由中间件 )) .toList(); +final List bingsRoutes = [ + GetPage( + name: '/chat', + page: () => const Chat(), + binding: ChatBinding(), + transition: Transition.rightToLeft, + middlewares: [RouteMiddleware()], + ), + GetPage( + name: '/chatNoFriend', + page: () => const ChatNoFriend(), + binding: ChatBinding(), + transition: Transition.rightToLeft, + middlewares: [RouteMiddleware()], + ), + GetPage( + name: '/chatGroup', + page: () => const ChatGroup(), + binding: ChatBinding(), + transition: Transition.rightToLeft, + middlewares: [RouteMiddleware()], + ), +]; + final List routePages = [ GetPage(name: '/login', page: () => const Login()), ...routeList, + ...bingsRoutes, ]; // 路由中间件拦截验证 diff --git a/lib/service/http.dart b/lib/service/http.dart index 89a8777..fc709aa 100644 --- a/lib/service/http.dart +++ b/lib/service/http.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:loopin/IM/push_service.dart'; import 'http_config.dart'; @@ -15,12 +16,16 @@ class Http { } static Future post(String url, {dynamic data, Map? headers}) async { - final res = await _dio.post( - url, - data: data, - options: Options(extra: headers ?? {}), - ); - return res.data; + try { + final res = await _dio.post( + url, + data: data, + options: Options(extra: headers ?? {}), + ); + return res.data; + } catch (e) { + logger.e('$e--------$url'); + } } static Future put(String url, {dynamic data, Map? headers}) async { diff --git a/lib/service/http_config.dart b/lib/service/http_config.dart index 56b1b8e..140ca7c 100644 --- a/lib/service/http_config.dart +++ b/lib/service/http_config.dart @@ -6,10 +6,11 @@ import 'package:get_storage/get_storage.dart'; class HttpConfig { static final Dio dio = Dio(BaseOptions( // baseUrl: 'http://43.143.227.203:8099', - baseUrl: 'http://111.62.22.190:8080', + // baseUrl: 'http://111.62.22.190:8080', // baseUrl: 'http://cjh.wuzhongjie.com.cn', - connectTimeout: Duration(seconds: 30), - receiveTimeout: Duration(seconds: 30), + baseUrl: 'http://82.156.121.2:8880', + // connectTimeout: Duration(seconds: 30), + // receiveTimeout: Duration(seconds: 30), )); static final box = GetStorage(); @@ -33,13 +34,14 @@ class HttpConfig { handler.next(options); }, onResponse: (response, handler) { + // logger.e(response.requestOptions.data); final data = response.data; - // data['code'] = 200; // 旧接口测试用 if (data is Map) { if (data['code'] != 200) { Get.snackbar( - '错误', - data['msg'] ?? '请求失败', + '错误码${data['code']}', + '${response.requestOptions.uri}\n${response.requestOptions.data}\n${data['msg']}' ?? '请求失败', + duration: Duration(minutes: 1), backgroundColor: Colors.red.withAlpha(230), colorText: Colors.white, icon: const Icon(Icons.error_outline, color: Colors.white), diff --git a/lib/update/upgrade_dialog.dart b/lib/update/upgrade_dialog.dart index a77d8e9..c9c8caf 100644 --- a/lib/update/upgrade_dialog.dart +++ b/lib/update/upgrade_dialog.dart @@ -27,7 +27,7 @@ class UpgradeDialog extends StatelessWidget { children: [ Image.asset('assets/images/update/rocket.png', width: 80, height: 80), const SizedBox(height: 12), - Text("发现新版本 v$version", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Text("发现新版本 $version", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 10), // Text(content, style: TextStyle(fontSize: 14)), Column( diff --git a/lib/update/upgrade_service.dart b/lib/update/upgrade_service.dart index 09a8299..2afa19b 100644 --- a/lib/update/upgrade_service.dart +++ b/lib/update/upgrade_service.dart @@ -1,52 +1,64 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:loopin/IM/im_core.dart'; +import 'package:loopin/api/common_api.dart'; +import 'package:loopin/service/http.dart'; import 'package:package_info_plus/package_info_plus.dart'; -// import 'package:loopin/api/common_api.dart'; -// import 'package:loopin/service/http.dart'; - import 'upgrade_dialog.dart'; import 'upgrade_util.dart'; class UpgradeService { - static Future checkUpgrade(BuildContext context) async { + static Future checkUpgrade(State state) async { final info = await PackageInfo.fromPlatform(); - print('App version: ${info.version}'); - print('version_code: ${info.buildNumber}'); - final currentVersion = info.version; - // final res = await Http.get(CommonApi.checkVersion); - // final data = res['data']; - final data = { - "version": "4.1.0", - "content": [ - "新增火箭弹窗", - "修复若干 Bug", - "优化界面动画", - ], - "force": 0, - "apkUrl": "https://wuzhongjie.com.cn/download/wzj.apk", - "iosUrl": "https://apps.apple.com/cn/app/无终街/id6479185362", - }; - // 0 表示 false非强制,非 0 表示 true强制 - final bool force = (data['force'] ?? 0) != 0; - // 弹窗 - showDialog( - context: context, - barrierDismissible: !force, - builder: (_) => UpgradeDialog( - version: data['version']?.toString() ?? '', - content: (data['content'] as List).map((e) => e.toString()).toList(), - force: force, - onConfirm: () { - if (Platform.isAndroid) { - Navigator.pop(context); - UpgradeUtil.downloadAndInstallAPK(context, data['apkUrl']?.toString() ?? ''); - } else if (Platform.isIOS) { - UpgradeUtil.launchAppStore(data['iosUrl']?.toString() ?? ''); - } - }, - ), - ); + if (!state.mounted) return; + + logger.i('App version: ${info.version}'); + logger.i('version_code: ${info.buildNumber}'); + final res = await Http.post(CommonApi.checkVersion, data: { + 'platformType': Platform.isAndroid ? 'android' : 'ios', + 'status': 1, + }); + if (!state.mounted) return; + + // logger.i(res); + final result = res['data']['records'] as List; + final data = result.first; + final currentVersion = info.buildNumber; + if (currentVersion != data['versionCode']) { + // 版本号不一致 + // 0 表示 false非强制,非 0 表示 true强制 + final bool force = (data['isForceUpdate'] ?? 0) != 0; + // 弹窗 + showDialog( + context: state.context, + barrierDismissible: !force, + builder: (_) => UpgradeDialog( + version: data['versionName']?.toString() ?? '', + content: (data['releaseNotes'] as String).split(',').map((e) => e.trim()).toList(), + force: force, + onConfirm: () { + if (Platform.isAndroid) { + Navigator.pop(state.context); + UpgradeUtil.downloadAndInstallAPK(state.context, data['downloadUrl']?.toString() ?? ''); + } else if (Platform.isIOS) { + UpgradeUtil.launchAppStore(data['downloadUrl']?.toString() ?? ''); + } + }, + ), + ); + } + // final data = { + // "version": "4.1.0", + // "content": [ + // "新增火箭弹窗", + // "修复若干 Bug", + // "优化界面动画", + // ], + // "force": 0, + // "apkUrl": "https://wuzhongjie.com.cn/download/wzj.apk", + // "iosUrl": "https://apps.apple.com/cn/app/无终街/id6479185362", + // }; } } diff --git a/lib/utils/audio_player_service.dart b/lib/utils/audio_player_service.dart new file mode 100644 index 0000000..1392b95 --- /dev/null +++ b/lib/utils/audio_player_service.dart @@ -0,0 +1,57 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:loopin/IM/im_core.dart'; + +class AudioPlayerService { + static final AudioPlayerService _instance = AudioPlayerService._internal(); + factory AudioPlayerService() => _instance; + AudioPlayerService._internal(); + + final AudioPlayer _audioPlayer = AudioPlayer(); + + /// 播放本地文件 + Future playLocal(String filePath) async { + try { + await _audioPlayer.setSourceDeviceFile(filePath); + await _audioPlayer.resume(); + } catch (e) { + logger.e('播放本地音频失败: $e'); + } + } + + /// 播放网络音频 + Future playNetwork(String url) async { + try { + await _audioPlayer.setSourceUrl(url); + await _audioPlayer.resume(); + } catch (e) { + logger.e('播放网络音频失败: $e'); + } + } + + /// 暂停播放 + Future pause() async { + try { + await _audioPlayer.pause(); + } catch (e) { + logger.e('暂停播放失败: $e'); + } + } + + /// 停止播放 + Future stop() async { + try { + await _audioPlayer.stop(); + } catch (e) { + logger.e('停止播放失败: $e'); + } + } + + /// 释放资源 + Future dispose() async { + try { + await _audioPlayer.dispose(); + } catch (e) { + logger.e('释放播放器失败: $e'); + } + } +} diff --git a/lib/utils/common.dart b/lib/utils/common.dart index 44316a5..3bbb784 100644 --- a/lib/utils/common.dart +++ b/lib/utils/common.dart @@ -5,7 +5,7 @@ import './storage.dart'; class Common { /// 判断是否登录 - static isLogin() { + static bool isLogin() { return Storage.hasData('hasLogged'); } diff --git a/lib/utils/index.dart b/lib/utils/index.dart index 662a7a9..50d613c 100644 --- a/lib/utils/index.dart +++ b/lib/utils/index.dart @@ -55,7 +55,7 @@ class Utils { } /// 是否为空 - static isEmpty(val) { + static bool isEmpty(val) { if (val == null) return true; if (val is bool && val == false) return true; if (val is String) return val.isEmpty; @@ -87,6 +87,7 @@ class Utils { //翻译媒体库title static String translateAlbumName(AssetPathEntity album) { + // logger.i(album.name); final Map albumNameMap = { 'recents': '最近项目', 'favorites': '收藏', @@ -105,6 +106,8 @@ class Utils { 'animated': '动图', 'raw': 'RAW格式', 'hidden': '已隐藏', + 'recent': '最近项目', // 安卓 + 'camera': '相机', // 安卓 }; return albumNameMap[album.name.toLowerCase()] ?? album.name; } diff --git a/lib/utils/notification_banner.dart b/lib/utils/notification_banner.dart index e737646..c453e0c 100644 --- a/lib/utils/notification_banner.dart +++ b/lib/utils/notification_banner.dart @@ -4,6 +4,7 @@ import 'package:loopin/IM/im_service.dart'; import 'package:loopin/utils/parse_message_summary.dart'; import 'package:shirne_dialog/shirne_dialog.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart'; class NotificationBanner { static void show(V2TimMessage msg) { @@ -49,4 +50,32 @@ class NotificationBanner { }, ); } + + /// 被关注通知 + static void foucs(V2TimUserFullInfo msg) { + final nickname = msg.nickName ?? '未知用户'; + final avatar = msg.faceUrl ?? ''; + final text = '$nickname:关注了你'; + + Get.snackbar( + '新的关注', + text, + duration: const Duration(seconds: 5), + snackPosition: SnackPosition.TOP, + margin: const EdgeInsets.all(12), + backgroundColor: Get.theme.cardColor, + colorText: Get.theme.textTheme.bodyLarge?.color, + icon: avatar.isNotEmpty + ? CircleAvatar( + backgroundImage: NetworkImage(avatar), + radius: 16, + ) + : null, + onTap: (_) async { + // 点击后立刻关闭 + Get.closeCurrentSnackbar(); + // 跳转到新关注我的页面 + }, + ); + } } diff --git a/lib/utils/parse_message_summary.dart b/lib/utils/parse_message_summary.dart index 2568e16..a395485 100644 --- a/lib/utils/parse_message_summary.dart +++ b/lib/utils/parse_message_summary.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:loopin/models/summary_type.dart'; import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; @@ -14,37 +15,38 @@ String parseMessageSummary(V2TimMessage msg) { case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: return '[视频]'; case MessageElemType.V2TIM_ELEM_TYPE_FILE: - return '[文件]'; + return '[文件]'; // 先不做 case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: - return '[位置]'; + return '[位置]'; // 先不做 case MessageElemType.V2TIM_ELEM_TYPE_FACE: return '[表情]'; case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: - return _parseCustomMessage(msg.customElem?.data); + return _parseCustomMessage(msg); case MessageElemType.V2TIM_ELEM_TYPE_MERGER: return '[合并转发消息]'; case MessageElemType.V2TIM_ELEM_TYPE_GROUP_TIPS: return '[群提示]'; default: - return '[未知消息类型]'; + return '[未知消息类型1]'; } } -String _parseCustomMessage(String? data) { - if (data == null || data.isEmpty) return '[自定义消息]'; - +String _parseCustomMessage(V2TimMessage? msg) { + if (msg == null) return '[null]'; + final sum = msg.cloudCustomData; try { - final jsonData = json.decode(data); - final type = jsonData['type']; - switch (type) { - case 'interaction': - return '[互动] ${jsonData['action'] ?? ''}'; - case 'forward': - return '[转发] ${jsonData['title'] ?? ''}'; + switch (sum) { + case SummaryType.hongbao: + final hbData = jsonDecode(msg.customElem!.data!); + return '[红包]${hbData['remark']}'; + case SummaryType.shareTuangou: + return '[分享商品]'; + case SummaryType.shareVideo: + return '[分享视频]'; default: - return '[自定义消息]'; + return '[未知消息类型2]'; } } catch (_) { - return '[自定义消息]'; + return '[未知消息类型3]'; } } diff --git a/lib/utils/snapshot.dart b/lib/utils/snapshot.dart new file mode 100644 index 0000000..1be4149 --- /dev/null +++ b/lib/utils/snapshot.dart @@ -0,0 +1,15 @@ +import 'package:path_provider/path_provider.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; + +/// 视频首帧截取 +Future generateVideoThumbnail(String videoPath) async { + final tempDir = await getTemporaryDirectory(); + final thumbnailPath = await VideoThumbnail.thumbnailFile( + video: videoPath, + thumbnailPath: tempDir.path, + imageFormat: ImageFormat.JPEG, + maxWidth: 120, + quality: 75, + ); + return thumbnailPath; +} diff --git a/lib/utils/voice_service.dart b/lib/utils/voice_service.dart new file mode 100644 index 0000000..bee4098 --- /dev/null +++ b/lib/utils/voice_service.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:loopin/IM/im_core.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +class VoiceService { + static final VoiceService _instance = VoiceService._internal(); + factory VoiceService() => _instance; + VoiceService._internal(); + + final AudioRecorder _recorder = AudioRecorder(); + String? _voiceFilePath; + DateTime? _startTime; + + /// 开始录音 + Future startRecording() async { + if (await _recorder.hasPermission()) { + final dir = await getTemporaryDirectory(); // 临时目录 + final filePath = '${dir.path}/${DateTime.now().millisecondsSinceEpoch}.m4a'; + _voiceFilePath = filePath; + _startTime = DateTime.now(); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.aacLc, + bitRate: 128000, + sampleRate: 44100, + ), + path: filePath, + ); + logger.i('开始录音,文件路径: $filePath'); + + return true; + } else { + logger.e("没有录音权限"); + + return false; + } + } + + /// 停止录音并返回文件路径 + Future?> stopRecording() async { + await _recorder.stop(); + + if (_voiceFilePath != null && File(_voiceFilePath!).existsSync() && _startTime != null) { + final duration = DateTime.now().difference(_startTime!); + final durationSeconds = duration.inSeconds; + if (durationSeconds < 1 || durationSeconds > 60) { + logger.w('录音时长不在允许范围(1-60秒),删除文件: $_voiceFilePath,时长: $durationSeconds 秒'); + try { + await File(_voiceFilePath!).delete(); + } catch (e) { + logger.e('删除录音文件失败: $e'); + } + return null; + } + logger.i('录音完成: $_voiceFilePath,时长: ${duration.inSeconds}秒'); + return { + 'path': _voiceFilePath, + 'duration': duration.inMilliseconds, + }; + } + logger.e("没有录到音频文件"); + return null; + } + + /// 取消录音(删除文件) + Future cancelRecording() async { + await _recorder.stop(); + if (_voiceFilePath != null) { + final file = File(_voiceFilePath!); + if (await file.exists()) { + await file.delete(); + logger.i('录音已取消并删除'); + } + } + _voiceFilePath = null; + } +} diff --git a/lib/utils/wxsdk.dart b/lib/utils/wxsdk.dart new file mode 100644 index 0000000..cdb8ea6 --- /dev/null +++ b/lib/utils/wxsdk.dart @@ -0,0 +1,144 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; +import 'package:fluwx/fluwx.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/api/common_api.dart'; +import 'package:loopin/service/http.dart'; + +class Wxsdk { + static bool _inited = false; + + static final Fluwx fluwx = Fluwx(); + + static Future init() async { + if (_inited) { + return true; + } + _inited = true; + final initRes = await fluwx.registerApi( + appId: 'wxebcdaea31881caab', + doOnAndroid: true, + doOnIOS: true, + universalLink: 'https://wuzhongjie.com.cn/', + ); + if (initRes) { + logger.i('微信sdk初始化成功'); + // 全局监听授权回调 + fluwx.addSubscriber( + (res) async { + //授权 + if (res is WeChatAuthResponse) { + if (res.isSuccessful) { + final code = res.code; + logger.i('微信回调,code: $code,类型:${res.state}'); + if (res.state == 'getOpenId') { + // TODO: 使用 code 向后台换取 access_token、unionid + final serverRes = await Http.post(CommonApi.wxLogin, data: { + "source": "wechat_open", + "socialCode": "${res.code}", + "socialState": "1", + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "social" + }); + final info = Get.find(); + info.customInfo['openId'] = serverRes['data']['openId']; + info.updateOpenId(); + info.customInfo.refresh(); + logger.w(serverRes['data']['openId']); + } + } else { + logger.w('微信授权失败: ${res.errStr}-类型:${res.state}'); + } + } + // 分享 + if (res is WeChatShareResponse) { + logger.w(res.isSuccessful); + // 这里只能确保打开了微信,是取消了还是确认了没办法知道 + if (res.isSuccessful) {} + } + }, + ); + } else { + logger.i('微信SDK初始化失败:$initRes'); + } + return initRes; + } + + /// 调用微信登录 + static Future login() async { + final result = await fluwx.authBy( + which: NormalAuth( + scope: 'snsapi_userinfo', + state: 'getOpenId', + ), + ); + if (!result) { + logger.e('微信授权请求发送失败'); + } + } + + ///分享好友 + static Future shareToFriend({ + required String title, + required String description, + required String webpageUrl, + String thumbnailAssetPath = 'assets/images/logo/logo.png', + }) async { + Uint8List? thumbData; + thumbData = await _loadLocalThumbnail(thumbnailAssetPath); + final model = WeChatShareWebPageModel( + webpageUrl, + title: title, + description: description, + thumbData: thumbData, + scene: WeChatScene.session, + ); + return Fluwx().share(model); + } + + ///分享到朋友圈 + static Future shareToTimeline({ + required String title, + required String webpageUrl, + String thumbnailAssetPath = 'assets/images/logo/logo.png', + }) async { + Uint8List? thumbData; + thumbData = await _loadLocalThumbnail(thumbnailAssetPath); + final model = WeChatShareWebPageModel( + webpageUrl, + title: title, + thumbData: thumbData, + scene: WeChatScene.timeline, + ); + return Fluwx().share(model); + } + + static Future _loadLocalThumbnail(String assetPath) async { + final byteData = await rootBundle.load(assetPath); + final originBytes = byteData.buffer.asUint8List(); + + final compressedBytes = await FlutterImageCompress.compressWithList( + originBytes, + minWidth: 120, // 微信要求120X120且小于32kb + minHeight: 120, + quality: 80, // 控制质量 + format: CompressFormat.jpeg, // 转为 JPEG + ); + + logger.i("thumbData size: ${compressedBytes.length} bytes"); + + return compressedBytes; + } + + /// 跳转小程序 + static Future openMiniApp({required orderId}) async { + var miniProgram = MiniProgram( + username: "gh_2ffaecc5508e", // 小程序原始ID + path: "/pages/index/index?id=$orderId", // 打开时带的路径参数 + miniProgramType: WXMiniProgramType.test, + ); + Fluwx().open(target: miniProgram); + } +} diff --git a/pubspec.lock b/pubspec.lock index ef230e6..8315bef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.12.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: e653f162ddfcec1da2040ba2d8553fff1662b5c2a5c636f4c21a3b11bee497de + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.1" boolean_selector: dependency: transitive description: @@ -49,6 +105,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" + bottom_picker: + dependency: "direct main" + description: + name: bottom_picker + sha256: b83c35861314aafdef6857be1a8d900d82fa90c979a12af9b653d5d9e7d35beb + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.1" card_swiper: dependency: "direct main" description: @@ -73,6 +137,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.3" + city_pickers: + dependency: "direct main" + description: + name: city_pickers + sha256: "583102c8d9eecb1f7abc5ff52a22d7cb019b9808cdb24b80c7692c769f8da153" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" cli_util: dependency: transitive description: @@ -177,6 +249,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.1" + easy_refresh: + dependency: "direct main" + description: + name: easy_refresh + sha256: "486e30abfcaae66c0f2c2798a10de2298eb9dc5e0bb7e1dba9328308968cae0c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.0" extended_image: dependency: transitive description: @@ -270,6 +350,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "10.0.1" + flutter_html: + dependency: "direct main" + description: + name: flutter_html + sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.6" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.3" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.5" flutter_launcher_icons: dependency: "direct dev" description: @@ -341,6 +477,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluwx: + dependency: "direct main" + description: + name: fluwx + sha256: "9db31d54043363c9c8283b5f0bc4df982edb45ba19d800df9d7de96a205371ae" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.7.0" form_builder_validators: dependency: "direct main" description: @@ -581,6 +725,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "5.1.1" + list_counter: + dependency: transitive + description: + name: list_counter + sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" logger: dependency: "direct main" description: @@ -597,6 +749,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.3.0" + lpinyin: + dependency: transitive + description: + name: lpinyin + sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.3" matcher: dependency: transitive description: @@ -749,6 +909,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_drawing: + dependency: transitive + description: + name: path_drawing + sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" path_parsing: dependency: transitive description: @@ -917,6 +1085,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.5" + record: + dependency: "direct main" + description: + name: record + sha256: daeb3f9b3fea9797094433fe6e49a879d8e4ca4207740bc6dc7e4a58764f0817 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "97d7122455f30de89a01c6c244c839085be6b12abca251fc0e78f67fed73628b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "73706ebbece6150654c9d6f57897cf9b622c581148304132ba85dba15df0fdfb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "0626678a092c75ce6af1e32fe7fd1dea709b92d308bc8e3b6d6348e2430beb95" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "02240833fde16c33fcf2c589f3e08d4394b704761b4a3bb609d872ff3043fbbd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: c1ad38f51e4af88a085b3e792a22c685cb3e7c23fc37aa7ce44c4cf18f25fe89 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: a12856d0b3dd03d336b4b10d7520a8b3e21649a06a8f95815318feaa8f07adbb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.9" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "85a22fc97f6d73ecd67c8ba5f2f472b74ef1d906f795b7970f771a0914167e99" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.6" safe_local_storage: dependency: transitive description: @@ -941,6 +1173,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.0" + scrollable_positioned_list: + dependency: transitive + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.8" shirne_dialog: dependency: "direct main" description: @@ -1002,6 +1242,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.3.1" + tencent_cloud_chat_push: + dependency: "direct main" + description: + name: tencent_cloud_chat_push + sha256: "7a76d107715e99fd4ed11489b9aa662e2f22d3f4614d9cee72e3f9d97c6f8b0f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.6.7019+1" tencent_cloud_chat_sdk: dependency: "direct main" description: @@ -1202,6 +1450,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.3.5" + video_thumbnail: + dependency: "direct main" + description: + name: video_thumbnail + sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.6" visibility_detector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b31d016..2ebbf70 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,8 +41,11 @@ dependencies: cupertino_icons: ^1.0.8 card_swiper: ^3.0.1 flutter_svg: ^2.0.16 + easy_refresh: ^3.4.0 # 腾讯IM tencent_cloud_chat_sdk: ^8.6.7019+2 + # 离线推送 + tencent_cloud_chat_push: ^8.6.7019+1 # 瀑布流组件 flutter_staggered_grid_view: ^0.7.0 # 状态管理 @@ -67,14 +70,23 @@ dependencies: wechat_assets_picker: ^9.5.1 device_info_plus: ^11.5.0 - photo_manager: ^3.7.1 + photo_manager: ^3.7.1 #翻译媒体库 flutter_form_builder: ^10.0.1 form_builder_validators: ^11.1.2 geolocator: ^14.0.1 - nested_scroll_view_plus: ^3.0.0 + nested_scroll_view_plus: ^3.0.0 #滚动 ai_barcode_scanner: ^7.0.0 + city_pickers: ^1.3.0 + bottom_picker: ^3.2.1 + + fluwx: ^5.7.0 #微信sdk + flutter_image_compress: ^2.4.0 #处理图片 + video_thumbnail: ^0.5.6 #视频首帧截取 + record: ^6.0.0 #音频 + audioplayers: ^6.5.0 #音频播放 + flutter_html: ^3.0.0 dev_dependencies: flutter_launcher_icons: ^0.13.1 # 使用最新版本 @@ -101,6 +113,8 @@ flutter: assets: - assets/images/ + #notify + - assets/images/notify/ #logo - assets/images/logo/ #avatar