- UI改版优化
|
|
@ -344,6 +344,8 @@
|
||||||
55B221230000000100784823 /* MoodStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B221220000000100784822 /* MoodStatusView.swift */; };
|
55B221230000000100784823 /* MoodStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B221220000000100784822 /* MoodStatusView.swift */; };
|
||||||
55B221250000000100784825 /* MoodStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B221240000000100784824 /* MoodStatusViewModel.swift */; };
|
55B221250000000100784825 /* MoodStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B221240000000100784824 /* MoodStatusViewModel.swift */; };
|
||||||
55B221270000000100784827 /* MoodStatusCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B221260000000100784826 /* MoodStatusCatalog.swift */; };
|
55B221270000000100784827 /* MoodStatusCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B221260000000100784826 /* MoodStatusCatalog.swift */; };
|
||||||
|
55B222010000000100784831 /* AddEmojiPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B222000000000100784830 /* AddEmojiPopView.swift */; };
|
||||||
|
55B222030000000100784833 /* AddMessagePopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B222020000000100784832 /* AddMessagePopView.swift */; };
|
||||||
55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; };
|
55BF752D2FFE53F70055DA57 /* LocationPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */; };
|
||||||
55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */; };
|
55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */; };
|
||||||
55C219C13024C00100784731 /* SearchLocationHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C219C13024C00100784730 /* SearchLocationHeaderView.swift */; };
|
55C219C13024C00100784731 /* SearchLocationHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C219C13024C00100784730 /* SearchLocationHeaderView.swift */; };
|
||||||
|
|
@ -765,6 +767,8 @@
|
||||||
55B221220000000100784822 /* MoodStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodStatusView.swift; sourceTree = "<group>"; };
|
55B221220000000100784822 /* MoodStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodStatusView.swift; sourceTree = "<group>"; };
|
||||||
55B221240000000100784824 /* MoodStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodStatusViewModel.swift; sourceTree = "<group>"; };
|
55B221240000000100784824 /* MoodStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodStatusViewModel.swift; sourceTree = "<group>"; };
|
||||||
55B221260000000100784826 /* MoodStatusCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodStatusCatalog.swift; sourceTree = "<group>"; };
|
55B221260000000100784826 /* MoodStatusCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodStatusCatalog.swift; sourceTree = "<group>"; };
|
||||||
|
55B222000000000100784830 /* AddEmojiPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEmojiPopView.swift; sourceTree = "<group>"; };
|
||||||
|
55B222020000000100784832 /* AddMessagePopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMessagePopView.swift; sourceTree = "<group>"; };
|
||||||
55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = "<group>"; };
|
55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = "<group>"; };
|
||||||
55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = "<group>"; };
|
55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = "<group>"; };
|
||||||
55C219C13024C00100784730 /* SearchLocationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationHeaderView.swift; sourceTree = "<group>"; };
|
55C219C13024C00100784730 /* SearchLocationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationHeaderView.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -1247,6 +1251,8 @@
|
||||||
305A76272FCA8C7000227D26 /* GroupMemberView.swift */,
|
305A76272FCA8C7000227D26 /* GroupMemberView.swift */,
|
||||||
55B2179030217D6600784774 /* HomeView2.swift */,
|
55B2179030217D6600784774 /* HomeView2.swift */,
|
||||||
55B2179230218CE100784774 /* GroupMemberView2.swift */,
|
55B2179230218CE100784774 /* GroupMemberView2.swift */,
|
||||||
|
55B222000000000100784830 /* AddEmojiPopView.swift */,
|
||||||
|
55B222020000000100784832 /* AddMessagePopView.swift */,
|
||||||
55B219A1302A000100784750 /* UnlockRequestPopView.swift */,
|
55B219A1302A000100784750 /* UnlockRequestPopView.swift */,
|
||||||
55B219B1302A000100784760 /* ReceiveMessagePopView.swift */,
|
55B219B1302A000100784760 /* ReceiveMessagePopView.swift */,
|
||||||
55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */,
|
55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */,
|
||||||
|
|
@ -2448,6 +2454,8 @@
|
||||||
55B220053031000100784805 /* FeedbackSuccessView.swift in Sources */,
|
55B220053031000100784805 /* FeedbackSuccessView.swift in Sources */,
|
||||||
55B221210000000100784821 /* MoodStatusVC.swift in Sources */,
|
55B221210000000100784821 /* MoodStatusVC.swift in Sources */,
|
||||||
55B221230000000100784823 /* MoodStatusView.swift in Sources */,
|
55B221230000000100784823 /* MoodStatusView.swift in Sources */,
|
||||||
|
55B222010000000100784831 /* AddEmojiPopView.swift in Sources */,
|
||||||
|
55B222030000000100784833 /* AddMessagePopView.swift in Sources */,
|
||||||
55B221250000000100784825 /* MoodStatusViewModel.swift in Sources */,
|
55B221250000000100784825 /* MoodStatusViewModel.swift in Sources */,
|
||||||
55B221270000000100784827 /* MoodStatusCatalog.swift in Sources */,
|
55B221270000000100784827 /* MoodStatusCatalog.swift in Sources */,
|
||||||
30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */,
|
30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */,
|
||||||
|
|
@ -2777,6 +2785,7 @@
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风发送语音消息。";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风发送语音消息。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。";
|
||||||
|
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "我们需要语音识别权限,以便将语音转换成文字。";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UIBackgroundModes = location;
|
INFOPLIST_KEY_UIBackgroundModes = location;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
|
|
@ -2831,6 +2840,7 @@
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风发送语音消息。";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风发送语音消息。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。";
|
||||||
|
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "我们需要语音识别权限,以便将语音转换成文字。";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UIBackgroundModes = location;
|
INFOPLIST_KEY_UIBackgroundModes = location;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
|
|
@ -2982,6 +2992,174 @@
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
559B7BA23037017D0031C511 /* AdHoc */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = AdHoc;
|
||||||
|
};
|
||||||
|
559B7BA33037017D0031C511 /* AdHoc */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = DA16D49AA46D4F6838340B55 /* Pods-QuickLocation.debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = QuickLocation/QuickLocation.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = LRDLWZ2Y83;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = "$(inherited)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = QuickLocation/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "极速定位";
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "您的相机将被用于扫描二维码、拍摄照片和视频。";
|
||||||
|
INFOPLIST_KEY_NSContactsUsageDescription = "需要访问通讯录以快速查找和邀请好友加入您的圈子";
|
||||||
|
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "极速定位需要在后台持续获取您的位置,以便在您锁屏或使用其他App时,家人仍可看到您的实时位置以保障安全";
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息以在地图上显示您的位置";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风发送语音消息。";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。";
|
||||||
|
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "我们需要语音识别权限,以便将语音转换成文字。";
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UIBackgroundModes = location;
|
||||||
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
|
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = jsdw_adhoc;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "QuickLocation/QuickLocation-Bridging-Header.h";
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
};
|
||||||
|
name = AdHoc;
|
||||||
|
};
|
||||||
|
559B7BA43037017D0031C511 /* AdHoc */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = DeviceActivityMonitorExtension/DeviceActivityMonitorExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = LRDLWZ2Y83;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = DeviceActivityMonitorExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.DeviceActivityMonitor;
|
||||||
|
PRODUCT_NAME = DeviceActivityMonitorExtension;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = AdHoc;
|
||||||
|
};
|
||||||
|
559B7BA53037017D0031C511 /* AdHoc */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = ShieldConfigurationExtension/ShieldConfigurationExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = LRDLWZ2Y83;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = ShieldConfigurationExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cn.zuomeng.jisuloca.ShieldConfiguration;
|
||||||
|
PRODUCT_NAME = ShieldConfigurationExtension;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = AdHoc;
|
||||||
|
};
|
||||||
A0AD067561B0A604B5DF5049 /* Debug */ = {
|
A0AD067561B0A604B5DF5049 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
|
@ -3044,6 +3222,7 @@
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
15B6023456261CCD0E3A673E /* Release */,
|
15B6023456261CCD0E3A673E /* Release */,
|
||||||
|
559B7BA43037017D0031C511 /* AdHoc */,
|
||||||
3A68FBB9A55555685B490D5C /* Debug */,
|
3A68FBB9A55555685B490D5C /* Debug */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
|
|
@ -3054,6 +3233,7 @@
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
3E43591E2FC48D2B003470A5 /* Debug */,
|
3E43591E2FC48D2B003470A5 /* Debug */,
|
||||||
3E43591F2FC48D2B003470A5 /* Release */,
|
3E43591F2FC48D2B003470A5 /* Release */,
|
||||||
|
559B7BA23037017D0031C511 /* AdHoc */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
|
|
@ -3063,6 +3243,7 @@
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
3E43591C2FC48D2B003470A5 /* Debug */,
|
3E43591C2FC48D2B003470A5 /* Debug */,
|
||||||
3E43591D2FC48D2B003470A5 /* Release */,
|
3E43591D2FC48D2B003470A5 /* Release */,
|
||||||
|
559B7BA33037017D0031C511 /* AdHoc */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
|
|
@ -3071,6 +3252,7 @@
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
B66BC4F86D908B8200A8DC8E /* Release */,
|
B66BC4F86D908B8200A8DC8E /* Release */,
|
||||||
|
559B7BA53037017D0031C511 /* AdHoc */,
|
||||||
A0AD067561B0A604B5DF5049 /* Debug */,
|
A0AD067561B0A604B5DF5049 /* Debug */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 34 KiB |
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "edit@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "edit@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"template-rendering-intent" : "original"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "circle_days@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "circle_days@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 605 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 28 KiB |
|
|
@ -39,7 +39,7 @@ extension DefaultsKeys {
|
||||||
var unlockLastLongitude: DefaultsKey<Double?> { .init("unlockLastLongitude") }
|
var unlockLastLongitude: DefaultsKey<Double?> { .init("unlockLastLongitude") }
|
||||||
/// 手机报告:按自然日累计屏幕时长(秒),key = yyyy-MM-dd
|
/// 手机报告:按自然日累计屏幕时长(秒),key = yyyy-MM-dd
|
||||||
var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) }
|
var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) }
|
||||||
/// 手机报告:进行中的解锁 session 起点(持久化,杀进程可续)
|
/// 手机报告:当前进程屏幕时长 session 起点(仅用于杀进程后结算到最后心跳,不跨进程续计)
|
||||||
var screenSessionStartAt: DefaultsKey<Date?> { .init("screenSessionStartAt") }
|
var screenSessionStartAt: DefaultsKey<Date?> { .init("screenSessionStartAt") }
|
||||||
/// 手机报告:本 App 今日进入前台次数
|
/// 手机报告:本 App 今日进入前台次数
|
||||||
var appUsageToday: DefaultsKey<Int> { .init("appUsageToday", defaultValue: 0) }
|
var appUsageToday: DefaultsKey<Int> { .init("appUsageToday", defaultValue: 0) }
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ final class MQTTService: NSObject {
|
||||||
/// - callback: 可选,该 topic 的专用回调,收到消息时优先于此回调
|
/// - callback: 可选,该 topic 的专用回调,收到消息时优先于此回调
|
||||||
func subscribe(topic: String, qos: CocoaMQTTQoS = .qos1, callback: ((CocoaMQTT5Message) -> Void)? = nil) {
|
func subscribe(topic: String, qos: CocoaMQTTQoS = .qos1, callback: ((CocoaMQTT5Message) -> Void)? = nil) {
|
||||||
let subscription = MqttSubscription(topic: topic, qos: qos)
|
let subscription = MqttSubscription(topic: topic, qos: qos)
|
||||||
subscription.noLocal = true
|
subscription.noLocal = false
|
||||||
subscription.retainAsPublished = true
|
subscription.retainAsPublished = true
|
||||||
subscription.retainHandling = .none
|
subscription.retainHandling = .none
|
||||||
mqtt?.subscribe([subscription])
|
mqtt?.subscribe([subscription])
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ extension DefaultsKeys {
|
||||||
openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002
|
openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002
|
||||||
openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010
|
openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010
|
||||||
#elseif AdHoc
|
#elseif AdHoc
|
||||||
setupApiEnv(-1)
|
setupApiEnv(1)
|
||||||
openIM_API = "https://imapi.zuom8.cn"
|
openIM_API = "https://imapi.zuom8.cn"
|
||||||
openIM_WS = "ws://imws.zuom8.cn"
|
openIM_WS = "ws://imws.zuom8.cn"
|
||||||
#else
|
#else
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,10 @@ final class UnlockCountManager: NSObject {
|
||||||
private var lastGeofenceCenter: CLLocationCoordinate2D?
|
private var lastGeofenceCenter: CLLocationCoordinate2D?
|
||||||
private var lastKnownLocation: CLLocation?
|
private var lastKnownLocation: CLLocation?
|
||||||
private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
||||||
/// 当前解锁 session 起点(锁屏结算屏幕时长)
|
/// 当前解锁 session 起点(锁屏结算屏幕时长,仅当前进程)
|
||||||
private var screenSessionStart: Date?
|
private var screenSessionStart: Date?
|
||||||
|
/// 本次进程里用户是否真正打开过 App;杀进程后被定位拉起不算
|
||||||
|
private var hasUserActivatedApp = false
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
@ -81,7 +83,7 @@ final class UnlockCountManager: NSObject {
|
||||||
rollDayIfNeeded()
|
rollDayIfNeeded()
|
||||||
let today = todayString()
|
let today = todayString()
|
||||||
var total = Defaults[\.screenTimeByDay][today] ?? 0
|
var total = Defaults[\.screenTimeByDay][today] ?? 0
|
||||||
if let start = effectiveScreenSessionStart {
|
if let start = screenSessionStart {
|
||||||
total += screenTimeSeconds(from: start, to: Date(), onDay: today)
|
total += screenTimeSeconds(from: start, to: Date(), onDay: today)
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
|
|
@ -113,8 +115,8 @@ final class UnlockCountManager: NSObject {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
didStart = true
|
didStart = true
|
||||||
|
closeOrphanScreenSessionIfNeeded()
|
||||||
rollDayIfNeeded()
|
rollDayIfNeeded()
|
||||||
restoreScreenSessionIfNeeded()
|
|
||||||
|
|
||||||
let available = UIApplication.shared.isProtectedDataAvailable
|
let available = UIApplication.shared.isProtectedDataAvailable
|
||||||
isSessionLocked = !available
|
isSessionLocked = !available
|
||||||
|
|
@ -160,9 +162,6 @@ final class UnlockCountManager: NSObject {
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||||||
handleNonActiveLaunchIfNeeded(launchOptions)
|
handleNonActiveLaunchIfNeeded(launchOptions)
|
||||||
reconcileUnlockIfNeeded(reason: "coldStartAfterLock")
|
reconcileUnlockIfNeeded(reason: "coldStartAfterLock")
|
||||||
if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked {
|
|
||||||
startScreenSession()
|
|
||||||
}
|
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,10 +204,6 @@ final class UnlockCountManager: NSObject {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private var effectiveScreenSessionStart: Date? {
|
|
||||||
screenSessionStart ?? Defaults[\.screenSessionStartAt]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dayString(for date: Date) -> String {
|
private func dayString(for date: Date) -> String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.calendar = Calendar.current
|
formatter.calendar = Calendar.current
|
||||||
|
|
@ -275,28 +270,32 @@ final class UnlockCountManager: NSObject {
|
||||||
return formatter.date(from: key)
|
return formatter.date(from: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreScreenSessionIfNeeded() {
|
/// 上一进程留下的 session 只结算到最后一次存活心跳,死掉的空档不再计入
|
||||||
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
private func closeOrphanScreenSessionIfNeeded() {
|
||||||
guard screenSessionStart == nil, let persisted = Defaults[\.screenSessionStartAt] else { return }
|
guard screenSessionStart == nil else { return }
|
||||||
screenSessionStart = persisted
|
guard let start = Defaults[\.screenSessionStartAt] else { return }
|
||||||
|
let heartbeat = Defaults[\.unlockLastHeartbeatAt]
|
||||||
|
if let heartbeat, heartbeat > start {
|
||||||
|
addScreenTime(from: start, to: heartbeat)
|
||||||
|
}
|
||||||
|
Defaults[\.screenSessionStartAt] = nil
|
||||||
|
debugLog("closeOrphanScreenSession start=\(start) end=\(String(describing: heartbeat))")
|
||||||
|
notifyChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startScreenSession() {
|
private func startScreenSession() {
|
||||||
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
guard UIApplication.shared.isProtectedDataAvailable else { return }
|
||||||
|
guard hasUserActivatedApp || UIApplication.shared.applicationState == .active else { return }
|
||||||
rollDayIfNeeded()
|
rollDayIfNeeded()
|
||||||
if screenSessionStart == nil {
|
guard screenSessionStart == nil else { return }
|
||||||
if let persisted = Defaults[\.screenSessionStartAt] {
|
screenSessionStart = Date()
|
||||||
screenSessionStart = persisted
|
Defaults[\.screenSessionStartAt] = screenSessionStart
|
||||||
} else {
|
Defaults[\.unlockLastHeartbeatAt] = screenSessionStart
|
||||||
screenSessionStart = Date()
|
|
||||||
Defaults[\.screenSessionStartAt] = screenSessionStart
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func flushScreenSession() {
|
private func flushScreenSession(end: Date = Date()) {
|
||||||
guard let start = effectiveScreenSessionStart else { return }
|
guard let start = screenSessionStart else { return }
|
||||||
addScreenTime(from: start, to: Date())
|
addScreenTime(from: start, to: end)
|
||||||
screenSessionStart = nil
|
screenSessionStart = nil
|
||||||
Defaults[\.screenSessionStartAt] = nil
|
Defaults[\.screenSessionStartAt] = nil
|
||||||
notifyChange()
|
notifyChange()
|
||||||
|
|
@ -478,6 +477,7 @@ final class UnlockCountManager: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func appWillTerminate() {
|
@objc private func appWillTerminate() {
|
||||||
|
flushScreenSession()
|
||||||
persistLifecycleHeartbeat()
|
persistLifecycleHeartbeat()
|
||||||
forceRefreshGeofenceFromLastKnown(reason: "willTerminate")
|
forceRefreshGeofenceFromLastKnown(reason: "willTerminate")
|
||||||
debugLog("willTerminate protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked])")
|
debugLog("willTerminate protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked])")
|
||||||
|
|
@ -485,8 +485,12 @@ final class UnlockCountManager: NSObject {
|
||||||
|
|
||||||
@objc private func appDidBecomeActive() {
|
@objc private func appDidBecomeActive() {
|
||||||
debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)")
|
||||||
|
hasUserActivatedApp = true
|
||||||
endBootBackgroundTask()
|
endBootBackgroundTask()
|
||||||
reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock")
|
reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock")
|
||||||
|
if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked {
|
||||||
|
startScreenSession()
|
||||||
|
}
|
||||||
recordAppUsage()
|
recordAppUsage()
|
||||||
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
restartLocationMonitoringIfPossible(reassertContinuous: true)
|
||||||
Defaults[\.unlockLastHeartbeatAt] = Date()
|
Defaults[\.unlockLastHeartbeatAt] = Date()
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ class CreateGroupViewModel {
|
||||||
|
|
||||||
// MARK: - 加载数据
|
// MARK: - 加载数据
|
||||||
func loadData() {
|
func loadData() {
|
||||||
sectionedItems.onNext((tagList + [Self.addTagToken]).mapSection())
|
sectionedItems.onNext(tagList.mapSection())
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Request
|
// MARK: - Request
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import RxDataSources
|
||||||
import OpenIMSDK
|
import OpenIMSDK
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
|
import Speech
|
||||||
import HXPHPicker
|
import HXPHPicker
|
||||||
import IQKeyboardManagerSwift
|
import IQKeyboardManagerSwift
|
||||||
import MJRefresh
|
import MJRefresh
|
||||||
|
|
@ -35,6 +36,7 @@ final class GroupChatVC: BaseViewController {
|
||||||
private var cameraPicker: ImagePicker?
|
private var cameraPicker: ImagePicker?
|
||||||
/// 加载更早历史前记录,用于保持阅读位置
|
/// 加载更早历史前记录,用于保持阅读位置
|
||||||
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
|
private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)?
|
||||||
|
private var didSetupKeyboard = false
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
init(groupId: String) {
|
init(groupId: String) {
|
||||||
|
|
@ -93,6 +95,11 @@ final class GroupChatVC: BaseViewController {
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
VoicePlayerManager.shared.stop()
|
VoicePlayerManager.shared.stop()
|
||||||
|
if isVoiceRecording {
|
||||||
|
finishVoiceRecording(action: .discard)
|
||||||
|
} else if !rootView.voiceRecordView.isHidden {
|
||||||
|
rootView.voiceRecordView.reset()
|
||||||
|
}
|
||||||
IQKeyboardManager.shared.isEnabled = true
|
IQKeyboardManager.shared.isEnabled = true
|
||||||
IQKeyboardManager.shared.resignOnTouchOutside = true
|
IQKeyboardManager.shared.resignOnTouchOutside = true
|
||||||
}
|
}
|
||||||
|
|
@ -104,18 +111,19 @@ final class GroupChatVC: BaseViewController {
|
||||||
|
|
||||||
// MARK: - Keyboard
|
// MARK: - Keyboard
|
||||||
private func setupKeyboard() {
|
private func setupKeyboard() {
|
||||||
// 键盘升起
|
guard !didSetupKeyboard else { return }
|
||||||
|
didSetupKeyboard = true
|
||||||
NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
|
NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
|
||||||
.subscribe(onNext: { [weak self] noti in
|
.subscribe(onNext: { [weak self] noti in
|
||||||
guard let self = self,
|
guard let self = self,
|
||||||
// 只在 VC 本身可见时响应,避免导航栈下层 VC 被全局通知唤醒
|
|
||||||
self.isViewLoaded && self.view.window != nil,
|
self.isViewLoaded && self.view.window != nil,
|
||||||
let userInfo = noti.userInfo,
|
let userInfo = noti.userInfo,
|
||||||
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
|
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
|
||||||
else { return }
|
else { return }
|
||||||
|
// 语音转文字预览自己抬界面,不要再顶聊天输入栏
|
||||||
|
guard self.rootView.voiceRecordView.isHidden else { return }
|
||||||
let height = frame.height
|
let height = frame.height
|
||||||
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
|
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
|
||||||
// 收起表情/语音面板
|
|
||||||
self.rootView.dismissAllPanels(excludeTextField: true)
|
self.rootView.dismissAllPanels(excludeTextField: true)
|
||||||
UIView.animate(withDuration: duration) {
|
UIView.animate(withDuration: duration) {
|
||||||
self.rootView.bottomBar.layoutChain.bottom(height + 20)
|
self.rootView.bottomBar.layoutChain.bottom(height + 20)
|
||||||
|
|
@ -123,6 +131,21 @@ final class GroupChatVC: BaseViewController {
|
||||||
self.scrollToBottom()
|
self.scrollToBottom()
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
|
NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification)
|
||||||
|
.subscribe(onNext: { [weak self] noti in
|
||||||
|
guard let self = self,
|
||||||
|
self.isViewLoaded && self.view.window != nil
|
||||||
|
else { return }
|
||||||
|
guard self.rootView.voiceRecordView.isHidden else { return }
|
||||||
|
guard self.rootView.emojiPanelView.isHidden else { return }
|
||||||
|
let duration = (noti.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
|
||||||
|
UIView.animate(withDuration: duration) {
|
||||||
|
self.rootView.bottomBar.layoutChain.bottom(kSafeBottomMargin)
|
||||||
|
self.rootView.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupPanelDismiss() {
|
private func setupPanelDismiss() {
|
||||||
|
|
@ -130,16 +153,18 @@ final class GroupChatVC: BaseViewController {
|
||||||
rootView.tableView.panGestureRecognizer.rx.event
|
rootView.tableView.panGestureRecognizer.rx.event
|
||||||
.filter { $0.state == .began }
|
.filter { $0.state == .began }
|
||||||
.subscribe(onNext: { [weak self] _ in
|
.subscribe(onNext: { [weak self] _ in
|
||||||
self?.rootView.dismissAllPanels()
|
guard let self = self, self.rootView.voiceRecordView.isHidden else { return }
|
||||||
self?.bubbleMenu?.dismiss()
|
self.rootView.dismissAllPanels()
|
||||||
|
self.bubbleMenu?.dismiss()
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
// 点击 cell 收起面板
|
// 点击 cell 收起面板
|
||||||
rootView.tableView.rx.itemSelected
|
rootView.tableView.rx.itemSelected
|
||||||
.subscribe(onNext: { [weak self] _ in
|
.subscribe(onNext: { [weak self] _ in
|
||||||
self?.rootView.dismissAllPanels()
|
guard let self = self, self.rootView.voiceRecordView.isHidden else { return }
|
||||||
self?.bubbleMenu?.dismiss()
|
self.rootView.dismissAllPanels()
|
||||||
|
self.bubbleMenu?.dismiss()
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
@ -201,6 +226,7 @@ final class GroupChatVC: BaseViewController {
|
||||||
rootView.bringSubviewToFront(rootView.quoteBar)
|
rootView.bringSubviewToFront(rootView.quoteBar)
|
||||||
rootView.bringSubviewToFront(rootView.bottomBar)
|
rootView.bringSubviewToFront(rootView.bottomBar)
|
||||||
rootView.bringSubviewToFront(rootView.disableIMView)
|
rootView.bringSubviewToFront(rootView.disableIMView)
|
||||||
|
rootView.bringSubviewToFront(rootView.voiceRecordView)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 顶部加载更早历史
|
// MARK: - 顶部加载更早历史
|
||||||
|
|
@ -375,7 +401,6 @@ final class GroupChatVC: BaseViewController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.viewModel.setPendingQuote(oim)
|
self.viewModel.setPendingQuote(oim)
|
||||||
self.rootView.textField.becomeFirstResponder()
|
|
||||||
}
|
}
|
||||||
menu.onDismiss = { [weak self] in self?.bubbleMenu = nil }
|
menu.onDismiss = { [weak self] in self?.bubbleMenu = nil }
|
||||||
let anchor = menuAnchorView(in: cell)
|
let anchor = menuAnchorView(in: cell)
|
||||||
|
|
@ -389,6 +414,7 @@ final class GroupChatVC: BaseViewController {
|
||||||
rootView.mentionTableView.dataSource = self
|
rootView.mentionTableView.dataSource = self
|
||||||
rootView.textField.rx.controlEvent(.editingChanged)
|
rootView.textField.rx.controlEvent(.editingChanged)
|
||||||
.subscribe(onNext: { [weak self] in
|
.subscribe(onNext: { [weak self] in
|
||||||
|
self?.rootView.refreshComposer()
|
||||||
self?.handleMentionTrigger()
|
self?.handleMentionTrigger()
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
@ -446,6 +472,7 @@ final class GroupChatVC: BaseViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rootView.textField.text = current + insert
|
rootView.textField.text = current + insert
|
||||||
|
rootView.refreshComposer()
|
||||||
rootView.textField.becomeFirstResponder()
|
rootView.textField.becomeFirstResponder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -467,37 +494,16 @@ final class GroupChatVC: BaseViewController {
|
||||||
AppRouter.push(Route.groupMemberList, userInfo: ["groupKey": self.viewModel.groupId])
|
AppRouter.push(Route.groupMemberList, userInfo: ["groupKey": self.viewModel.groupId])
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
// 语音按钮
|
// 语音按钮:切换到按住说话
|
||||||
rootView.voiceBtn.rx.tap.subscribe(onNext: { [weak self] _ in
|
rootView.voiceBtn.rx.tap.subscribe(onNext: { [weak self] _ in
|
||||||
guard let self = self else { return }
|
self?.enterVoiceInputMode()
|
||||||
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
||||||
switch status {
|
|
||||||
case .authorized:
|
|
||||||
break
|
|
||||||
case .notDetermined:
|
|
||||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
||||||
guard granted else { return }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.rootView.dismissAllPanels()
|
|
||||||
self.showSpeakPanel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
Permission.openAppSetting(title: "请开启麦克风权限",
|
|
||||||
message: "请在iPhone的“设置-隐私-麦克风”选项中允许\(kAppName)访问你的麦克风。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.rootView.dismissAllPanels()
|
|
||||||
self.showSpeakPanel()
|
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
// 键盘按钮
|
rootView.keyboardBtn.rx.tap
|
||||||
rootView.voiceRecordView.keyboardBtn.rx.tap
|
|
||||||
.subscribe(onNext: { [weak self] _ in
|
.subscribe(onNext: { [weak self] _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.rootView.dismissAllPanels()
|
self.rootView.setVoiceInputMode(false)
|
||||||
self.rootView.textField.becomeFirstResponder()
|
self.rootView.textField.becomeFirstResponder()
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
@ -510,14 +516,6 @@ final class GroupChatVC: BaseViewController {
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
rootView.voiceRecordView.emojiBtn.rx.tap
|
|
||||||
.subscribe(onNext: { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.rootView.dismissAllPanels()
|
|
||||||
self.showEmojiPanel()
|
|
||||||
})
|
|
||||||
.disposed(by: disposeBag)
|
|
||||||
|
|
||||||
// 表情面板点击
|
// 表情面板点击
|
||||||
rootView.emojiCollectionView.rx.modelSelected(String.self)
|
rootView.emojiCollectionView.rx.modelSelected(String.self)
|
||||||
.subscribe(onNext: { [weak self] name in
|
.subscribe(onNext: { [weak self] name in
|
||||||
|
|
@ -526,16 +524,13 @@ final class GroupChatVC: BaseViewController {
|
||||||
})
|
})
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
|
|
||||||
Observable.merge(
|
rootView.galleryBtn.rx.tap
|
||||||
rootView.galleryBtn.rx.tap.asObservable(),
|
.subscribe(onNext: { [weak self] in
|
||||||
rootView.voiceRecordView.addBtn.rx.tap.asObservable()
|
guard let self = self else { return }
|
||||||
)
|
self.rootView.dismissAllPanels()
|
||||||
.subscribe(onNext: { [weak self] in
|
self.showAlbum()
|
||||||
guard let self = self else { return }
|
})
|
||||||
self.rootView.dismissAllPanels()
|
.disposed(by: disposeBag)
|
||||||
self.showAlbum()
|
|
||||||
})
|
|
||||||
.disposed(by: disposeBag)
|
|
||||||
|
|
||||||
rootView.cameraBtn.rx.tap
|
rootView.cameraBtn.rx.tap
|
||||||
.subscribe(onNext: { [weak self] in
|
.subscribe(onNext: { [weak self] in
|
||||||
|
|
@ -571,18 +566,6 @@ final class GroupChatVC: BaseViewController {
|
||||||
.disposed(by: disposeBag)
|
.disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 显示语音面板
|
|
||||||
private func showSpeakPanel() {
|
|
||||||
let show = rootView.voiceRecordView.isHidden
|
|
||||||
rootView.voiceRecordView.isHidden = !show
|
|
||||||
let offset: CGFloat = show ? 252 : 0
|
|
||||||
UIView.animate(withDuration: 0.25) {
|
|
||||||
self.rootView.bottomBar.layoutChain.bottom(show ? offset - self.rootView.bottomBar.dl.height : kSafeBottomMargin)
|
|
||||||
self.rootView.voiceRecordView.layoutChain.bottom(offset - 252)
|
|
||||||
}
|
|
||||||
scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 显示表情面板
|
// MARK: - 显示表情面板
|
||||||
private func showEmojiPanel() {
|
private func showEmojiPanel() {
|
||||||
let show = self.rootView.emojiPanelView.isHidden
|
let show = self.rootView.emojiPanelView.isHidden
|
||||||
|
|
@ -660,89 +643,192 @@ final class GroupChatVC: BaseViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Voice Recording
|
// MARK: - Voice Recording
|
||||||
private var audioRecorder: AVAudioRecorder?
|
private var isVoiceRecording = false
|
||||||
|
private let voiceCapture = ChatVoiceCapture()
|
||||||
private var recordFileURL: URL?
|
private var recordFileURL: URL?
|
||||||
private var recordTimer: Timer?
|
private var recordTimer: Timer?
|
||||||
private var recordDuration: Int = 0
|
private var recordDuration: Int = 0
|
||||||
|
private var currentVoiceHover: VoiceRecordView.HoverTarget = .none
|
||||||
|
|
||||||
|
private func enterVoiceInputMode() {
|
||||||
|
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
rootView.dismissAllPanels()
|
||||||
|
rootView.setVoiceInputMode(true)
|
||||||
|
requestSpeechAuthorizationIfNeeded()
|
||||||
|
case .notDetermined:
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self, granted else { return }
|
||||||
|
self.rootView.dismissAllPanels()
|
||||||
|
self.rootView.setVoiceInputMode(true)
|
||||||
|
self.requestSpeechAuthorizationIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
Permission.openAppSetting(title: "请开启麦克风权限",
|
||||||
|
message: "请在iPhone的“设置-隐私-麦克风”选项中允许\(kAppName)访问你的麦克风。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func setupVoiceRecording() {
|
private func setupVoiceRecording() {
|
||||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceLongPress(_:)))
|
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceLongPress(_:)))
|
||||||
longPress.minimumPressDuration = 0.2
|
longPress.minimumPressDuration = 0.18
|
||||||
rootView.voiceRecordView.speakBtn.addGestureRecognizer(longPress)
|
longPress.allowableMovement = .greatestFiniteMagnitude
|
||||||
|
rootView.holdSpeakBtn.addGestureRecognizer(longPress)
|
||||||
|
|
||||||
|
rootView.voiceRecordView.onPreviewCancel = { [weak self] in
|
||||||
|
self?.discardPreview()
|
||||||
|
}
|
||||||
|
rootView.voiceRecordView.onPreviewSend = { [weak self] text in
|
||||||
|
self?.rootView.voiceRecordView.reset()
|
||||||
|
self?.rootView.restoreComposerPosition()
|
||||||
|
self?.viewModel.input.sendMessage.onNext(text)
|
||||||
|
}
|
||||||
|
voiceCapture.onLevel = { [weak self] level in
|
||||||
|
self?.rootView.voiceRecordView.pushLevel(level)
|
||||||
|
}
|
||||||
|
voiceCapture.onPartialText = { [weak self] text in
|
||||||
|
self?.rootView.voiceRecordView.updateRecognizedText(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleVoiceLongPress(_ gesture: UILongPressGestureRecognizer) {
|
@objc private func handleVoiceLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||||
let location = gesture.location(in: rootView.voiceRecordView)
|
let point = gesture.location(in: rootView.voiceRecordView)
|
||||||
let btnCenterX = rootView.voiceRecordView.speakBtn.center.x
|
|
||||||
let isCancel = location.x < btnCenterX - 100
|
|
||||||
|
|
||||||
switch gesture.state {
|
switch gesture.state {
|
||||||
case .began:
|
case .began:
|
||||||
AudioServicesPlaySystemSound(1519)
|
startVoiceRecording()
|
||||||
rootView.voiceRecordView.state = .recording
|
|
||||||
startRecording()
|
|
||||||
case .changed:
|
case .changed:
|
||||||
let wasRecording = rootView.voiceRecordView.state == .recording
|
guard isVoiceRecording else { return }
|
||||||
rootView.voiceRecordView.state = isCancel ? .canceling : .recording
|
let target = rootView.voiceRecordView.hoverTarget(at: point)
|
||||||
rootView.voiceRecordView.cancelBtn.isSelected = isCancel
|
if target != currentVoiceHover {
|
||||||
if isCancel && wasRecording {
|
currentVoiceHover = target
|
||||||
AudioServicesPlaySystemSound(1519)
|
AudioServicesPlaySystemSound(1519)
|
||||||
}
|
}
|
||||||
|
rootView.voiceRecordView.setHover(target)
|
||||||
case .ended:
|
case .ended:
|
||||||
stopRecording(cancel: isCancel)
|
guard isVoiceRecording else { return }
|
||||||
|
finishVoiceRecording(action: finishAction(for: currentVoiceHover))
|
||||||
|
case .cancelled, .failed:
|
||||||
|
if isVoiceRecording {
|
||||||
|
finishVoiceRecording(action: .discard)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startRecording() {
|
private enum VoiceFinishAction {
|
||||||
let session = AVAudioSession.sharedInstance()
|
case discard
|
||||||
try? session.setCategory(.playAndRecord, mode: .default)
|
case sendVoice
|
||||||
try? session.setActive(true)
|
case convert
|
||||||
|
}
|
||||||
|
|
||||||
let dir = NSTemporaryDirectory()
|
private func finishAction(for hover: VoiceRecordView.HoverTarget) -> VoiceFinishAction {
|
||||||
let filename = "voice_\(Int(Date().timeIntervalSince1970)).wav"
|
switch hover {
|
||||||
recordFileURL = URL(fileURLWithPath: dir + filename)
|
case .cancel: return .discard
|
||||||
|
case .convert: return .convert
|
||||||
let settings: [String: Any] = [
|
case .none: return .sendVoice
|
||||||
AVFormatIDKey: kAudioFormatLinearPCM,
|
|
||||||
AVSampleRateKey: 8000,
|
|
||||||
AVNumberOfChannelsKey: 1,
|
|
||||||
AVLinearPCMBitDepthKey: 16,
|
|
||||||
AVLinearPCMIsFloatKey: false
|
|
||||||
]
|
|
||||||
|
|
||||||
guard let url = recordFileURL else { return }
|
|
||||||
audioRecorder = try? AVAudioRecorder(url: url, settings: settings)
|
|
||||||
audioRecorder?.record()
|
|
||||||
|
|
||||||
recordDuration = 0
|
|
||||||
recordTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
||||||
self?.recordDuration += 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopRecording(cancel: Bool) {
|
private func startVoiceRecording() {
|
||||||
audioRecorder?.stop()
|
guard !isVoiceRecording else { return }
|
||||||
|
AudioServicesPlaySystemSound(1519)
|
||||||
|
currentVoiceHover = .none
|
||||||
|
bringInputOverlaysToFront()
|
||||||
|
|
||||||
|
let url = URL(fileURLWithPath: NSTemporaryDirectory() + "voice_\(Int(Date().timeIntervalSince1970)).caf")
|
||||||
|
recordFileURL = url
|
||||||
|
|
||||||
|
let startCapture: (Bool) -> Void = { [weak self] enableSpeech in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
try self.voiceCapture.start(fileURL: url, enableSpeech: enableSpeech)
|
||||||
|
self.isVoiceRecording = true
|
||||||
|
self.recordDuration = 0
|
||||||
|
self.rootView.voiceRecordView.beginRecording()
|
||||||
|
self.recordTimer?.invalidate()
|
||||||
|
self.recordTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
|
||||||
|
self?.tickVoiceRecording()
|
||||||
|
}
|
||||||
|
RunLoop.main.add(self.recordTimer!, forMode: .common)
|
||||||
|
} catch {
|
||||||
|
self.dl.show(text: "无法开始录音")
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startCapture(SFSpeechRecognizer.authorizationStatus() == .authorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestSpeechAuthorizationIfNeeded() {
|
||||||
|
guard SFSpeechRecognizer.authorizationStatus() == .notDetermined else { return }
|
||||||
|
SFSpeechRecognizer.requestAuthorization { _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tickVoiceRecording() {
|
||||||
|
guard isVoiceRecording else { return }
|
||||||
|
recordDuration = voiceCapture.duration
|
||||||
|
rootView.voiceRecordView.updateDuration(recordDuration)
|
||||||
|
if recordDuration >= 60 {
|
||||||
|
finishVoiceRecording(action: finishAction(for: currentVoiceHover))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishVoiceRecording(action: VoiceFinishAction) {
|
||||||
|
guard isVoiceRecording else {
|
||||||
|
if action == .discard {
|
||||||
|
rootView.voiceRecordView.reset()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isVoiceRecording = false
|
||||||
recordTimer?.invalidate()
|
recordTimer?.invalidate()
|
||||||
recordTimer = nil
|
recordTimer = nil
|
||||||
rootView.voiceRecordView.stopRotating()
|
let duration = max(recordDuration, voiceCapture.duration)
|
||||||
|
voiceCapture.stop(finishRecognition: action == .convert)
|
||||||
try? AVAudioSession.sharedInstance().setActive(false)
|
let text = voiceCapture.latestText
|
||||||
|
let url = recordFileURL
|
||||||
|
|
||||||
if cancel {
|
switch action {
|
||||||
self.rootView.voiceRecordView.cancelBtn.isSelected = false
|
case .discard:
|
||||||
if let url = recordFileURL { try? FileManager.default.removeItem(at: url) }
|
deleteRecordFile()
|
||||||
return
|
rootView.voiceRecordView.reset()
|
||||||
|
case .sendVoice:
|
||||||
|
rootView.voiceRecordView.reset()
|
||||||
|
guard let url, duration >= 1 else {
|
||||||
|
dl.show(text: "说话时间太短")
|
||||||
|
deleteRecordFile()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendVoiceMessage(url: url, durationMs: duration * 1000)
|
||||||
|
case .convert:
|
||||||
|
deleteRecordFile()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
|
||||||
|
guard let self, !self.isVoiceRecording, !self.rootView.voiceRecordView.isHidden else { return }
|
||||||
|
let latest = self.voiceCapture.latestText.isEmpty ? text : self.voiceCapture.latestText
|
||||||
|
self.rootView.voiceRecordView.showPreview(text: latest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard let url = recordFileURL, recordDuration >= 1 else {
|
private func discardPreview() {
|
||||||
self.dl.show(text: "说话时间太短")
|
deleteRecordFile()
|
||||||
if let url = recordFileURL { try? FileManager.default.removeItem(at: url) }
|
rootView.voiceRecordView.reset()
|
||||||
return
|
rootView.restoreComposerPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteRecordFile() {
|
||||||
|
if let url = recordFileURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
}
|
}
|
||||||
|
recordFileURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
// Send voice message via OpenIM
|
private func sendVoiceMessage(url: URL, durationMs: Int) {
|
||||||
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000)
|
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: durationMs)
|
||||||
viewModel.cacheMessage(msg)
|
viewModel.cacheMessage(msg)
|
||||||
OIMManager.manager.sendMessage(msg,
|
OIMManager.manager.sendMessage(msg,
|
||||||
recvID: "",
|
recvID: "",
|
||||||
|
|
|
||||||
|
|
@ -125,21 +125,59 @@ class GroupChatView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收起所有浮层面板
|
/// 收起所有浮层面板
|
||||||
|
private var isVoiceInputMode = false
|
||||||
|
|
||||||
|
func setVoiceInputMode(_ enabled: Bool) {
|
||||||
|
isVoiceInputMode = enabled
|
||||||
|
if enabled {
|
||||||
|
textField.resignFirstResponder()
|
||||||
|
emojiPanelView.isHidden = true
|
||||||
|
}
|
||||||
|
refreshComposer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshComposer() {
|
||||||
|
let hasText = !(textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
if isVoiceInputMode {
|
||||||
|
textField.isHidden = true
|
||||||
|
emojiBtn.isHidden = true
|
||||||
|
voiceBtn.isHidden = true
|
||||||
|
sendBtn.isHidden = true
|
||||||
|
holdSpeakBtn.isHidden = false
|
||||||
|
keyboardBtn.isHidden = false
|
||||||
|
} else {
|
||||||
|
textField.isHidden = false
|
||||||
|
emojiBtn.isHidden = false
|
||||||
|
holdSpeakBtn.isHidden = true
|
||||||
|
keyboardBtn.isHidden = true
|
||||||
|
sendBtn.isHidden = !hasText
|
||||||
|
voiceBtn.isHidden = hasText
|
||||||
|
}
|
||||||
|
layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
func dismissAllPanels(excludeTextField: Bool = false) {
|
func dismissAllPanels(excludeTextField: Bool = false) {
|
||||||
let needsReset = !emojiPanelView.isHidden
|
let needsReset = !emojiPanelView.isHidden
|
||||||
|| !voiceRecordView.isHidden
|
|
||||||
|| textField.isFirstResponder
|
|| textField.isFirstResponder
|
||||||
|| !mentionPickerView.isHidden
|
|| !mentionPickerView.isHidden
|
||||||
|
|
||||||
guard needsReset else { return }
|
guard needsReset else { return }
|
||||||
stopVisibleEmojiAnimations()
|
stopVisibleEmojiAnimations()
|
||||||
emojiPanelView.isHidden = true
|
emojiPanelView.isHidden = true
|
||||||
voiceRecordView.isHidden = true
|
|
||||||
mentionPickerView.isHidden = true
|
mentionPickerView.isHidden = true
|
||||||
if !excludeTextField { textField.resignFirstResponder() }
|
if !excludeTextField { textField.resignFirstResponder() }
|
||||||
UIView.animate(withDuration: 0.25) {
|
restoreComposerPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreComposerPosition(animated: Bool = true) {
|
||||||
|
let update = {
|
||||||
self.bottomBar.layoutChain.bottom(kSafeBottomMargin)
|
self.bottomBar.layoutChain.bottom(kSafeBottomMargin)
|
||||||
self.voiceRecordView.layoutChain.bottom(-252)
|
self.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
if animated {
|
||||||
|
UIView.animate(withDuration: 0.25, animations: update)
|
||||||
|
} else {
|
||||||
|
update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,10 +246,12 @@ class GroupChatView: UIView {
|
||||||
addSubview(emojiPanelView)
|
addSubview(emojiPanelView)
|
||||||
emojiPanelView.addSubview(emojiCollectionView)
|
emojiPanelView.addSubview(emojiCollectionView)
|
||||||
emojiPanelView.addSubview(emojiPageControl)
|
emojiPanelView.addSubview(emojiPageControl)
|
||||||
bottomBar.addSubview(voiceBtn)
|
|
||||||
bottomBar.addSubview(textField)
|
bottomBar.addSubview(textField)
|
||||||
|
bottomBar.addSubview(holdSpeakBtn)
|
||||||
bottomBar.addSubview(emojiBtn)
|
bottomBar.addSubview(emojiBtn)
|
||||||
|
bottomBar.addSubview(voiceBtn)
|
||||||
bottomBar.addSubview(sendBtn)
|
bottomBar.addSubview(sendBtn)
|
||||||
|
bottomBar.addSubview(keyboardBtn)
|
||||||
quickActionBar.addSubview(galleryBtn)
|
quickActionBar.addSubview(galleryBtn)
|
||||||
quickActionBar.addSubview(cameraBtn)
|
quickActionBar.addSubview(cameraBtn)
|
||||||
quickActionBar.addSubview(locationBtn)
|
quickActionBar.addSubview(locationBtn)
|
||||||
|
|
@ -341,13 +381,17 @@ class GroupChatView: UIView {
|
||||||
|
|
||||||
bottomBarCornerView.layoutChain.edges()
|
bottomBarCornerView.layoutChain.edges()
|
||||||
|
|
||||||
voiceBtn.layoutChain
|
keyboardBtn.layoutChain
|
||||||
.left(13).centerY()
|
.right(12).centerY()
|
||||||
.width(28).height(28)
|
.width(28).height(28)
|
||||||
|
|
||||||
sendBtn.layoutChain
|
sendBtn.layoutChain
|
||||||
.right(10).centerY()
|
.right(8).centerY()
|
||||||
.width(56).height(40)
|
.width(36).height(36)
|
||||||
|
|
||||||
|
voiceBtn.layoutChain
|
||||||
|
.right(12).centerY()
|
||||||
|
.width(28).height(28)
|
||||||
|
|
||||||
emojiBtn.layoutChain
|
emojiBtn.layoutChain
|
||||||
.rightToLeftOfView(sendBtn, offset: -8)
|
.rightToLeftOfView(sendBtn, offset: -8)
|
||||||
|
|
@ -355,16 +399,18 @@ class GroupChatView: UIView {
|
||||||
.width(30).height(30)
|
.width(30).height(30)
|
||||||
|
|
||||||
textField.layoutChain
|
textField.layoutChain
|
||||||
.leftToRightOfView(voiceBtn, offset: 10)
|
.left(16)
|
||||||
.rightToLeftOfView(emojiBtn, offset: -10)
|
.rightToLeftOfView(emojiBtn, offset: -10)
|
||||||
.centerY()
|
.centerY()
|
||||||
.height(36)
|
.height(36)
|
||||||
|
|
||||||
voiceRecordView.layoutChain
|
holdSpeakBtn.layoutChain
|
||||||
.edgesHorzontal()
|
.left(16)
|
||||||
.bottom(-252)
|
.rightToLeftOfView(keyboardBtn, offset: -10)
|
||||||
.height(252)
|
.centerY()
|
||||||
// .heightToWidth(252/375)
|
.height(36)
|
||||||
|
|
||||||
|
voiceRecordView.layoutChain.edges()
|
||||||
|
|
||||||
emojiPanelView.layoutChain
|
emojiPanelView.layoutChain
|
||||||
.edgesHorzontal()
|
.edgesHorzontal()
|
||||||
|
|
@ -640,33 +686,44 @@ class GroupChatView: UIView {
|
||||||
|
|
||||||
lazy var textField: UITextField = {
|
lazy var textField: UITextField = {
|
||||||
let tf = UITextField()
|
let tf = UITextField()
|
||||||
tf.font = .systemFont(ofSize: 14)
|
tf.font = .systemFont(ofSize: 16)
|
||||||
tf.backgroundColor = .white
|
tf.textColor = UIColor(hexStr: "#293445")
|
||||||
tf.cornerRadius = 18
|
tf.backgroundColor = .clear
|
||||||
tf.placeholder = "输入消息..."
|
tf.placeholder = "发消息或者按住说话"
|
||||||
tf.returnKeyType = .send
|
tf.returnKeyType = .send
|
||||||
tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 36))
|
tf.tintColor = UIColor(hexStr: "#16B3FF")
|
||||||
tf.leftViewMode = .always
|
|
||||||
return tf
|
return tf
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
lazy var holdSpeakBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.setTitle("按住 说话", for: .normal)
|
||||||
|
btn.setTitleColor(UIColor(hexStr: "#293445"), for: .normal)
|
||||||
|
btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
btn.isExclusiveTouch = true
|
||||||
|
btn.isHidden = true
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var keyboardBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.setImage(UIImage(named: "IM/keyboard"), for: .normal)
|
||||||
|
btn.isHidden = true
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
lazy var emojiBtn: UIButton = {
|
lazy var emojiBtn: UIButton = {
|
||||||
let btn = UIButton(type: .custom)
|
let btn = UIButton(type: .custom)
|
||||||
btn.setImage(UIImage(named: "IM/emoji_input"), for: .normal)
|
btn.setImage(UIImage(named: "IM/emoji_input"), for: .normal)
|
||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var addBtn: UIButton = {
|
|
||||||
let btn = UIButton(type: .custom)
|
|
||||||
btn.setImage(UIImage(named: "IM/add"), for: .normal)
|
|
||||||
return btn
|
|
||||||
}()
|
|
||||||
|
|
||||||
lazy var sendBtn: UIButton = {
|
lazy var sendBtn: UIButton = {
|
||||||
let btn = UIButton(type: .custom)
|
let btn = UIButton(type: .custom)
|
||||||
btn.setImage(UIImage(named: "IM/send"), for: .normal)
|
btn.setImage(UIImage(named: "IM/send"), for: .normal)
|
||||||
btn.backgroundColor = UIColor(hexStr: "#18B5F4")
|
btn.backgroundColor = UIColor(hexStr: "#16B3FF")
|
||||||
btn.cornerRadius = 14
|
btn.cornerRadius = 10
|
||||||
|
btn.isHidden = true
|
||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -747,6 +804,7 @@ class GroupChatView: UIView {
|
||||||
backgroundColor = UIColor(hexStr: "#F5F5F5")
|
backgroundColor = UIColor(hexStr: "#F5F5F5")
|
||||||
setupUI()
|
setupUI()
|
||||||
setupRx()
|
setupRx()
|
||||||
|
refreshComposer()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
|
@ -1171,8 +1229,9 @@ class TextSendMsgCell: UITableViewCell {
|
||||||
.topToBottomOfView(senderNameView, offset: 5)
|
.topToBottomOfView(senderNameView, offset: 5)
|
||||||
.rightToView(senderNameView)
|
.rightToView(senderNameView)
|
||||||
.left(60, relation: .greaterThanOrEqual)
|
.left(60, relation: .greaterThanOrEqual)
|
||||||
.width(100, relation: .greaterThanOrEqual)
|
|
||||||
.height(30, relation: .greaterThanOrEqual)
|
.height(30, relation: .greaterThanOrEqual)
|
||||||
|
contentLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
contentLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
|
||||||
contentLabel.translatesAutoresizingMaskIntoConstraints = false
|
contentLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentLabel.layoutChain
|
contentLabel.layoutChain
|
||||||
|
|
@ -1180,6 +1239,7 @@ class TextSendMsgCell: UITableViewCell {
|
||||||
.edgesHorzontal(12)
|
.edgesHorzontal(12)
|
||||||
|
|
||||||
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
|
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
quoteBlock.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4)
|
let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4)
|
||||||
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
|
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
|
||||||
quoteTopConstraint = qTop
|
quoteTopConstraint = qTop
|
||||||
|
|
@ -1188,7 +1248,6 @@ class TextSendMsgCell: UITableViewCell {
|
||||||
qTop,
|
qTop,
|
||||||
quoteBlock.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor),
|
quoteBlock.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor),
|
||||||
quoteBlock.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 60),
|
quoteBlock.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 60),
|
||||||
quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor),
|
|
||||||
quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
||||||
qH
|
qH
|
||||||
])
|
])
|
||||||
|
|
@ -1304,8 +1363,9 @@ class TextReceivedMsgCell: UITableViewCell {
|
||||||
.topToBottomOfView(senderNameView, offset: 5)
|
.topToBottomOfView(senderNameView, offset: 5)
|
||||||
.leftToView(senderNameView)
|
.leftToView(senderNameView)
|
||||||
.right(60, relation: .greaterThanOrEqual)
|
.right(60, relation: .greaterThanOrEqual)
|
||||||
.width(100, relation: .greaterThanOrEqual)
|
|
||||||
.height(30, relation: .greaterThanOrEqual)
|
.height(30, relation: .greaterThanOrEqual)
|
||||||
|
contentLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
contentLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
|
||||||
contentLabel.translatesAutoresizingMaskIntoConstraints = false
|
contentLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentLabel.layoutChain
|
contentLabel.layoutChain
|
||||||
|
|
@ -1313,6 +1373,7 @@ class TextReceivedMsgCell: UITableViewCell {
|
||||||
.edgesHorzontal(12)
|
.edgesHorzontal(12)
|
||||||
|
|
||||||
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
|
quoteBlock.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
quoteBlock.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
// 引用在 bubbleView 下方
|
// 引用在 bubbleView 下方
|
||||||
let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4)
|
let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4)
|
||||||
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
|
let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0)
|
||||||
|
|
@ -1322,7 +1383,6 @@ class TextReceivedMsgCell: UITableViewCell {
|
||||||
qTop,
|
qTop,
|
||||||
quoteBlock.leadingAnchor.constraint(equalTo: senderNameView.leadingAnchor),
|
quoteBlock.leadingAnchor.constraint(equalTo: senderNameView.leadingAnchor),
|
||||||
quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60),
|
quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60),
|
||||||
quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor),
|
|
||||||
quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
||||||
qH
|
qH
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,399 @@
|
||||||
|
//
|
||||||
|
// AddEmojiPopView.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Lottie
|
||||||
|
|
||||||
|
final class AddEmojiPopView: UIView {
|
||||||
|
|
||||||
|
private static let shared = AddEmojiPopView(frame: CGRect(origin: .zero, size: kScreenSize))
|
||||||
|
private static let primaryBlue = UIColor(hexStr: "#16B3FF")
|
||||||
|
|
||||||
|
private let names = InteractionEmojiCell.normalFileNames
|
||||||
|
private var selectedName: String?
|
||||||
|
private var previewName: String?
|
||||||
|
private var lastLayoutWidth: CGFloat = 0
|
||||||
|
private var completion: ((String?) -> Void)?
|
||||||
|
|
||||||
|
private let collectionLayout = CollectionHFlowLayout()
|
||||||
|
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionLayout)
|
||||||
|
|
||||||
|
private let bgView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .black.withAlphaComponent(0.5)
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let previewView: LottieAnimationView = {
|
||||||
|
let view = LottieAnimationView()
|
||||||
|
view.contentMode = .scaleAspectFit
|
||||||
|
view.loopMode = .loop
|
||||||
|
view.backgroundBehavior = .pauseAndRestore
|
||||||
|
view.isUserInteractionEnabled = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let infoView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .white
|
||||||
|
view.layer.cornerRadius = 28
|
||||||
|
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
view.clipsToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let titleLab: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "实时表情"
|
||||||
|
label.textColor = UIColor(hexStr: "#293445")
|
||||||
|
label.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
label.textAlignment = .center
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let pageControl: UIPageControl = {
|
||||||
|
let control = UIPageControl()
|
||||||
|
control.isUserInteractionEnabled = false
|
||||||
|
control.currentPageIndicatorTintColor = UIColor(hexStr: "#16B3FF")
|
||||||
|
control.pageIndicatorTintColor = UIColor(hexStr: "#7AD6FF", alpha: 0.4)
|
||||||
|
return control
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var cancelBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.setTitle("取消", for: .normal)
|
||||||
|
btn.setTitleColor(Self.primaryBlue, for: .normal)
|
||||||
|
btn.titleLabel?.font = FontManager.boboBold(18)
|
||||||
|
btn.backgroundColor = .white
|
||||||
|
btn.cornerRadius = 16
|
||||||
|
btn.layer.borderWidth = 1
|
||||||
|
btn.layer.borderColor = Self.primaryBlue.cgColor
|
||||||
|
btn.addTarget(self, action: #selector(cancelAction), for: .touchUpInside)
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var confirmBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.setTitle("确定", for: .normal)
|
||||||
|
btn.setTitleColor(.white, for: .normal)
|
||||||
|
btn.titleLabel?.font = FontManager.boboBold(18)
|
||||||
|
btn.backgroundColor = Self.primaryBlue
|
||||||
|
btn.cornerRadius = 16
|
||||||
|
btn.addTarget(self, action: #selector(confirmAction), for: .touchUpInside)
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .clear
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
updateGridLayout()
|
||||||
|
if collectionView.bounds.width > 1, collectionView.contentSize.width < 1, !names.isEmpty {
|
||||||
|
collectionLayout.invalidateLayout()
|
||||||
|
collectionView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
addSubview(bgView)
|
||||||
|
addSubview(previewView)
|
||||||
|
addSubview(infoView)
|
||||||
|
bgView.layoutChain.edges()
|
||||||
|
|
||||||
|
infoView.addSubview(titleLab)
|
||||||
|
infoView.addSubview(collectionView)
|
||||||
|
infoView.addSubview(pageControl)
|
||||||
|
infoView.addSubview(cancelBtn)
|
||||||
|
infoView.addSubview(confirmBtn)
|
||||||
|
|
||||||
|
infoView.layoutChain
|
||||||
|
.bottom()
|
||||||
|
.edgesHorzontal()
|
||||||
|
|
||||||
|
titleLab.layoutChain
|
||||||
|
.top(22)
|
||||||
|
.edgesHorzontal(20)
|
||||||
|
.height(24)
|
||||||
|
|
||||||
|
cancelBtn.layoutChain
|
||||||
|
.left(20)
|
||||||
|
.bottom(20 + kSafeBottomMargin)
|
||||||
|
.height(44)
|
||||||
|
.widthToView(confirmBtn)
|
||||||
|
|
||||||
|
confirmBtn.layoutChain
|
||||||
|
.leftToRightOfView(cancelBtn, offset: 12)
|
||||||
|
.right(20)
|
||||||
|
.bottomToView(cancelBtn)
|
||||||
|
.height(44)
|
||||||
|
|
||||||
|
pageControl.layoutChain
|
||||||
|
.centerX()
|
||||||
|
.bottomToTopOfView(cancelBtn, offset: -10)
|
||||||
|
.height(16)
|
||||||
|
|
||||||
|
collectionLayout.rows = 3
|
||||||
|
collectionLayout.colums = 4
|
||||||
|
applyGridLayout(for: kScreenWidth - 32)
|
||||||
|
collectionView.backgroundColor = .clear
|
||||||
|
collectionView.isPagingEnabled = true
|
||||||
|
collectionView.bounces = false
|
||||||
|
collectionView.showsHorizontalScrollIndicator = false
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.register(AddEmojiCell.self)
|
||||||
|
collectionView.layoutChain
|
||||||
|
.topToBottomOfView(titleLab, offset: 16)
|
||||||
|
.edgesHorzontal(16)
|
||||||
|
.height(236)
|
||||||
|
.bottomToTopOfView(pageControl, offset: -4)
|
||||||
|
|
||||||
|
previewView.layoutChain
|
||||||
|
.centerX()
|
||||||
|
.bottomToTopOfView(infoView, offset: -36)
|
||||||
|
.width(148)
|
||||||
|
.height(148)
|
||||||
|
|
||||||
|
let tap = UITapGestureRecognizer(target: self, action: #selector(cancelAction))
|
||||||
|
tap.delegate = self
|
||||||
|
addGestureRecognizer(tap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetSelection() {
|
||||||
|
selectedName = names.first
|
||||||
|
pageControl.numberOfPages = max((names.count + 11) / 12, 1)
|
||||||
|
pageControl.currentPage = 0
|
||||||
|
lastLayoutWidth = 0
|
||||||
|
reloadGrid()
|
||||||
|
updatePreview(with: selectedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadGrid() {
|
||||||
|
updateGridLayout()
|
||||||
|
collectionLayout.invalidateLayout()
|
||||||
|
collectionView.reloadData()
|
||||||
|
collectionView.layoutIfNeeded()
|
||||||
|
collectionView.setContentOffset(.zero, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateGridLayout() {
|
||||||
|
let collectionWidth = collectionView.bounds.width
|
||||||
|
let width = collectionWidth > 1 ? collectionWidth : max(bounds.width - 32, kScreenWidth - 32)
|
||||||
|
guard width > 1 else { return }
|
||||||
|
applyGridLayout(for: width)
|
||||||
|
if collectionWidth > 1 {
|
||||||
|
lastLayoutWidth = collectionWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyGridLayout(for width: CGFloat) {
|
||||||
|
let columns: CGFloat = 4
|
||||||
|
let rows: CGFloat = 3
|
||||||
|
let inset = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)
|
||||||
|
let hSpacing: CGFloat = 20
|
||||||
|
let vSpacing: CGFloat = 10
|
||||||
|
let itemWidth = floor((width - inset.left - inset.right - hSpacing * (columns - 1)) / columns)
|
||||||
|
let collectionHeight = collectionView.bounds.height > 1 ? collectionView.bounds.height : 236
|
||||||
|
let itemHeight = floor((collectionHeight - inset.top - inset.bottom - vSpacing * (rows - 1)) / rows)
|
||||||
|
collectionLayout.itemSize = CGSize(width: max(itemWidth, 1), height: max(itemHeight, 1))
|
||||||
|
collectionLayout.hSpacing = hSpacing
|
||||||
|
collectionLayout.vSpacing = vSpacing
|
||||||
|
collectionLayout.sectionInset = inset
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePreview(with name: String?) {
|
||||||
|
guard let name else {
|
||||||
|
previewName = nil
|
||||||
|
previewView.stop()
|
||||||
|
previewView.animation = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if previewName == name, previewView.animation != nil {
|
||||||
|
if !previewView.isAnimationPlaying {
|
||||||
|
previewView.play()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewName = name
|
||||||
|
previewView.stop()
|
||||||
|
previewView.animation = nil
|
||||||
|
EmojiPanelCell.loadAnimation(for: name) { [weak self] animation in
|
||||||
|
guard let self, self.previewName == name else { return }
|
||||||
|
self.previewView.animation = animation
|
||||||
|
self.previewView.currentProgress = 0
|
||||||
|
self.previewView.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopPreview() {
|
||||||
|
previewName = nil
|
||||||
|
previewView.stop()
|
||||||
|
previewView.animation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func slideOffTransform() -> CGAffineTransform {
|
||||||
|
layoutIfNeeded()
|
||||||
|
return CGAffineTransform(translationX: 0, y: max(infoView.bounds.height, kScreenHeight * 0.4))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelAction() {
|
||||||
|
completion?(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmAction() {
|
||||||
|
completion?(selectedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AddEmojiPopView {
|
||||||
|
static func show(completion: @escaping (String?) -> Void) {
|
||||||
|
guard let superView = kKeyWindow else { return }
|
||||||
|
let pop = AddEmojiPopView.shared
|
||||||
|
if pop.superview != nil {
|
||||||
|
pop.removeFromSuperview()
|
||||||
|
}
|
||||||
|
pop.frame = CGRect(x: 0, y: 0, width: kScreenWidth, height: kScreenHeight)
|
||||||
|
pop.completion = { name in
|
||||||
|
completion(name)
|
||||||
|
AddEmojiPopView.dismiss()
|
||||||
|
}
|
||||||
|
pop.bgView.alpha = 0
|
||||||
|
pop.previewView.alpha = 0
|
||||||
|
superView.addSubview(pop)
|
||||||
|
superView.bringSubviewToFront(pop)
|
||||||
|
pop.setNeedsLayout()
|
||||||
|
pop.layoutIfNeeded()
|
||||||
|
pop.resetSelection()
|
||||||
|
pop.infoView.transform = pop.slideOffTransform()
|
||||||
|
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) {
|
||||||
|
pop.bgView.alpha = 1
|
||||||
|
pop.previewView.alpha = 1
|
||||||
|
pop.infoView.transform = .identity
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
pop.reloadGrid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dismiss() {
|
||||||
|
let pop = AddEmojiPopView.shared
|
||||||
|
guard pop.superview != nil else { return }
|
||||||
|
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn]) {
|
||||||
|
pop.bgView.alpha = 0
|
||||||
|
pop.previewView.alpha = 0
|
||||||
|
pop.infoView.transform = pop.slideOffTransform()
|
||||||
|
} completion: { _ in
|
||||||
|
pop.stopPreview()
|
||||||
|
pop.infoView.transform = .identity
|
||||||
|
pop.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AddEmojiPopView: UIGestureRecognizerDelegate {
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||||
|
touch.view == self || touch.view == bgView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AddEmojiPopView: UICollectionViewDataSource, UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
names.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let cell: AddEmojiCell = collectionView.dequeueReusableCell(for: indexPath)
|
||||||
|
let name = names[indexPath.item]
|
||||||
|
cell.configure(image: InteractionEmojiCell.pngImage(named: name), selected: name == selectedName)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
selectedName = names[indexPath.item]
|
||||||
|
collectionView.reloadData()
|
||||||
|
updatePreview(with: selectedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
guard scrollView.bounds.width > 0 else { return }
|
||||||
|
pageControl.currentPage = Int(round(scrollView.contentOffset.x / scrollView.bounds.width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AddEmojiCell: UICollectionViewCell {
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = UIColor(hexStr: "#F7F8FA")
|
||||||
|
contentView.layer.cornerRadius = 16
|
||||||
|
contentView.layer.borderWidth = 2
|
||||||
|
contentView.layer.borderColor = UIColor.clear.cgColor
|
||||||
|
contentView.clipsToBounds = true
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
contentView.addSubview(imageView)
|
||||||
|
imageView.layoutChain.edges(all: 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(image: UIImage?, selected: Bool) {
|
||||||
|
imageView.image = image
|
||||||
|
contentView.layer.borderColor = selected ? UIColor(hexStr: "#16B3FF").cgColor : UIColor.clear.cgColor
|
||||||
|
contentView.backgroundColor = selected ? UIColor(hexStr: "#F3FBFF") : UIColor(hexStr: "#F7F8FA")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
final class ReceivedEmojiPopView {
|
||||||
|
|
||||||
|
private static let emojiView: LottieAnimationView = {
|
||||||
|
let view = LottieAnimationView()
|
||||||
|
view.contentMode = .scaleAspectFit
|
||||||
|
view.loopMode = .playOnce
|
||||||
|
view.backgroundBehavior = .pauseAndRestore
|
||||||
|
view.isUserInteractionEnabled = false
|
||||||
|
view.isHidden = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func show(name: String) {
|
||||||
|
guard let window = kKeyWindow else { return }
|
||||||
|
let view = emojiView
|
||||||
|
if view.superview != window {
|
||||||
|
view.removeFromSuperview()
|
||||||
|
window.addSubview(view)
|
||||||
|
view.layoutChain
|
||||||
|
.centerX()
|
||||||
|
.centerY()
|
||||||
|
.width(180)
|
||||||
|
.height(180)
|
||||||
|
}
|
||||||
|
window.bringSubviewToFront(view)
|
||||||
|
view.stop()
|
||||||
|
view.animation = nil
|
||||||
|
view.isHidden = false
|
||||||
|
EmojiPanelCell.loadAnimation(for: name) { animation in
|
||||||
|
guard view.superview != nil else { return }
|
||||||
|
view.animation = animation
|
||||||
|
view.currentProgress = 0
|
||||||
|
view.play { finished in
|
||||||
|
guard finished else { return }
|
||||||
|
view.isHidden = true
|
||||||
|
view.animation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
//
|
||||||
|
// AddMessagePopView.swift
|
||||||
|
// QuickLocation
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class AddMessagePopView: UIView {
|
||||||
|
|
||||||
|
private static let shared = AddMessagePopView(frame: CGRect(origin: .zero, size: kScreenSize))
|
||||||
|
private static let primaryBlue = UIColor(hexStr: "#16B3FF")
|
||||||
|
|
||||||
|
private let messages = QuickMessageView.messageList
|
||||||
|
private var selectedIndex: Int = 0
|
||||||
|
private var completion: ((Int?) -> Void)?
|
||||||
|
|
||||||
|
private let bgView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .black.withAlphaComponent(0.5)
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let infoView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .white
|
||||||
|
view.layer.cornerRadius = 28
|
||||||
|
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
view.clipsToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let titleLab: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "实时消息"
|
||||||
|
label.textColor = UIColor(hexStr: "#293445")
|
||||||
|
label.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
label.textAlignment = .center
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let stackView: UIStackView = {
|
||||||
|
let stack = UIStackView()
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.alignment = .leading
|
||||||
|
stack.spacing = 12
|
||||||
|
return stack
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var cancelBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.setTitle("取消", for: .normal)
|
||||||
|
btn.setTitleColor(Self.primaryBlue, for: .normal)
|
||||||
|
btn.titleLabel?.font = FontManager.boboBold(18)
|
||||||
|
btn.backgroundColor = .white
|
||||||
|
btn.cornerRadius = 16
|
||||||
|
btn.layer.borderWidth = 1
|
||||||
|
btn.layer.borderColor = Self.primaryBlue.cgColor
|
||||||
|
btn.addTarget(self, action: #selector(cancelAction), for: .touchUpInside)
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var confirmBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.setTitle("确定", for: .normal)
|
||||||
|
btn.setTitleColor(.white, for: .normal)
|
||||||
|
btn.titleLabel?.font = FontManager.boboBold(18)
|
||||||
|
btn.backgroundColor = Self.primaryBlue
|
||||||
|
btn.cornerRadius = 16
|
||||||
|
btn.addTarget(self, action: #selector(confirmAction), for: .touchUpInside)
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .clear
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
addSubview(bgView)
|
||||||
|
addSubview(infoView)
|
||||||
|
bgView.layoutChain.edges()
|
||||||
|
|
||||||
|
infoView.addSubview(titleLab)
|
||||||
|
infoView.addSubview(stackView)
|
||||||
|
infoView.addSubview(cancelBtn)
|
||||||
|
infoView.addSubview(confirmBtn)
|
||||||
|
|
||||||
|
infoView.layoutChain
|
||||||
|
.bottom()
|
||||||
|
.edgesHorzontal()
|
||||||
|
|
||||||
|
titleLab.layoutChain
|
||||||
|
.top(22)
|
||||||
|
.edgesHorzontal(20)
|
||||||
|
.height(24)
|
||||||
|
|
||||||
|
cancelBtn.layoutChain
|
||||||
|
.left(20)
|
||||||
|
.bottom(20 + kSafeBottomMargin)
|
||||||
|
.height(44)
|
||||||
|
.widthToView(confirmBtn)
|
||||||
|
|
||||||
|
confirmBtn.layoutChain
|
||||||
|
.leftToRightOfView(cancelBtn, offset: 12)
|
||||||
|
.right(20)
|
||||||
|
.bottomToView(cancelBtn)
|
||||||
|
.height(44)
|
||||||
|
|
||||||
|
stackView.layoutChain
|
||||||
|
.topToBottomOfView(titleLab, offset: 20)
|
||||||
|
.left(20)
|
||||||
|
.right(20)
|
||||||
|
.bottomToTopOfView(cancelBtn, offset: -20)
|
||||||
|
|
||||||
|
for (index, text) in messages.enumerated() {
|
||||||
|
let button = UIButton(type: .custom)
|
||||||
|
button.tag = index
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.title = text
|
||||||
|
configuration.baseForegroundColor = UIColor(hexStr: "#293445")
|
||||||
|
configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)
|
||||||
|
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||||
|
var outgoing = incoming
|
||||||
|
outgoing.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
button.configuration = configuration
|
||||||
|
button.layer.cornerRadius = 10
|
||||||
|
button.layer.borderWidth = 1
|
||||||
|
button.addTarget(self, action: #selector(selectMessage(_:)), for: .touchUpInside)
|
||||||
|
stackView.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
|
||||||
|
let tap = UITapGestureRecognizer(target: self, action: #selector(cancelAction))
|
||||||
|
tap.delegate = self
|
||||||
|
addGestureRecognizer(tap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetSelection() {
|
||||||
|
selectedIndex = 0
|
||||||
|
refreshButtons()
|
||||||
|
updatePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshButtons() {
|
||||||
|
for (index, view) in stackView.arrangedSubviews.enumerated() {
|
||||||
|
guard let button = view as? UIButton else { continue }
|
||||||
|
let selected = index == selectedIndex
|
||||||
|
var configuration = button.configuration
|
||||||
|
configuration?.baseForegroundColor = selected ? Self.primaryBlue : UIColor(hexStr: "#293445")
|
||||||
|
button.configuration = configuration
|
||||||
|
button.backgroundColor = selected ? UIColor(hexStr: "#F3FBFF") : UIColor(hexStr: "#F5F6F8")
|
||||||
|
button.layer.borderColor = (selected ? Self.primaryBlue : UIColor.clear).cgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func slideOffTransform() -> CGAffineTransform {
|
||||||
|
layoutIfNeeded()
|
||||||
|
return CGAffineTransform(translationX: 0, y: max(infoView.bounds.height, kScreenHeight * 0.4))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePreview() {
|
||||||
|
guard messages.indices.contains(selectedIndex) else { return }
|
||||||
|
ReceivedMessagePopView.showPreview(text: messages[selectedIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func selectMessage(_ sender: UIButton) {
|
||||||
|
selectedIndex = sender.tag
|
||||||
|
refreshButtons()
|
||||||
|
updatePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelAction() {
|
||||||
|
completion?(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmAction() {
|
||||||
|
completion?(selectedIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AddMessagePopView {
|
||||||
|
static func show(completion: @escaping (Int?) -> Void) {
|
||||||
|
guard let superView = kKeyWindow else { return }
|
||||||
|
let pop = AddMessagePopView.shared
|
||||||
|
if pop.superview != nil {
|
||||||
|
pop.removeFromSuperview()
|
||||||
|
}
|
||||||
|
pop.frame = CGRect(x: 0, y: 0, width: kScreenWidth, height: kScreenHeight)
|
||||||
|
pop.completion = { index in
|
||||||
|
completion(index)
|
||||||
|
AddMessagePopView.dismiss()
|
||||||
|
}
|
||||||
|
pop.bgView.alpha = 0
|
||||||
|
superView.addSubview(pop)
|
||||||
|
superView.bringSubviewToFront(pop)
|
||||||
|
pop.layoutIfNeeded()
|
||||||
|
pop.resetSelection()
|
||||||
|
pop.infoView.transform = pop.slideOffTransform()
|
||||||
|
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) {
|
||||||
|
pop.bgView.alpha = 1
|
||||||
|
pop.infoView.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dismiss() {
|
||||||
|
let pop = AddMessagePopView.shared
|
||||||
|
guard pop.superview != nil else { return }
|
||||||
|
ReceivedMessagePopView.dismissPreview()
|
||||||
|
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn]) {
|
||||||
|
pop.bgView.alpha = 0
|
||||||
|
pop.infoView.transform = pop.slideOffTransform()
|
||||||
|
} completion: { _ in
|
||||||
|
pop.infoView.transform = .identity
|
||||||
|
pop.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AddMessagePopView: UIGestureRecognizerDelegate {
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||||
|
touch.view == self || touch.view == bgView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
final class ReceivedMessagePopView {
|
||||||
|
|
||||||
|
private static let banner: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = UIColor.white.withAlphaComponent(0.92)
|
||||||
|
view.layer.cornerRadius = 10
|
||||||
|
view.isHidden = true
|
||||||
|
view.isUserInteractionEnabled = false
|
||||||
|
view.addSubview(messageLab)
|
||||||
|
messageLab.layoutChain.edges(all: 12)
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let messageLab: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
label.textColor = UIColor(hexStr: "#0F2846")
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static var hideWorkItem: DispatchWorkItem?
|
||||||
|
private static var isPreview = false
|
||||||
|
private static var showToken = 0
|
||||||
|
private static var didInstallConstraints = false
|
||||||
|
|
||||||
|
static func showPreview(text: String) {
|
||||||
|
let attr = NSAttributedString(
|
||||||
|
string: text,
|
||||||
|
attributes: [
|
||||||
|
.font: UIFont.systemFont(ofSize: 12, weight: .medium),
|
||||||
|
.foregroundColor: UIColor(hexStr: "#0F2846")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
isPreview = true
|
||||||
|
show(attributed: attr, autoHide: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func showIncoming(nickName: String, message: String) {
|
||||||
|
let name = nickName.isEmpty ? "成员" : nickName
|
||||||
|
let prefix = "对你说:"
|
||||||
|
let fullText = name + prefix + message
|
||||||
|
let attr = NSMutableAttributedString(string: fullText)
|
||||||
|
let fullRange = NSRange(location: 0, length: (fullText as NSString).length)
|
||||||
|
let nameRange = NSRange(location: 0, length: (name as NSString).length)
|
||||||
|
attr.addAttribute(.font, value: UIFont.systemFont(ofSize: 12, weight: .medium), range: fullRange)
|
||||||
|
attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#333333"), range: fullRange)
|
||||||
|
attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: nameRange)
|
||||||
|
isPreview = false
|
||||||
|
show(attributed: attr, autoHide: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dismissPreview() {
|
||||||
|
guard isPreview else { return }
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dismiss() {
|
||||||
|
hideWorkItem?.cancel()
|
||||||
|
hideWorkItem = nil
|
||||||
|
isPreview = false
|
||||||
|
guard banner.superview != nil, !banner.isHidden else { return }
|
||||||
|
showToken += 1
|
||||||
|
let token = showToken
|
||||||
|
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn]) {
|
||||||
|
banner.transform = CGAffineTransform(translationX: 0, y: -90)
|
||||||
|
banner.alpha = 0
|
||||||
|
} completion: { finished in
|
||||||
|
guard finished, token == showToken else { return }
|
||||||
|
banner.isHidden = true
|
||||||
|
banner.alpha = 1
|
||||||
|
banner.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func show(attributed: NSAttributedString, autoHide: Bool) {
|
||||||
|
guard let window = kKeyWindow else { return }
|
||||||
|
hideWorkItem?.cancel()
|
||||||
|
hideWorkItem = nil
|
||||||
|
showToken += 1
|
||||||
|
let token = showToken
|
||||||
|
let view = banner
|
||||||
|
if view.superview != window {
|
||||||
|
view.removeFromSuperview()
|
||||||
|
didInstallConstraints = false
|
||||||
|
window.addSubview(view)
|
||||||
|
}
|
||||||
|
if !didInstallConstraints {
|
||||||
|
view.layoutChain
|
||||||
|
.top(kStatusBarHeight)
|
||||||
|
.centerX()
|
||||||
|
view.widthAnchor.constraint(lessThanOrEqualToConstant: kScreenWidth - 30).isActive = true
|
||||||
|
didInstallConstraints = true
|
||||||
|
}
|
||||||
|
window.bringSubviewToFront(view)
|
||||||
|
messageLab.attributedText = attributed
|
||||||
|
messageLab.preferredMaxLayoutWidth = kScreenWidth - 54
|
||||||
|
let shouldAnimateIn = view.isHidden || view.alpha < 1
|
||||||
|
view.isHidden = false
|
||||||
|
view.alpha = 1
|
||||||
|
if shouldAnimateIn {
|
||||||
|
view.transform = CGAffineTransform(translationX: 0, y: -90)
|
||||||
|
UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseOut]) {
|
||||||
|
view.transform = .identity
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view.transform = .identity
|
||||||
|
}
|
||||||
|
guard autoHide else { return }
|
||||||
|
let work = DispatchWorkItem {
|
||||||
|
guard token == showToken else { return }
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
hideWorkItem = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,10 @@ class GroupListPopView: UIView {
|
||||||
|
|
||||||
private static let shared = GroupListPopView(frame: CGRect(origin: .zero, size: kScreenSize))
|
private static let shared = GroupListPopView(frame: CGRect(origin: .zero, size: kScreenSize))
|
||||||
|
|
||||||
private static let rowHeight: CGFloat = 72
|
private static let rowHeight: CGFloat = 80
|
||||||
private static let maxVisibleRows = 3
|
private static let maxVisibleRows = 3
|
||||||
private static let primaryBlue = UIColor(hexStr: "#16B3FF")
|
private static let primaryBlue = UIColor(hexStr: "#16B3FF")
|
||||||
private static let cardBg = UIColor(hexStr: "#F5FBFF")
|
private static let cardBg = UIColor(hexStr: "#E3F6FF")
|
||||||
private static let tagBg = UIColor(hexStr: "#FFF9DD")
|
private static let tagBg = UIColor(hexStr: "#FFF9DD")
|
||||||
private static let tagText = UIColor(hexStr: "#D5923F")
|
private static let tagText = UIColor(hexStr: "#D5923F")
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ class GroupListPopView: UIView {
|
||||||
bg.backgroundColor = UIColor(hexStr: "#FDFDFD")
|
bg.backgroundColor = UIColor(hexStr: "#FDFDFD")
|
||||||
view.addSubview(bg)
|
view.addSubview(bg)
|
||||||
bg.layoutChain
|
bg.layoutChain
|
||||||
.top(80)
|
.top(88)
|
||||||
.edges(excludingEdge: .top)
|
.edges(excludingEdge: .top)
|
||||||
|
|
||||||
return view
|
return view
|
||||||
|
|
@ -74,8 +74,8 @@ class GroupListPopView: UIView {
|
||||||
lazy var headerImageView: UIImageView = {
|
lazy var headerImageView: UIImageView = {
|
||||||
let view = UIImageView()
|
let view = UIImageView()
|
||||||
view.image = UIImage(named: "Home/group_list_header")
|
view.image = UIImage(named: "Home/group_list_header")
|
||||||
view.contentMode = .scaleAspectFill
|
view.contentMode = .scaleAspectFit
|
||||||
view.clipsToBounds = true
|
view.clipsToBounds = false
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ class GroupListPopView: UIView {
|
||||||
btn.setTitleColor(Self.primaryBlue, for: .normal)
|
btn.setTitleColor(Self.primaryBlue, for: .normal)
|
||||||
btn.titleLabel?.font = FontManager.boboBold(18)
|
btn.titleLabel?.font = FontManager.boboBold(18)
|
||||||
btn.backgroundColor = .white
|
btn.backgroundColor = .white
|
||||||
btn.cornerRadius = 16
|
btn.cornerRadius = 25
|
||||||
btn.layer.borderWidth = 1
|
btn.layer.borderWidth = 1
|
||||||
btn.layer.borderColor = Self.primaryBlue.cgColor
|
btn.layer.borderColor = Self.primaryBlue.cgColor
|
||||||
btn.addTarget(self, action: #selector(cancelAction), for: .touchUpInside)
|
btn.addTarget(self, action: #selector(cancelAction), for: .touchUpInside)
|
||||||
|
|
@ -112,7 +112,7 @@ class GroupListPopView: UIView {
|
||||||
btn.setTitleColor(.white, for: .normal)
|
btn.setTitleColor(.white, for: .normal)
|
||||||
btn.titleLabel?.font = FontManager.boboBold(18)
|
btn.titleLabel?.font = FontManager.boboBold(18)
|
||||||
btn.backgroundColor = Self.primaryBlue
|
btn.backgroundColor = Self.primaryBlue
|
||||||
btn.cornerRadius = 16
|
btn.cornerRadius = 25
|
||||||
btn.addTarget(self, action: #selector(confirmAction), for: .touchUpInside)
|
btn.addTarget(self, action: #selector(confirmAction), for: .touchUpInside)
|
||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
@ -137,19 +137,19 @@ class GroupListPopView: UIView {
|
||||||
headerImageView.layoutChain
|
headerImageView.layoutChain
|
||||||
.top()
|
.top()
|
||||||
.edgesHorzontal()
|
.edgesHorzontal()
|
||||||
.heightToWidth(127/375)
|
.heightToWidth(271.0 / 750.0)
|
||||||
|
|
||||||
cancelBtn.layoutChain
|
cancelBtn.layoutChain
|
||||||
.left(20)
|
.left(20)
|
||||||
.bottom(20 + kSafeBottomMargin)
|
.bottom(20 + kSafeBottomMargin)
|
||||||
.height(44)
|
.height(50)
|
||||||
.widthToView(confirmBtn)
|
.widthToView(confirmBtn)
|
||||||
|
|
||||||
confirmBtn.layoutChain
|
confirmBtn.layoutChain
|
||||||
.leftToRightOfView(cancelBtn, offset: 12)
|
.leftToRightOfView(cancelBtn, offset: 12)
|
||||||
.right(20)
|
.right(20)
|
||||||
.bottomToView(cancelBtn)
|
.bottomToView(cancelBtn)
|
||||||
.height(44)
|
.height(50)
|
||||||
|
|
||||||
tableView.layoutChain
|
tableView.layoutChain
|
||||||
.topToBottomOfView(headerImageView, offset: -20)
|
.topToBottomOfView(headerImageView, offset: -20)
|
||||||
|
|
@ -245,7 +245,10 @@ extension GroupListPopView: UITableViewDataSource, UITableViewDelegate {
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell: GroupListCell = tableView.dequeueReusableCell(for: indexPath)
|
let cell: GroupListCell = tableView.dequeueReusableCell(for: indexPath)
|
||||||
let model = groupList[indexPath.row]
|
let model = groupList[indexPath.row]
|
||||||
cell.configure(model: model, isSelected: selectedGroupKey == model.group_key)
|
let onlineCount: Int? = (model.group_key == groupModel?.default_group_key)
|
||||||
|
? groupModel?.select_group_employee.filter { $0.is_online }.count
|
||||||
|
: nil
|
||||||
|
cell.configure(model: model, isSelected: selectedGroupKey == model.group_key, onlineCount: onlineCount)
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,10 +261,14 @@ extension GroupListPopView: UITableViewDataSource, UITableViewDelegate {
|
||||||
// MARK: - GroupListCell
|
// MARK: - GroupListCell
|
||||||
class GroupListCell: UITableViewCell {
|
class GroupListCell: UITableViewCell {
|
||||||
|
|
||||||
func configure(model: GroupInfoModel, isSelected: Bool) {
|
func configure(model: GroupInfoModel, isSelected: Bool, onlineCount: Int? = nil) {
|
||||||
avaterImgView.image = model.groupIcon
|
avaterImgView.image = model.groupIcon
|
||||||
nameLab.text = model.name
|
nameLab.text = model.name
|
||||||
countLab.text = "共\(model.people_no)成员"
|
if let onlineCount {
|
||||||
|
countLab.text = "共\(model.people_no)成员/\(onlineCount)在线"
|
||||||
|
} else {
|
||||||
|
countLab.text = "共\(model.people_no)成员"
|
||||||
|
}
|
||||||
|
|
||||||
tagStack.arrangedSubviews.forEach {
|
tagStack.arrangedSubviews.forEach {
|
||||||
tagStack.removeArrangedSubview($0)
|
tagStack.removeArrangedSubview($0)
|
||||||
|
|
@ -320,8 +327,8 @@ class GroupListCell: UITableViewCell {
|
||||||
avaterImgView.layoutChain
|
avaterImgView.layoutChain
|
||||||
.left(12)
|
.left(12)
|
||||||
.centerY()
|
.centerY()
|
||||||
.width(44)
|
.width(48)
|
||||||
.height(44)
|
.height(48)
|
||||||
|
|
||||||
checkIcon.layoutChain
|
checkIcon.layoutChain
|
||||||
.right(12)
|
.right(12)
|
||||||
|
|
@ -352,7 +359,7 @@ class GroupListCell: UITableViewCell {
|
||||||
lazy var cardView: UIView = {
|
lazy var cardView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.backgroundColor = .white
|
view.backgroundColor = .white
|
||||||
view.cornerRadius = 12
|
view.cornerRadius = 16
|
||||||
view.clipsToBounds = true
|
view.clipsToBounds = true
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
@ -360,7 +367,7 @@ class GroupListCell: UITableViewCell {
|
||||||
lazy var avaterImgView: UIImageView = {
|
lazy var avaterImgView: UIImageView = {
|
||||||
let view = UIImageView()
|
let view = UIImageView()
|
||||||
view.contentMode = .scaleAspectFill
|
view.contentMode = .scaleAspectFill
|
||||||
view.cornerRadius = 8
|
view.cornerRadius = 10
|
||||||
view.clipsToBounds = true
|
view.clipsToBounds = true
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ import SwiftyUserDefaults
|
||||||
class GroupMemberView2: UIView {
|
class GroupMemberView2: UIView {
|
||||||
|
|
||||||
private var navBarHeightConstraint: NSLayoutConstraint?
|
private var navBarHeightConstraint: NSLayoutConstraint?
|
||||||
|
private var todayTrackTopToInteraction: NSLayoutConstraint?
|
||||||
|
private var todayTrackTopToMember: NSLayoutConstraint?
|
||||||
private var currentMemberModel: GroupMemberModel?
|
private var currentMemberModel: GroupMemberModel?
|
||||||
|
private var emojiEchoByUserId: [String: String] = [:]
|
||||||
|
private var messageEchoByUserId: [String: String] = [:]
|
||||||
|
|
||||||
private static let batteryNormalColor = UIColor(hexStr: "#47DD00")
|
private static let batteryNormalColor = UIColor(hexStr: "#47DD00")
|
||||||
private static let batteryLowColor = UIColor(hexStr: "#F85A5D")
|
private static let batteryLowColor = UIColor(hexStr: "#F85A5D")
|
||||||
|
|
@ -44,6 +48,55 @@ class GroupMemberView2: UIView {
|
||||||
setupUnknownDeviceInfo(battery: model.battery)
|
setupUnknownDeviceInfo(battery: model.battery)
|
||||||
}
|
}
|
||||||
refreshMemberReports(for: model)
|
refreshMemberReports(for: model)
|
||||||
|
updateRealtimeInteractionVisibility(isCurrentUser: isCurrentUser)
|
||||||
|
refreshRealtimeInteraction()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEmojiEcho(name: String, for userId: String) {
|
||||||
|
emojiEchoByUserId[userId] = name
|
||||||
|
if currentMemberModel?.user_id == userId {
|
||||||
|
refreshRealtimeInteraction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMessageEcho(text: String, for userId: String) {
|
||||||
|
messageEchoByUserId[userId] = text
|
||||||
|
if currentMemberModel?.user_id == userId {
|
||||||
|
refreshRealtimeInteraction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshRealtimeInteraction() {
|
||||||
|
let userId = currentMemberModel?.user_id ?? ""
|
||||||
|
applyEmojiDisplay(emojiEchoByUserId[userId])
|
||||||
|
applyMessageDisplay(messageEchoByUserId[userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRealtimeInteractionVisibility(isCurrentUser: Bool) {
|
||||||
|
let show = !isCurrentUser
|
||||||
|
interactionView.isHidden = !show
|
||||||
|
if show {
|
||||||
|
todayTrackTopToMember?.isActive = false
|
||||||
|
todayTrackTopToInteraction?.isActive = true
|
||||||
|
} else {
|
||||||
|
todayTrackTopToInteraction?.isActive = false
|
||||||
|
todayTrackTopToMember?.isActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyEmojiDisplay(_ name: String?) {
|
||||||
|
if let name, let image = InteractionEmojiCell.pngImage(named: name) {
|
||||||
|
emojiImageView.image = image
|
||||||
|
emojiImageView.tintColor = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let config = UIImage.SymbolConfiguration(pointSize: 36, weight: .regular)
|
||||||
|
emojiImageView.image = UIImage(systemName: "face.smiling", withConfiguration: config)
|
||||||
|
emojiImageView.tintColor = UIColor(hexStr: "#C8CDD6")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyMessageDisplay(_ text: String?) {
|
||||||
|
messageTextLab.text = text ?? "暂时没有消息记录"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateDistance(for model: GroupMemberModel, isCurrentUser: Bool) {
|
private func updateDistance(for model: GroupMemberModel, isCurrentUser: Bool) {
|
||||||
|
|
@ -261,10 +314,20 @@ class GroupMemberView2: UIView {
|
||||||
memberView.layoutChain
|
memberView.layoutChain
|
||||||
.edges(excludingEdge: .bottom)
|
.edges(excludingEdge: .bottom)
|
||||||
|
|
||||||
|
scrollContentView.addSubview(interactionView)
|
||||||
|
interactionView.isHidden = true
|
||||||
|
interactionView.layoutChain
|
||||||
|
.topToBottomOfView(memberView, offset: 12)
|
||||||
|
.edgesHorzontal()
|
||||||
|
|
||||||
scrollContentView.addSubview(todayTrackView)
|
scrollContentView.addSubview(todayTrackView)
|
||||||
todayTrackView.layoutChain
|
todayTrackView.layoutChain
|
||||||
.topToBottomOfView(memberView, offset: 15)
|
.topToBottomOfView(interactionView, offset: 15)
|
||||||
|
todayTrackTopToInteraction = todayTrackView.jh_lastConstraint
|
||||||
|
todayTrackTopToInteraction?.isActive = false
|
||||||
|
todayTrackView.layoutChain
|
||||||
.edgesHorzontal()
|
.edgesHorzontal()
|
||||||
|
todayTrackTopToMember = todayTrackView.jh_pinEdge(.top, toEdge: .bottom, of: memberView, offset: 12)
|
||||||
|
|
||||||
scrollContentView.addSubview(phoneReportView)
|
scrollContentView.addSubview(phoneReportView)
|
||||||
phoneReportView.layoutChain
|
phoneReportView.layoutChain
|
||||||
|
|
@ -300,11 +363,19 @@ class GroupMemberView2: UIView {
|
||||||
.leftToRightOfView(icon, offset: 6)
|
.leftToRightOfView(icon, offset: 6)
|
||||||
.centerY(icon)
|
.centerY(icon)
|
||||||
|
|
||||||
view.addSubview(changeGroupBtn)
|
let groupBtnView = UIView()
|
||||||
changeGroupBtn.layoutChain
|
groupBtnView.backgroundColor = .white
|
||||||
|
groupBtnView.cornerRadius = 8
|
||||||
|
view.addSubview(groupBtnView)
|
||||||
|
groupBtnView.addSubview(changeGroupBtn)
|
||||||
|
groupBtnView.layoutChain
|
||||||
.centerY(icon)
|
.centerY(icon)
|
||||||
.right(15)
|
.right(15)
|
||||||
.height(20)
|
.width(70)
|
||||||
|
.height(24)
|
||||||
|
|
||||||
|
changeGroupBtn.layoutChain
|
||||||
|
.edges()
|
||||||
|
|
||||||
let memberView = UIView()
|
let memberView = UIView()
|
||||||
memberView.backgroundColor = .white
|
memberView.backgroundColor = .white
|
||||||
|
|
@ -458,8 +529,8 @@ class GroupMemberView2: UIView {
|
||||||
lazy var memberNameLab: UILabel = {
|
lazy var memberNameLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = " "
|
label.text = " "
|
||||||
label.font = FontManager.youSheBiaoTiHei(12)
|
label.font = FontManager.youSheBiaoTiHei(14)
|
||||||
label.textColor = UIColor(hexStr: "#353B4F")
|
label.textColor = UIColor(hexStr: "#293445")
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -714,6 +785,120 @@ class GroupMemberView2: UIView {
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// MARK: - 实时互动
|
||||||
|
lazy var interactionView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
|
||||||
|
let icon = UIImageView()
|
||||||
|
icon.image = UIImage(named: "Home/member_today_track")
|
||||||
|
view.addSubview(icon)
|
||||||
|
icon.layoutChain
|
||||||
|
.top()
|
||||||
|
.left(15)
|
||||||
|
.width(18)
|
||||||
|
.heightToWidth(1)
|
||||||
|
|
||||||
|
let titleLab = UILabel()
|
||||||
|
titleLab.text = "实时互动"
|
||||||
|
titleLab.textColor = UIColor(hexStr: "#293445")
|
||||||
|
titleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
|
view.addSubview(titleLab)
|
||||||
|
titleLab.layoutChain
|
||||||
|
.leftToRightOfView(icon, offset: 6)
|
||||||
|
.centerY(icon)
|
||||||
|
|
||||||
|
view.addSubview(emojiCard)
|
||||||
|
view.addSubview(messageCard)
|
||||||
|
emojiCard.layoutChain
|
||||||
|
.topToBottomOfView(icon, offset: 12)
|
||||||
|
.left(16)
|
||||||
|
.height(58)
|
||||||
|
.bottom()
|
||||||
|
messageCard.layoutChain
|
||||||
|
.topToView(emojiCard)
|
||||||
|
.leftToRightOfView(emojiCard, offset: 10)
|
||||||
|
.right(16)
|
||||||
|
.widthToView(emojiCard)
|
||||||
|
.bottomToView(emojiCard)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var emojiCard: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .white
|
||||||
|
view.cornerRadius = 16
|
||||||
|
|
||||||
|
view.addSubview(emojiImageView)
|
||||||
|
emojiImageView.layoutChain
|
||||||
|
.top(5)
|
||||||
|
.centerX()
|
||||||
|
.width(30)
|
||||||
|
.heightToWidth(1)
|
||||||
|
|
||||||
|
let hintLab = UILabel()
|
||||||
|
hintLab.text = "点击发送表情"
|
||||||
|
hintLab.textColor = UIColor(hexStr: "#9AA1AE")
|
||||||
|
hintLab.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
hintLab.textAlignment = .center
|
||||||
|
view.addSubview(hintLab)
|
||||||
|
hintLab.layoutChain
|
||||||
|
.topToBottomOfView(emojiImageView, offset: 6)
|
||||||
|
.edgesHorzontal(8)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var messageCard: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .white
|
||||||
|
view.cornerRadius = 16
|
||||||
|
|
||||||
|
let msgView = UIView()
|
||||||
|
msgView.backgroundColor = UIColor(hexStr: "#F2F2F2")
|
||||||
|
msgView.cornerRadius = 8
|
||||||
|
|
||||||
|
view.addSubview(msgView)
|
||||||
|
msgView.addSubview(messageTextLab)
|
||||||
|
|
||||||
|
msgView.layoutChain
|
||||||
|
.top(9)
|
||||||
|
.edgesHorzontal(10)
|
||||||
|
.height(26)
|
||||||
|
|
||||||
|
messageTextLab.layoutChain
|
||||||
|
.edgesHorzontal(25)
|
||||||
|
.centerY()
|
||||||
|
|
||||||
|
let hintLab = UILabel()
|
||||||
|
hintLab.text = "点击发送消息"
|
||||||
|
hintLab.textColor = UIColor(hexStr: "#9AA1AE")
|
||||||
|
hintLab.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
hintLab.textAlignment = .center
|
||||||
|
view.addSubview(hintLab)
|
||||||
|
hintLab.layoutChain
|
||||||
|
.topToBottomOfView(msgView, offset: 6)
|
||||||
|
.edgesHorzontal(8)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var emojiImageView: UIImageView = {
|
||||||
|
let view = UIImageView()
|
||||||
|
view.contentMode = .scaleAspectFit
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
lazy var messageTextLab: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "暂时没有消息记录"
|
||||||
|
label.textColor = UIColor(hexStr: "#293445")
|
||||||
|
label.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
label.textAlignment = .center
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: - 今日轨迹
|
// MARK: - 今日轨迹
|
||||||
lazy var todayTrackView: UIView = {
|
lazy var todayTrackView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ class HomeViewController: BaseViewController {
|
||||||
#if !targetEnvironment(simulator)
|
#if !targetEnvironment(simulator)
|
||||||
/// 自己持有的地图点,避免只靠 mapView.annotations 对不上同一实例
|
/// 自己持有的地图点,避免只靠 mapView.annotations 对不上同一实例
|
||||||
private var annotationByUserId: [String: MemberAnnotation] = [:]
|
private var annotationByUserId: [String: MemberAnnotation] = [:]
|
||||||
|
/// 散开前的真实坐标,annotation.coordinate 可能是展示偏移
|
||||||
|
private var trueCoordinateByUserId: [String: CLLocationCoordinate2D] = [:]
|
||||||
|
private let overlapClusterRadius: CLLocationDistance = 20
|
||||||
|
private let overlapSpreadRadius: CLLocationDistance = 12
|
||||||
#endif
|
#endif
|
||||||
/// 最新位置(供 MQTT 定时上报)
|
/// 最新位置(供 MQTT 定时上报)
|
||||||
private var lastLocation: CLLocation?
|
private var lastLocation: CLLocation?
|
||||||
|
|
@ -54,6 +58,8 @@ class HomeViewController: BaseViewController {
|
||||||
private var subscribedMemberIds: [String] = []
|
private var subscribedMemberIds: [String] = []
|
||||||
/// 底部成员面板是否显示
|
/// 底部成员面板是否显示
|
||||||
private var isMemberPanelShown = false
|
private var isMemberPanelShown = false
|
||||||
|
/// 地图是否自动跟随当前用户;滑动查看后关闭,点击定位按钮后重新打开
|
||||||
|
private var isAutoFollowingUser = true
|
||||||
private var isSOSPlaying = false
|
private var isSOSPlaying = false
|
||||||
private var sosReceiveAlertView: SOSReceiveAlertView?
|
private var sosReceiveAlertView: SOSReceiveAlertView?
|
||||||
private var sosAudioStopWorkItem: DispatchWorkItem?
|
private var sosAudioStopWorkItem: DispatchWorkItem?
|
||||||
|
|
@ -116,42 +122,61 @@ class HomeViewController: BaseViewController {
|
||||||
|
|
||||||
private func startLocationTimer() {
|
private func startLocationTimer() {
|
||||||
locationTimer?.invalidate()
|
locationTimer?.invalidate()
|
||||||
locationTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
let timer = Timer(timeInterval: 5, repeats: true) { [weak self] _ in
|
||||||
guard let self = self, let loc = self.lastLocation else { return }
|
self?.reportCurrentLocationIfNeeded()
|
||||||
let coord = loc.coordinate
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
locationTimer = timer
|
||||||
|
reportCurrentLocationIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
// 坐标变化不大时复用上次地址
|
private func reportCurrentLocationIfNeeded() {
|
||||||
if let last = self.lastGeocodedCoord,
|
guard !AppContextManager.shared.userId.isEmpty,
|
||||||
hypot(coord.latitude - last.latitude, coord.longitude - last.longitude) < 0.001,
|
let loc = lastLocation,
|
||||||
!self.lastAddress.isEmpty {
|
CLLocationCoordinate2DIsValid(loc.coordinate) else { return }
|
||||||
self.publishLocation(coord: coord, address: self.lastAddress, loc: loc)
|
let coord = loc.coordinate
|
||||||
return
|
publishLocation(coord: coord, address: lastAddress, loc: loc)
|
||||||
}
|
refreshAddressIfNeeded(coord: coord, loc: loc)
|
||||||
|
}
|
||||||
|
|
||||||
// 反向地理编码获取地址
|
private func refreshAddressIfNeeded(coord: CLLocationCoordinate2D, loc: CLLocation) {
|
||||||
self.geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, error in
|
if let last = lastGeocodedCoord,
|
||||||
guard let self = self else { return }
|
hypot(coord.latitude - last.latitude, coord.longitude - last.longitude) < 0.001 {
|
||||||
let address = placemarks?.first?.name
|
return
|
||||||
?? placemarks?.first?.thoroughfare
|
}
|
||||||
?? placemarks?.first?.locality
|
geocoder.cancelGeocode()
|
||||||
?? ""
|
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||||
self.lastAddress = address
|
let address = placemarks?.first?.name
|
||||||
|
?? placemarks?.first?.thoroughfare
|
||||||
|
?? placemarks?.first?.locality
|
||||||
|
?? ""
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self else { return }
|
||||||
|
if !address.isEmpty {
|
||||||
|
self.lastAddress = address
|
||||||
|
}
|
||||||
self.lastGeocodedCoord = coord
|
self.lastGeocodedCoord = coord
|
||||||
self.publishLocation(coord: coord, address: address, loc: loc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func publishLocation(coord: CLLocationCoordinate2D, address: String, loc: CLLocation) {
|
private func publishLocation(coord: CLLocationCoordinate2D, address: String, loc: CLLocation) {
|
||||||
MQTTService.shared.reportLocation(
|
let work = {
|
||||||
lat: coord.latitude,
|
MQTTService.shared.reportLocation(
|
||||||
lon: coord.longitude,
|
lat: coord.latitude,
|
||||||
addr: address,
|
lon: coord.longitude,
|
||||||
speed: loc.speed,
|
addr: address,
|
||||||
bearing: loc.course,
|
speed: loc.speed,
|
||||||
altitude: loc.altitude,
|
bearing: loc.course,
|
||||||
accuracy: loc.horizontalAccuracy
|
altitude: loc.altitude,
|
||||||
)
|
accuracy: loc.horizontalAccuracy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if Thread.isMainThread {
|
||||||
|
work()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async(execute: work)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
@ -242,16 +267,23 @@ class HomeViewController: BaseViewController {
|
||||||
self?.openTodayTrackDetail()
|
self?.openTodayTrackDetail()
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
|
// 实时互动
|
||||||
|
rootView.groupMemberView.emojiCard.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
||||||
|
self?.openEmojiPopView()
|
||||||
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
|
rootView.groupMemberView.messageCard.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
||||||
|
self?.openMessagePopView()
|
||||||
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
// 手机报告二级页
|
// 手机报告二级页
|
||||||
rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
rootView.groupMemberView.phoneReportView.rx.tapGesture.subscribe(onNext: { [weak self] _ in
|
||||||
self?.openPhoneReportDetail()
|
self?.openPhoneReportDetail()
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
|
|
||||||
// 地图回到自己
|
// 地图回到自己并重新开启自动跟随
|
||||||
rootView.locationView.rx.tapGesture.subscribe { _ in
|
rootView.locationView.rx.tapGesture.subscribe { _ in
|
||||||
if let ann = self.currentUserAnnotation {
|
self.startFollowingCurrentUser()
|
||||||
self.selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false)
|
|
||||||
}
|
|
||||||
}.disposed(by: disposeBag)
|
}.disposed(by: disposeBag)
|
||||||
|
|
||||||
// User Config刷新
|
// User Config刷新
|
||||||
|
|
@ -612,8 +644,20 @@ class HomeViewController: BaseViewController {
|
||||||
|
|
||||||
// 其他成员从 select_group_employee 取,过滤掉当前用户
|
// 其他成员从 select_group_employee 取,过滤掉当前用户
|
||||||
var others: [CircleMember] = []
|
var others: [CircleMember] = []
|
||||||
|
let now = Date()
|
||||||
for model in list where model.user_id != currentUserId || currentUserId.isEmpty {
|
for model in list where model.user_id != currentUserId || currentUserId.isEmpty {
|
||||||
let m = CircleMember(member: model, isOwner: isGroupOwner(model.user_id))
|
var m = CircleMember(member: model, isOwner: isGroupOwner(model.user_id))
|
||||||
|
let mqttFresh = lastUpdateTimes[model.user_id].map { now.timeIntervalSince($0) < 60 } ?? false
|
||||||
|
if mqttFresh, let trueCoord = trueCoordinateByUserId[model.user_id],
|
||||||
|
CLLocationCoordinate2DIsValid(trueCoord) {
|
||||||
|
m = CircleMember(
|
||||||
|
id: m.id, name: m.name, avatar: m.avatar,
|
||||||
|
isOnline: true, isOwner: m.isOwner,
|
||||||
|
coordinate: trueCoord, address: m.address,
|
||||||
|
heading: m.heading, lastUpdateTime: m.lastUpdateTime,
|
||||||
|
battery: m.battery
|
||||||
|
)
|
||||||
|
}
|
||||||
guard CLLocationCoordinate2DIsValid(m.coordinate) else { continue }
|
guard CLLocationCoordinate2DIsValid(m.coordinate) else { continue }
|
||||||
others.append(m)
|
others.append(m)
|
||||||
}
|
}
|
||||||
|
|
@ -637,7 +681,12 @@ class HomeViewController: BaseViewController {
|
||||||
}
|
}
|
||||||
let onlineIDs = Set(onlineMembers.map { $0.id })
|
let onlineIDs = Set(onlineMembers.map { $0.id })
|
||||||
|
|
||||||
let toRemove = allExisting.filter { !onlineIDs.contains($0.member.id) }
|
let toRemove = allExisting.filter { annotation in
|
||||||
|
if annotation === currentUserAnnotation || annotation.member.isCurrentUser || annotation.member.id == "current" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !onlineIDs.contains(annotation.member.id)
|
||||||
|
}
|
||||||
if !toRemove.isEmpty {
|
if !toRemove.isEmpty {
|
||||||
mapView.removeAnnotations(toRemove)
|
mapView.removeAnnotations(toRemove)
|
||||||
}
|
}
|
||||||
|
|
@ -657,30 +706,10 @@ class HomeViewController: BaseViewController {
|
||||||
mapView.addAnnotations(annotations)
|
mapView.addAnnotations(annotations)
|
||||||
annotations.forEach { rememberAnnotation($0) }
|
annotations.forEach { rememberAnnotation($0) }
|
||||||
}
|
}
|
||||||
|
// 自己的针只等高德 didUpdate 再加,不用苹果 GPS / 缓存点
|
||||||
if currentUserAnnotation == nil,
|
applyDisplayCoordinates()
|
||||||
annotationByUserId["current"] == nil,
|
refreshAnnotationScreenPositions()
|
||||||
let me = currentUserMember,
|
bringAnnotationToFront(userId: selectedMemberId)
|
||||||
let loc = lastLocation,
|
|
||||||
CLLocationCoordinate2DIsValid(loc.coordinate),
|
|
||||||
!isMemberInBubble("current") {
|
|
||||||
let updatedMe = CircleMember(
|
|
||||||
id: "current",
|
|
||||||
name: me.name,
|
|
||||||
avatar: me.avatar,
|
|
||||||
isOnline: true,
|
|
||||||
isOwner: false,
|
|
||||||
coordinate: loc.coordinate,
|
|
||||||
address: me.address,
|
|
||||||
heading: me.heading,
|
|
||||||
lastUpdateTime: me.lastUpdateTime,
|
|
||||||
battery: me.battery
|
|
||||||
)
|
|
||||||
currentUserMember = updatedMe
|
|
||||||
let annotation = MemberAnnotation(member: updatedMe)
|
|
||||||
mapView.addAnnotation(annotation)
|
|
||||||
rememberAnnotation(annotation)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -708,6 +737,70 @@ class HomeViewController: BaseViewController {
|
||||||
AppRouter.push(Route.todayTrackDetail, userInfo: userInfo)
|
AppRouter.push(Route.todayTrackDetail, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func openEmojiPopView() {
|
||||||
|
AddEmojiPopView.show { [weak self] name in
|
||||||
|
guard let self, let name else { return }
|
||||||
|
self.sendRealtimeEmoji(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openMessagePopView() {
|
||||||
|
AddMessagePopView.show { [weak self] index in
|
||||||
|
guard let self, let index else { return }
|
||||||
|
self.sendRealtimeMessage(index: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRealtimeEmoji(_ name: String) {
|
||||||
|
guard name.hasPrefix("normal_") else { return }
|
||||||
|
let numStr = name.replacingOccurrences(of: "normal_", with: "")
|
||||||
|
guard let num = Int(numStr) else { return }
|
||||||
|
let targetId = selectedMemberId
|
||||||
|
sendRealtimeEmote(emoteIdx: "1\(num)".integer) { [weak self] in
|
||||||
|
self?.rootView.groupMemberView.applyEmojiEcho(name: name, for: targetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRealtimeMessage(index: Int) {
|
||||||
|
let texts = QuickMessageView.messageList
|
||||||
|
guard texts.indices.contains(index) else { return }
|
||||||
|
let targetId = selectedMemberId
|
||||||
|
sendRealtimeEmote(emoteIdx: "3\(index)".integer) { [weak self] in
|
||||||
|
self?.rootView.groupMemberView.applyMessageEcho(text: texts[index], for: targetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRealtimeEmote(emoteIdx: Int, onSuccess: @escaping () -> Void) {
|
||||||
|
guard selectedMemberId != AppContextManager.shared.userId else {
|
||||||
|
DLToast.show(text: "不能给自己发送")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let model = viewModel.groupModel else { return }
|
||||||
|
DLToast.showLoading()
|
||||||
|
UserService.sendEmote(
|
||||||
|
emoteIdx: emoteIdx,
|
||||||
|
groupKey: model.default_group_key,
|
||||||
|
targetUid: selectedMemberId
|
||||||
|
).subscribe(onNext: { _ in
|
||||||
|
DLToast.show(text: "发送成功")
|
||||||
|
onSuccess()
|
||||||
|
}, onError: { _ in }).disposed(by: disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyIncomingEmote(from userId: String, emojiName: String?, message: String?) {
|
||||||
|
if let emojiName {
|
||||||
|
rootView.groupMemberView.applyEmojiEcho(name: emojiName, for: userId)
|
||||||
|
ReceivedEmojiPopView.show(name: emojiName)
|
||||||
|
}
|
||||||
|
if let message {
|
||||||
|
rootView.groupMemberView.applyMessageEcho(text: message, for: userId)
|
||||||
|
ReceivedMessagePopView.showIncoming(
|
||||||
|
nickName: viewModel.getUserNickName(id: userId),
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func openPhoneReportDetail() {
|
private func openPhoneReportDetail() {
|
||||||
let members = viewModel.memberList.map { $0.toJSON() }
|
let members = viewModel.memberList.map { $0.toJSON() }
|
||||||
var userInfo: [String: Any] = [
|
var userInfo: [String: Any] = [
|
||||||
|
|
@ -740,13 +833,40 @@ class HomeViewController: BaseViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !targetEnvironment(simulator)
|
// if dismissPanel {
|
||||||
if dismissPanel {
|
// rootView.dismissGroupMemberView()
|
||||||
rootView.dismissGroupMemberView()
|
// }
|
||||||
}
|
|
||||||
if centerMap {
|
if centerMap {
|
||||||
|
let isSelf = userId == "current" || viewModel.isCurrentUser(id: userId)
|
||||||
|
if !isSelf {
|
||||||
|
stopFollowingCurrentUser()
|
||||||
|
}
|
||||||
centerMapOnMember(userId: userId)
|
centerMapOnMember(userId: userId)
|
||||||
}
|
}
|
||||||
|
#if !targetEnvironment(simulator)
|
||||||
|
bringAnnotationToFront(userId: listUserId, restack: true)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startFollowingCurrentUser() {
|
||||||
|
isAutoFollowingUser = true
|
||||||
|
#if !targetEnvironment(simulator)
|
||||||
|
if let ann = currentUserAnnotation {
|
||||||
|
selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false)
|
||||||
|
} else if let coordinate = lastLocation?.coordinate, CLLocationCoordinate2DIsValid(coordinate) {
|
||||||
|
rootView.mapView.setCenter(coordinate, animated: true)
|
||||||
|
rootView.mapView.setZoomLevel(16, animated: true)
|
||||||
|
}
|
||||||
|
rootView.mapView.setUserTrackingMode(.follow, animated: true)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopFollowingCurrentUser() {
|
||||||
|
guard isAutoFollowingUser else { return }
|
||||||
|
isAutoFollowingUser = false
|
||||||
|
#if !targetEnvironment(simulator)
|
||||||
|
rootView.mapView.setUserTrackingMode(.none, animated: false)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -843,6 +963,9 @@ extension HomeViewController {
|
||||||
isOnline: isOnline, lastUpdateTime: firstPoint.time
|
isOnline: isOnline, lastUpdateTime: firstPoint.time
|
||||||
)
|
)
|
||||||
updateOnlineCount()
|
updateOnlineCount()
|
||||||
|
self.refreshSelectedMemberInfoIfNeeded(userId: userId)
|
||||||
|
// 自己的地图点由 GPS 维护(id = "current"),MQTT 回环不再加点,避免叠两个自己
|
||||||
|
if userId == AppContextManager.shared.userId { return }
|
||||||
// 经纬度无变化跳过地图标注更新
|
// 经纬度无变化跳过地图标注更新
|
||||||
let shouldUpdateMap: Bool = {
|
let shouldUpdateMap: Bool = {
|
||||||
if let existing = self.members.first(where: { $0.id == userId }) {
|
if let existing = self.members.first(where: { $0.id == userId }) {
|
||||||
|
|
@ -858,7 +981,6 @@ extension HomeViewController {
|
||||||
} else if !isOnline {
|
} else if !isOnline {
|
||||||
self.removeAnnotation(userId: userId)
|
self.removeAnnotation(userId: userId)
|
||||||
}
|
}
|
||||||
self.refreshSelectedMemberInfoIfNeeded(userId: userId)
|
|
||||||
}
|
}
|
||||||
case "disconnect": // 离线
|
case "disconnect": // 离线
|
||||||
self.removeAnnotation(userId: userId)
|
self.removeAnnotation(userId: userId)
|
||||||
|
|
@ -876,47 +998,18 @@ extension HomeViewController {
|
||||||
let emoteUserId = parts[1]
|
let emoteUserId = parts[1]
|
||||||
|
|
||||||
if firstChar == "1" || firstChar == "2" { // 表情
|
if firstChar == "1" || firstChar == "2" { // 表情
|
||||||
// let emojiFileName = firstChar == "1" ? "normal_" : "fun_"
|
let emojiFileName = firstChar == "1" ? "normal_\(lastChar)" : "fun_\(lastChar)"
|
||||||
let nickName = self.viewModel.getUserNickName(id: emoteUserId)
|
DispatchQueue.main.async { [weak self] in
|
||||||
let fullText = nickName + "对你发送了表情"
|
self?.applyIncomingEmote(from: emoteUserId, emojiName: emojiFileName, message: nil)
|
||||||
let attr = NSMutableAttributedString(string: fullText)
|
}
|
||||||
attr.addAttribute(.font, value: UIFont.systemFont(ofSize: 12, weight: .medium), range: NSRange(location: 0, length: fullText.count))
|
|
||||||
attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: NSRange(location: 0, length: nickName.count))
|
|
||||||
// DispatchQueue.main.async { [weak self] in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.rootView.messageLab.attributedText = attr
|
|
||||||
// self.rootView.messageBubbleView.isHidden = false
|
|
||||||
// if let path = Bundle.main.path(forResource: "\(emojiFileName)\(lastChar)", ofType: "json") {
|
|
||||||
// self.rootView.emojiPopView.animation = LottieAnimation.filepath(path)
|
|
||||||
// self.rootView.bringSubviewToFront(self.rootView.emojiPopView)
|
|
||||||
// self.rootView.emojiPopView.isHidden = false
|
|
||||||
// self.rootView.emojiPopView.play { completed in
|
|
||||||
// self.rootView.emojiPopView.isHidden = true
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
|
|
||||||
// self.rootView.messageBubbleView.isHidden = true
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
else if firstChar == "3" { // 文字
|
else if firstChar == "3" { // 文字
|
||||||
let textIdx = lastChar.integer
|
let textIdx = lastChar.integer
|
||||||
let texts = QuickMessageView.messageList
|
let texts = QuickMessageView.messageList
|
||||||
guard textIdx < texts.count else { break }
|
guard textIdx < texts.count else { break }
|
||||||
let nickName = self.viewModel.getUserNickName(id: emoteUserId)
|
DispatchQueue.main.async { [weak self] in
|
||||||
let fullText = nickName + "对你说:" + texts[textIdx]
|
self?.applyIncomingEmote(from: emoteUserId, emojiName: nil, message: texts[textIdx])
|
||||||
let attr = NSMutableAttributedString(string: fullText)
|
}
|
||||||
attr.addAttribute(.font, value: UIFont.systemFont(ofSize: 12, weight: .medium), range: NSRange(location: 0, length: fullText.count))
|
|
||||||
attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: NSRange(location: 0, length: nickName.count))
|
|
||||||
attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#333333"), range: NSRange(location: nickName.count, length: texts[textIdx].count))
|
|
||||||
// DispatchQueue.main.async { [weak self] in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.rootView.messageLab.attributedText = attr
|
|
||||||
// self.rootView.messageBubbleView.isHidden = false
|
|
||||||
// DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
|
|
||||||
// self.rootView.messageBubbleView.isHidden = true
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
case "bubble": // 气泡
|
case "bubble": // 气泡
|
||||||
guard let userId = msg.data?.user_id else { return }
|
guard let userId = msg.data?.user_id else { return }
|
||||||
|
|
@ -1003,6 +1096,166 @@ extension HomeViewController {
|
||||||
currentUserAnnotation = annotation
|
currentUserAnnotation = annotation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加点后按当前 view 尺寸重投;follow 延后到下一帧再开,避免动画冻住旧投影
|
||||||
|
private func refreshAnnotationScreenPositions() {
|
||||||
|
guard let mapView = rootView.mapView else { return }
|
||||||
|
let shouldResumeFollow = isAutoFollowingUser
|
||||||
|
if mapView.userTrackingMode != .none {
|
||||||
|
mapView.setUserTrackingMode(.none, animated: false)
|
||||||
|
}
|
||||||
|
for annotation in mapView.annotations ?? [] {
|
||||||
|
guard let memberAnn = annotation as? MemberAnnotation else { continue }
|
||||||
|
let coord = memberAnn.coordinate
|
||||||
|
memberAnn.coordinate = coord
|
||||||
|
if let view = mapView.view(for: memberAnn) as? MemberAnnotationView {
|
||||||
|
view.applyAnchor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mapView.setCenter(mapView.centerCoordinate, animated: false)
|
||||||
|
guard shouldResumeFollow else { return }
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, self.isAutoFollowingUser else { return }
|
||||||
|
self.rootView.mapView.setUserTrackingMode(.follow, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metersBetween(_ a: CLLocationCoordinate2D, _ b: CLLocationCoordinate2D) -> CLLocationDistance {
|
||||||
|
CLLocation(latitude: a.latitude, longitude: a.longitude)
|
||||||
|
.distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func offsetCoordinate(_ coord: CLLocationCoordinate2D, meters: CLLocationDistance, bearing: Double) -> CLLocationCoordinate2D {
|
||||||
|
let earth: CLLocationDistance = 6_378_137
|
||||||
|
let latRad = coord.latitude * .pi / 180
|
||||||
|
let dLat = meters * cos(bearing) / earth * 180 / .pi
|
||||||
|
let dLon = meters * sin(bearing) / (earth * cos(latRad)) * 180 / .pi
|
||||||
|
return CLLocationCoordinate2D(latitude: coord.latitude + dLat, longitude: coord.longitude + dLon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 20 米内的针绕真实点散开;自己钉在 GPS,别人往外偏
|
||||||
|
private func applyDisplayCoordinates() {
|
||||||
|
guard let mapView = rootView.mapView else { return }
|
||||||
|
let annotations = (mapView.annotations ?? []).compactMap { $0 as? MemberAnnotation }
|
||||||
|
guard !annotations.isEmpty else { return }
|
||||||
|
|
||||||
|
struct Pin {
|
||||||
|
let id: String
|
||||||
|
let isSelf: Bool
|
||||||
|
let trueCoord: CLLocationCoordinate2D
|
||||||
|
let annotation: MemberAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
|
var pins: [Pin] = []
|
||||||
|
for annotation in annotations {
|
||||||
|
let isSelf = annotation.member.isCurrentUser
|
||||||
|
let key = isSelf ? "current" : annotation.member.id
|
||||||
|
let trueCoord: CLLocationCoordinate2D
|
||||||
|
if let stored = trueCoordinateByUserId[key], CLLocationCoordinate2DIsValid(stored) {
|
||||||
|
trueCoord = stored
|
||||||
|
} else if isSelf, let loc = lastLocation?.coordinate, CLLocationCoordinate2DIsValid(loc) {
|
||||||
|
trueCoord = loc
|
||||||
|
} else if let member = members.first(where: { $0.id == annotation.member.id }),
|
||||||
|
CLLocationCoordinate2DIsValid(member.coordinate) {
|
||||||
|
trueCoord = member.coordinate
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pins.append(Pin(id: key, isSelf: isSelf, trueCoord: trueCoord, annotation: annotation))
|
||||||
|
}
|
||||||
|
|
||||||
|
var assigned = Set<Int>()
|
||||||
|
for i in pins.indices {
|
||||||
|
guard !assigned.contains(i) else { continue }
|
||||||
|
var cluster = [i]
|
||||||
|
assigned.insert(i)
|
||||||
|
var k = 0
|
||||||
|
while k < cluster.count {
|
||||||
|
let cur = cluster[k]
|
||||||
|
for j in pins.indices where !assigned.contains(j) {
|
||||||
|
if metersBetween(pins[cur].trueCoord, pins[j].trueCoord) < overlapClusterRadius {
|
||||||
|
cluster.append(j)
|
||||||
|
assigned.insert(j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if cluster.count == 1 {
|
||||||
|
pins[cluster[0]].annotation.coordinate = pins[cluster[0]].trueCoord
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let selfIdx = cluster.first(where: { pins[$0].isSelf })
|
||||||
|
let centroid: CLLocationCoordinate2D = {
|
||||||
|
if let selfIdx { return pins[selfIdx].trueCoord }
|
||||||
|
let lat = cluster.map { pins[$0].trueCoord.latitude }.reduce(0, +) / Double(cluster.count)
|
||||||
|
let lon = cluster.map { pins[$0].trueCoord.longitude }.reduce(0, +) / Double(cluster.count)
|
||||||
|
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if let selfIdx {
|
||||||
|
pins[selfIdx].annotation.coordinate = pins[selfIdx].trueCoord
|
||||||
|
let others = cluster.filter { !pins[$0].isSelf }.sorted { pins[$0].id < pins[$1].id }
|
||||||
|
for (i, idx) in others.enumerated() {
|
||||||
|
let angle = 2 * Double.pi * Double(i) / Double(max(others.count, 1)) - Double.pi / 2
|
||||||
|
pins[idx].annotation.coordinate = offsetCoordinate(centroid, meters: overlapSpreadRadius, bearing: angle)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let ordered = cluster.sorted { pins[$0].id < pins[$1].id }
|
||||||
|
for (i, idx) in ordered.enumerated() {
|
||||||
|
let angle = 2 * Double.pi * Double(i) / Double(ordered.count) - Double.pi / 2
|
||||||
|
pins[idx].annotation.coordinate = offsetCoordinate(centroid, meters: overlapSpreadRadius, bearing: angle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSelectedAnnotation(_ annotation: MemberAnnotation, userId: String) -> Bool {
|
||||||
|
let ids = annotationIds(matching: userId)
|
||||||
|
return ids.contains(annotation.member.id)
|
||||||
|
|| (annotation.member.isCurrentUser && (ids.contains("current") || ids.contains(AppContextManager.shared.userId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func raiseAnnotationView(_ view: MAAnnotationView) {
|
||||||
|
view.zIndex = 10
|
||||||
|
var current: UIView = view
|
||||||
|
while let superview = current.superview, !(superview is MAMapView) {
|
||||||
|
superview.bringSubviewToFront(current)
|
||||||
|
current = superview
|
||||||
|
}
|
||||||
|
current.superview?.bringSubviewToFront(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AMap 的 zIndex 只在 viewFor / didAdd 时生效;GPS 刷新自己的点还会把层级打乱。
|
||||||
|
/// restack=true 时先删再加,保证选中针在最上层。
|
||||||
|
private func bringAnnotationToFront(userId: String, restack: Bool = false) {
|
||||||
|
guard let mapView = rootView.mapView, !userId.isEmpty else { return }
|
||||||
|
var selectedAnn: MemberAnnotation?
|
||||||
|
for annotation in mapView.annotations ?? [] {
|
||||||
|
guard let memberAnn = annotation as? MemberAnnotation else { continue }
|
||||||
|
let selected = isSelectedAnnotation(memberAnn, userId: userId)
|
||||||
|
if selected { selectedAnn = memberAnn }
|
||||||
|
if let view = mapView.view(for: memberAnn) as? MemberAnnotationView {
|
||||||
|
if selected {
|
||||||
|
view.zIndex = 10
|
||||||
|
} else if memberAnn.member.isCurrentUser {
|
||||||
|
view.zIndex = 1
|
||||||
|
} else {
|
||||||
|
view.zIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let selectedAnn else { return }
|
||||||
|
if restack {
|
||||||
|
mapView.removeAnnotation(selectedAnn)
|
||||||
|
mapView.addAnnotation(selectedAnn)
|
||||||
|
rememberAnnotation(selectedAnn)
|
||||||
|
}
|
||||||
|
if let view = mapView.view(for: selectedAnn) {
|
||||||
|
raiseAnnotationView(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
/// 当前用户地图点 id 是 "current",MQTT 里是真实 user_id
|
/// 当前用户地图点 id 是 "current",MQTT 里是真实 user_id
|
||||||
private func annotationIds(matching userId: String) -> Set<String> {
|
private func annotationIds(matching userId: String) -> Set<String> {
|
||||||
|
|
@ -1062,43 +1315,56 @@ extension HomeViewController {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新指定成员的标注位置(只更新坐标,头像/名称不变)
|
/// 更新指定成员的标注位置(用 MQTT 坐标,不依赖接口 last_position)
|
||||||
private func updateAnnotation(userId: String, coordinate: CLLocationCoordinate2D, address: String) {
|
private func updateAnnotation(userId: String, coordinate: CLLocationCoordinate2D, address: String) {
|
||||||
#if !targetEnvironment(simulator)
|
#if !targetEnvironment(simulator)
|
||||||
if isMemberInBubble(userId) {
|
if isMemberInBubble(userId) {
|
||||||
removeAnnotation(userId: userId)
|
removeAnnotation(userId: userId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 更新 members 数组
|
guard CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||||||
if let idx = members.firstIndex(where: { $0.id == userId }) {
|
trueCoordinateByUserId[userId] = coordinate
|
||||||
let old = members[idx]
|
|
||||||
members[idx] = CircleMember(
|
|
||||||
id: old.id, name: old.name, avatar: old.avatar,
|
|
||||||
isOnline: true, isOwner: old.isOwner,
|
|
||||||
coordinate: coordinate, address: address,
|
|
||||||
heading: old.heading, lastUpdateTime: old.lastUpdateTime,
|
|
||||||
battery: old.battery
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新地图标注
|
|
||||||
guard let mapView = rootView.mapView else { return }
|
|
||||||
let memberToShow: CircleMember? = {
|
let memberToShow: CircleMember? = {
|
||||||
if let existing = members.first(where: { $0.id == userId }) {
|
if let idx = members.firstIndex(where: { $0.id == userId }) {
|
||||||
return existing
|
let old = members[idx]
|
||||||
|
let updated = CircleMember(
|
||||||
|
id: old.id, name: old.name, avatar: old.avatar,
|
||||||
|
isOnline: true, isOwner: old.isOwner,
|
||||||
|
coordinate: coordinate, address: address,
|
||||||
|
heading: old.heading, lastUpdateTime: old.lastUpdateTime,
|
||||||
|
battery: old.battery
|
||||||
|
)
|
||||||
|
members[idx] = updated
|
||||||
|
return updated
|
||||||
}
|
}
|
||||||
guard let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return nil }
|
guard let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return nil }
|
||||||
let created = CircleMember(member: model, isOwner: viewModel.isGroupOwn(id: model.user_id))
|
let created = CircleMember(
|
||||||
guard CLLocationCoordinate2DIsValid(created.coordinate) else { return nil }
|
id: model.user_id,
|
||||||
|
name: model.nick_name,
|
||||||
|
avatar: model.head_pic,
|
||||||
|
isOnline: true,
|
||||||
|
isOwner: viewModel.isGroupOwn(id: model.user_id),
|
||||||
|
coordinate: coordinate,
|
||||||
|
address: address,
|
||||||
|
heading: 0,
|
||||||
|
lastUpdateTime: model.last_active_time,
|
||||||
|
battery: model.battery
|
||||||
|
)
|
||||||
members.append(created)
|
members.append(created)
|
||||||
return created
|
return created
|
||||||
}()
|
}()
|
||||||
if let oldMember = memberToShow, oldMember.isOnline {
|
|
||||||
removeAnnotation(userId: userId)
|
guard let member = memberToShow, let mapView = rootView.mapView else { return }
|
||||||
let newAnn = MemberAnnotation(member: oldMember)
|
if let existing = annotationByUserId[userId] {
|
||||||
|
existing.coordinate = coordinate
|
||||||
|
} else {
|
||||||
|
let newAnn = MemberAnnotation(member: member)
|
||||||
mapView.addAnnotation(newAnn)
|
mapView.addAnnotation(newAnn)
|
||||||
rememberAnnotation(newAnn)
|
rememberAnnotation(newAnn)
|
||||||
}
|
}
|
||||||
|
applyDisplayCoordinates()
|
||||||
|
bringAnnotationToFront(userId: selectedMemberId)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1114,7 +1380,9 @@ extension HomeViewController: MAMapViewDelegate {
|
||||||
|
|
||||||
guard let memberAnnotation = annotation as? MemberAnnotation else { return nil }
|
guard let memberAnnotation = annotation as? MemberAnnotation else { return nil }
|
||||||
|
|
||||||
let identifier = "MemberAnnotation"
|
let identifier = memberAnnotation.member.isCurrentUser
|
||||||
|
? "MemberAnnotation.current"
|
||||||
|
: "MemberAnnotation.member"
|
||||||
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MemberAnnotationView
|
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MemberAnnotationView
|
||||||
if annotationView == nil {
|
if annotationView == nil {
|
||||||
annotationView = MemberAnnotationView(annotation: memberAnnotation, reuseIdentifier: identifier)
|
annotationView = MemberAnnotationView(annotation: memberAnnotation, reuseIdentifier: identifier)
|
||||||
|
|
@ -1131,10 +1399,25 @@ extension HomeViewController: MAMapViewDelegate {
|
||||||
if memberAnnotation.member.isCurrentUser {
|
if memberAnnotation.member.isCurrentUser {
|
||||||
annotationView?.updateHeading(currentHeading)
|
annotationView?.updateHeading(currentHeading)
|
||||||
}
|
}
|
||||||
|
let selectedIds = annotationIds(matching: selectedMemberId)
|
||||||
|
if selectedIds.contains(memberAnnotation.member.id)
|
||||||
|
|| (memberAnnotation.member.isCurrentUser && (selectedIds.contains("current") || selectedIds.contains(AppContextManager.shared.userId))) {
|
||||||
|
annotationView?.zIndex = 10
|
||||||
|
} else if memberAnnotation.member.isCurrentUser {
|
||||||
|
annotationView?.zIndex = 1
|
||||||
|
} else {
|
||||||
|
annotationView?.zIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
return annotationView
|
return annotationView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapView(_ mapView: MAMapView!, didAddAnnotationViews views: [Any]!) {
|
||||||
|
for case let view as MemberAnnotationView in views ?? [] {
|
||||||
|
view.applyAnchor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) {
|
func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) {
|
||||||
guard let ann = view.annotation as? MemberAnnotation else { return }
|
guard let ann = view.annotation as? MemberAnnotation else { return }
|
||||||
mapView.deselectAnnotation(ann, animated: false)
|
mapView.deselectAnnotation(ann, animated: false)
|
||||||
|
|
@ -1149,16 +1432,23 @@ extension HomeViewController: MAMapViewDelegate {
|
||||||
rootView.updateGPSSignal(bars: strength.barCount)
|
rootView.updateGPSSignal(bars: strength.barCount)
|
||||||
|
|
||||||
// 地图标注
|
// 地图标注
|
||||||
|
let isFirstFix = lastLocation == nil
|
||||||
lastLocation = location
|
lastLocation = location
|
||||||
|
if isFirstFix {
|
||||||
|
reportCurrentLocationIfNeeded()
|
||||||
|
}
|
||||||
Defaults[\.currentLatitude] = location.coordinate.latitude
|
Defaults[\.currentLatitude] = location.coordinate.latitude
|
||||||
Defaults[\.currentLongitude] = location.coordinate.longitude
|
Defaults[\.currentLongitude] = location.coordinate.longitude
|
||||||
let coordinate = location.coordinate
|
let coordinate = location.coordinate
|
||||||
guard CLLocationCoordinate2DIsValid(coordinate) else { return }
|
guard CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||||||
|
trueCoordinateByUserId["current"] = coordinate
|
||||||
|
|
||||||
if isMemberInBubble("current") {
|
if isMemberInBubble("current") {
|
||||||
removeAnnotation(userId: "current")
|
removeAnnotation(userId: "current")
|
||||||
} else if let ann = currentUserAnnotation {
|
} else if let ann = currentUserAnnotation {
|
||||||
ann.coordinate = coordinate
|
ann.coordinate = coordinate
|
||||||
|
applyDisplayCoordinates()
|
||||||
|
bringAnnotationToFront(userId: selectedMemberId)
|
||||||
} else if let me = currentUserMember {
|
} else if let me = currentUserMember {
|
||||||
let updatedMe = CircleMember(
|
let updatedMe = CircleMember(
|
||||||
id: "current",
|
id: "current",
|
||||||
|
|
@ -1176,13 +1466,19 @@ extension HomeViewController: MAMapViewDelegate {
|
||||||
let annotation = MemberAnnotation(member: updatedMe)
|
let annotation = MemberAnnotation(member: updatedMe)
|
||||||
mapView.addAnnotation(annotation)
|
mapView.addAnnotation(annotation)
|
||||||
rememberAnnotation(annotation)
|
rememberAnnotation(annotation)
|
||||||
|
applyDisplayCoordinates()
|
||||||
|
bringAnnotationToFront(userId: selectedMemberId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMemberPanelShown {
|
if isAutoFollowingUser, mapView.userTrackingMode != .follow {
|
||||||
mapView.setCenter(coordinate, animated: true)
|
mapView.setUserTrackingMode(.follow, animated: false)
|
||||||
mapView.setUserTrackingMode(.follow, animated: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapView(_ mapView: MAMapView!, mapWillMoveByUser wasUserAction: Bool) {
|
||||||
|
guard wasUserAction else { return }
|
||||||
|
stopFollowingCurrentUser()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SOS 接收提示和报警音
|
// MARK: - SOS 接收提示和报警音
|
||||||
|
|
@ -1284,24 +1580,6 @@ extension HomeViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 快捷消息 & 表情
|
|
||||||
extension HomeViewController: TTGTextTagCollectionViewDelegate {
|
|
||||||
func textTagCollectionView(_ textTagCollectionView: TTGTextTagCollectionView!, didTap tag: TTGTextTag!, at index: UInt) {
|
|
||||||
let emoteIdx = "3\(index)".integer
|
|
||||||
requestSendEmote(emoteIdx: emoteIdx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestSendEmote(emoteIdx: Int) {
|
|
||||||
guard let model = viewModel.groupModel else { return }
|
|
||||||
DLToast.showLoading()
|
|
||||||
UserService.sendEmote(emoteIdx: emoteIdx,
|
|
||||||
groupKey: model.default_group_key,
|
|
||||||
targetUid: viewModel.targetUid).subscribe(onNext: { response in
|
|
||||||
DLToast.show(text: "发送成功")
|
|
||||||
}, onError: { _ in }).disposed(by: disposeBag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
#if !targetEnvironment(simulator)
|
#if !targetEnvironment(simulator)
|
||||||
// MARK: - CLLocationManagerDelegate (heading only)
|
// MARK: - CLLocationManagerDelegate (heading only)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,13 @@ final class InteractionEmojiCell: UICollectionViewCell {
|
||||||
static let normalFileNames: [String] = normalPngMap.keys.sorted()
|
static let normalFileNames: [String] = normalPngMap.keys.sorted()
|
||||||
static let funFileNames: [String] = funPngMap.keys.sorted()
|
static let funFileNames: [String] = funPngMap.keys.sorted()
|
||||||
|
|
||||||
|
static func pngImage(named name: String) -> UIImage? {
|
||||||
|
let map = name.hasPrefix("fun_") ? funPngMap : normalPngMap
|
||||||
|
guard let pngName = map[name],
|
||||||
|
let path = Bundle.main.path(forResource: pngName, ofType: "png") else { return nil }
|
||||||
|
return UIImage(contentsOfFile: path)
|
||||||
|
}
|
||||||
|
|
||||||
private let imageView: UIImageView = {
|
private let imageView: UIImageView = {
|
||||||
let v = UIImageView()
|
let v = UIImageView()
|
||||||
v.contentMode = .scaleAspectFit
|
v.contentMode = .scaleAspectFit
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ final class MemberPhoneReportView: UIView {
|
||||||
static let leftPanelAspect: CGFloat = 147 / 164
|
static let leftPanelAspect: CGFloat = 147 / 164
|
||||||
static let panelGap: CGFloat = 13
|
static let panelGap: CGFloat = 13
|
||||||
static let cardInset: CGFloat = 12
|
static let cardInset: CGFloat = 12
|
||||||
static let usageCardAspect: CGFloat = 69 / 146
|
|
||||||
static let usageUnlockGap: CGFloat = 9
|
static let usageUnlockGap: CGFloat = 9
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,7 +161,6 @@ final class MemberPhoneReportView: UIView {
|
||||||
usageCard.layoutChain
|
usageCard.layoutChain
|
||||||
.top()
|
.top()
|
||||||
.edgesHorzontal()
|
.edgesHorzontal()
|
||||||
.heightToWidth(Layout.usageCardAspect)
|
|
||||||
|
|
||||||
usageIcon.image = UIImage(named: "Home/member_usage_count")
|
usageIcon.image = UIImage(named: "Home/member_usage_count")
|
||||||
usageIcon.contentMode = .scaleAspectFit
|
usageIcon.contentMode = .scaleAspectFit
|
||||||
|
|
@ -204,7 +202,7 @@ final class MemberPhoneReportView: UIView {
|
||||||
unlockCard.layoutChain
|
unlockCard.layoutChain
|
||||||
.topToBottomOfView(usageCard, offset: Layout.usageUnlockGap)
|
.topToBottomOfView(usageCard, offset: Layout.usageUnlockGap)
|
||||||
.edgesHorzontal()
|
.edgesHorzontal()
|
||||||
.heightToWidth(Layout.usageCardAspect)
|
.heightToView(usageCard)
|
||||||
.bottom()
|
.bottom()
|
||||||
|
|
||||||
unlockIcon.image = UIImage(named: "Home/member_daily_unlock")
|
unlockIcon.image = UIImage(named: "Home/member_daily_unlock")
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ final class SearchLocationHeaderView: UIView {
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
func startMarqueeAnimation() {
|
func startMarqueeAnimation() {
|
||||||
|
marqueeScrollView.isHidden = true
|
||||||
|
return
|
||||||
|
|
||||||
marqueeScrollView.layer.removeAllAnimations()
|
marqueeScrollView.layer.removeAllAnimations()
|
||||||
marqueeScrollView.contentOffset.x = 0
|
marqueeScrollView.contentOffset.x = 0
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
|
@ -46,6 +49,7 @@ final class SearchLocationHeaderView: UIView {
|
||||||
addSubview(marqueeScrollView)
|
addSubview(marqueeScrollView)
|
||||||
|
|
||||||
heroImageView.backgroundColor = .clear
|
heroImageView.backgroundColor = .clear
|
||||||
|
heroImageView.contentMode = .scaleAspectFit
|
||||||
heroImageView.image = UIImage(named: "SearchLocation/header_bg")
|
heroImageView.image = UIImage(named: "SearchLocation/header_bg")
|
||||||
heroImageView.layoutChain
|
heroImageView.layoutChain
|
||||||
.top()
|
.top()
|
||||||
|
|
@ -54,18 +58,16 @@ final class SearchLocationHeaderView: UIView {
|
||||||
.height(190)
|
.height(190)
|
||||||
|
|
||||||
let count = Int.random(in: 8000...15000)
|
let count = Int.random(in: 8000...15000)
|
||||||
let usageText = "有 \(count) 人正在使用此功能"
|
let formatter = NumberFormatter()
|
||||||
let attr = NSMutableAttributedString(string: usageText)
|
formatter.numberStyle = .decimal
|
||||||
if let range = usageText.range(of: "\(count)") {
|
formatter.groupingSeparator = ","
|
||||||
attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"),
|
let countText = formatter.string(from: NSNumber(value: count)) ?? "\(count)"
|
||||||
range: NSRange(range, in: usageText))
|
usageLab.text = "有\(countText)人正在使用此功能"
|
||||||
}
|
|
||||||
usageLab.attributedText = attr
|
|
||||||
usageLab.font = .systemFont(ofSize: 14, weight: .medium)
|
usageLab.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
usageLab.textColor = UIColor(hexStr: "#999999")
|
usageLab.textColor = UIColor(hexStr: "#999999")
|
||||||
usageLab.textAlignment = .center
|
usageLab.textAlignment = .center
|
||||||
usageLab.layoutChain
|
usageLab.layoutChain
|
||||||
.topToBottomOfView(heroImageView, offset: 8)
|
.topToBottomOfView(heroImageView, offset: 42)
|
||||||
.centerX()
|
.centerX()
|
||||||
|
|
||||||
headlineLab.text = "实时查看TA的位置"
|
headlineLab.text = "实时查看TA的位置"
|
||||||
|
|
@ -73,7 +75,7 @@ final class SearchLocationHeaderView: UIView {
|
||||||
headlineLab.textColor = UIColor(hexStr: "#293445")
|
headlineLab.textColor = UIColor(hexStr: "#293445")
|
||||||
headlineLab.textAlignment = .center
|
headlineLab.textAlignment = .center
|
||||||
headlineLab.layoutChain
|
headlineLab.layoutChain
|
||||||
.topToBottomOfView(usageLab, offset: 16)
|
.topToBottomOfView(usageLab, offset: 37)
|
||||||
.centerX()
|
.centerX()
|
||||||
|
|
||||||
subheadLab.text = "为你安心守护出行安全"
|
subheadLab.text = "为你安心守护出行安全"
|
||||||
|
|
@ -81,17 +83,14 @@ final class SearchLocationHeaderView: UIView {
|
||||||
subheadLab.textColor = UIColor(hexStr: "#293445")
|
subheadLab.textColor = UIColor(hexStr: "#293445")
|
||||||
subheadLab.textAlignment = .center
|
subheadLab.textAlignment = .center
|
||||||
subheadLab.layoutChain
|
subheadLab.layoutChain
|
||||||
.topToBottomOfView(headlineLab, offset: 6)
|
.topToBottomOfView(headlineLab, offset: 18)
|
||||||
.centerX()
|
.centerX()
|
||||||
|
.bottom()
|
||||||
|
|
||||||
|
marqueeScrollView.isHidden = true
|
||||||
marqueeScrollView.backgroundColor = .clear
|
marqueeScrollView.backgroundColor = .clear
|
||||||
marqueeScrollView.showsHorizontalScrollIndicator = false
|
marqueeScrollView.showsHorizontalScrollIndicator = false
|
||||||
marqueeScrollView.isScrollEnabled = false
|
marqueeScrollView.isScrollEnabled = false
|
||||||
marqueeScrollView.layoutChain
|
|
||||||
.topToBottomOfView(subheadLab, offset: 12)
|
|
||||||
.edgesHorzontal()
|
|
||||||
.height(36)
|
|
||||||
.bottom()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func randomPhoneNumber() -> String {
|
private func randomPhoneNumber() -> String {
|
||||||
|
|
|
||||||
|
|
@ -25,22 +25,37 @@ class SearchLocationResultVC: BaseViewController {
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
let isVip = AppContextManager.shared.vip > 1
|
|
||||||
rootView.unlockVipView.isHidden = isVip
|
|
||||||
rootView.scrollView.isHidden = !isVip
|
|
||||||
|
|
||||||
reactiveAction()
|
reactiveAction()
|
||||||
|
applyResultPresentation()
|
||||||
requestPhoneArea()
|
requestPhoneArea()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
rootView.startMarqueeAnimation()
|
rootView.startMarqueeAnimation()
|
||||||
|
applyResultPresentation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isVipUnlocked() -> Bool {
|
||||||
|
AppContextManager.shared.vip > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyResultPresentation() {
|
||||||
|
handleCode()
|
||||||
|
let isVip = isVipUnlocked()
|
||||||
|
rootView.areaBlurView.isHidden = isVip
|
||||||
|
if !isVip {
|
||||||
|
rootView.actionBtn.setTitle("开通会员", for: .normal)
|
||||||
|
rootView.actionBtn.isHidden = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reactiveAction() {
|
private func reactiveAction() {
|
||||||
rootView.actionBtn.rx.tap.subscribe(onNext: { _ in
|
rootView.actionBtn.rx.tap.subscribe(onNext: { _ in
|
||||||
|
guard self.isVipUnlocked() else {
|
||||||
|
AppRouter.push(Route.vipRecharge)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch self.code {
|
switch self.code {
|
||||||
case 0:
|
case 0:
|
||||||
NotificationCenter.default.post(name: .ShowMemberLocationNotification,
|
NotificationCenter.default.post(name: .ShowMemberLocationNotification,
|
||||||
|
|
@ -84,8 +99,6 @@ class SearchLocationResultVC: BaseViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleCode() {
|
private func handleCode() {
|
||||||
guard AppContextManager.shared.vip > 1 else { return }
|
|
||||||
|
|
||||||
rootView.actionBtn.isHidden = true
|
rootView.actionBtn.isHidden = true
|
||||||
rootView.inviteView.isHidden = true
|
rootView.inviteView.isHidden = true
|
||||||
|
|
||||||
|
|
@ -114,7 +127,9 @@ class SearchLocationResultVC: BaseViewController {
|
||||||
)
|
)
|
||||||
rootView.actionBtn.setTitle("邀请TA加入", for: .normal)
|
rootView.actionBtn.setTitle("邀请TA加入", for: .normal)
|
||||||
rootView.actionBtn.isHidden = false
|
rootView.actionBtn.isHidden = false
|
||||||
requestGroupInfo()
|
if groupModel == nil {
|
||||||
|
requestGroupInfo()
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -123,12 +138,10 @@ class SearchLocationResultVC: BaseViewController {
|
||||||
private func requestPhoneArea() {
|
private func requestPhoneArea() {
|
||||||
SystemService.phoneArea(phone: phone).subscribe(onNext: { response in
|
SystemService.phoneArea(phone: phone).subscribe(onNext: { response in
|
||||||
guard let data = response.data else { return }
|
guard let data = response.data else { return }
|
||||||
self.handleCode()
|
|
||||||
let province = data["Province"].safeString
|
let province = data["Province"].safeString
|
||||||
let city = data["City"].safeString
|
let city = data["City"].safeString
|
||||||
let area = "\(province)·\(city)"
|
self.rootView.phoneAreaLab.text = "\(province)·\(city)"
|
||||||
self.rootView.phoneAreaLab.text = area
|
self.applyResultPresentation()
|
||||||
self.rootView.unlockVipPhoneAreaLab.text = area
|
|
||||||
}).disposed(by: disposeBag)
|
}).disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,13 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import RxSwift
|
|
||||||
import RxCocoa
|
|
||||||
import Lottie
|
import Lottie
|
||||||
|
|
||||||
class SearchLocationResultView: UIView {
|
class SearchLocationResultView: UIView {
|
||||||
|
|
||||||
var disposeBag = DisposeBag()
|
|
||||||
|
|
||||||
let headerView = SearchLocationHeaderView()
|
let headerView = SearchLocationHeaderView()
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
addSubview(unlockVipView)
|
|
||||||
addSubview(navBgView)
|
addSubview(navBgView)
|
||||||
addSubview(navView)
|
addSubview(navView)
|
||||||
addSubview(scrollView)
|
addSubview(scrollView)
|
||||||
|
|
@ -28,8 +23,6 @@ class SearchLocationResultView: UIView {
|
||||||
.edges(excludingEdge: .bottom)
|
.edges(excludingEdge: .bottom)
|
||||||
.height(kNaviHeight)
|
.height(kNaviHeight)
|
||||||
|
|
||||||
unlockVipView.layoutChain.edges()
|
|
||||||
|
|
||||||
scrollView.layoutChain
|
scrollView.layoutChain
|
||||||
.topToBottomOfView(navView)
|
.topToBottomOfView(navView)
|
||||||
.edges(excludingEdge: .top)
|
.edges(excludingEdge: .top)
|
||||||
|
|
@ -104,11 +97,19 @@ class SearchLocationResultView: UIView {
|
||||||
locationRow.addSubview(pinIcon)
|
locationRow.addSubview(pinIcon)
|
||||||
locationRow.addSubview(phoneAreaLab)
|
locationRow.addSubview(phoneAreaLab)
|
||||||
|
|
||||||
pinIcon.layoutChain.left().top().bottom().width(18).height(18)
|
locationRow.addSubview(areaBlurView)
|
||||||
|
|
||||||
|
pinIcon.layoutChain.left().centerY().width(18).height(18)
|
||||||
phoneAreaLab.layoutChain
|
phoneAreaLab.layoutChain
|
||||||
.leftToRightOfView(pinIcon, offset: 6)
|
.leftToRightOfView(pinIcon, offset: 6)
|
||||||
.right()
|
.right()
|
||||||
.centerY(pinIcon)
|
.top()
|
||||||
|
.bottom()
|
||||||
|
areaBlurView.layoutChain
|
||||||
|
.topToView(phoneAreaLab, offset: -2)
|
||||||
|
.bottomToView(phoneAreaLab, offset: 2)
|
||||||
|
.leftToView(phoneAreaLab, offset: -4)
|
||||||
|
.rightToView(phoneAreaLab, offset: 4)
|
||||||
locationRow.layoutChain.top(20).centerX()
|
locationRow.layoutChain.top(20).centerX()
|
||||||
|
|
||||||
let pillContainer = UIView()
|
let pillContainer = UIView()
|
||||||
|
|
@ -152,6 +153,15 @@ class SearchLocationResultView: UIView {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
lazy var areaBlurView: UIVisualEffectView = {
|
||||||
|
let view = UIVisualEffectView(effect: UIBlurEffect(style: .light))
|
||||||
|
view.isUserInteractionEnabled = false
|
||||||
|
view.isHidden = true
|
||||||
|
view.cornerRadius = 4
|
||||||
|
view.clipsToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
lazy var statusPillLab: UILabel = {
|
lazy var statusPillLab: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = .systemFont(ofSize: 16, weight: .medium)
|
label.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
|
@ -241,65 +251,6 @@ class SearchLocationResultView: UIView {
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var unlockVipView: UIView = {
|
|
||||||
let view = UIView()
|
|
||||||
view.backgroundColor = .white
|
|
||||||
view.isHidden = true
|
|
||||||
|
|
||||||
let heroPlaceholder = UIImageView()
|
|
||||||
heroPlaceholder.backgroundColor = UIColor(hexStr: "#F5F5F5")
|
|
||||||
heroPlaceholder.cornerRadius = 16
|
|
||||||
view.addSubview(heroPlaceholder)
|
|
||||||
heroPlaceholder.layoutChain
|
|
||||||
.top(120)
|
|
||||||
.edgesHorzontal(40)
|
|
||||||
.height(200)
|
|
||||||
|
|
||||||
view.addSubview(unlockVipPhoneAreaLab)
|
|
||||||
unlockVipPhoneAreaLab.layoutChain
|
|
||||||
.topToBottomOfView(heroPlaceholder, offset: 16)
|
|
||||||
.centerX()
|
|
||||||
|
|
||||||
let unlockVipBtn = UIButton()
|
|
||||||
unlockVipBtn.setTitle("开通会员", for: .normal)
|
|
||||||
unlockVipBtn.setTitleColor(.white, for: .normal)
|
|
||||||
unlockVipBtn.titleLabel?.font = FontManager.boboBold(16)
|
|
||||||
unlockVipBtn.backgroundColor = UIColor(hexStr: "#16B3FF")
|
|
||||||
unlockVipBtn.cornerRadius = 25
|
|
||||||
unlockVipBtn.rx.tap.subscribe(onNext: { _ in
|
|
||||||
AppRouter.push(Route.vipRecharge)
|
|
||||||
}).disposed(by: disposeBag)
|
|
||||||
view.addSubview(unlockVipBtn)
|
|
||||||
unlockVipBtn.layoutChain
|
|
||||||
.edgesHorzontal(15)
|
|
||||||
.bottom(kSafeBottomMargin + 20)
|
|
||||||
.height(50)
|
|
||||||
|
|
||||||
let unlockVipTitleLab = UILabel()
|
|
||||||
unlockVipTitleLab.text = "再迈一步,你就能看到:"
|
|
||||||
unlockVipTitleLab.font = .systemFont(ofSize: 14, weight: .medium)
|
|
||||||
unlockVipTitleLab.textColor = UIColor(hexStr: "#16B3FF")
|
|
||||||
view.addSubview(unlockVipTitleLab)
|
|
||||||
unlockVipTitleLab.layoutChain
|
|
||||||
.bottomToTopOfView(unlockVipBtn, offset: -60)
|
|
||||||
.centerX()
|
|
||||||
|
|
||||||
return view
|
|
||||||
}()
|
|
||||||
|
|
||||||
lazy var unlockVipLottieView: LottieAnimationView = {
|
|
||||||
let view = LottieAnimationView(name: "phone_search_no_vip")
|
|
||||||
view.isHidden = true
|
|
||||||
return view
|
|
||||||
}()
|
|
||||||
|
|
||||||
lazy var unlockVipPhoneAreaLab: UILabel = {
|
|
||||||
let label = UILabel()
|
|
||||||
label.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
||||||
label.textColor = UIColor(hexStr: "#16B3FF")
|
|
||||||
return label
|
|
||||||
}()
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
backgroundColor = .white
|
backgroundColor = .white
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,9 @@ class SearchLocationView: UIView {
|
||||||
|
|
||||||
contentView.addSubview(searchInputView)
|
contentView.addSubview(searchInputView)
|
||||||
searchInputView.layoutChain
|
searchInputView.layoutChain
|
||||||
.topToBottomOfView(headerView, offset: 20)
|
.topToBottomOfView(headerView, offset: 45)
|
||||||
.edgesHorzontal(15)
|
.edgesHorzontal(15)
|
||||||
.bottom(kSafeBottomMargin + 30)
|
.bottom(kSafeBottomMargin + 30, relation: .greaterThanOrEqual)
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,8 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
canShowCallout = false
|
canShowCallout = false
|
||||||
isEnabled = true
|
isEnabled = true
|
||||||
isUserInteractionEnabled = true
|
isUserInteractionEnabled = true
|
||||||
|
// 不设 image 时高德按默认大头针锚点,自定义 bounds 会偏;给 1px 透明图让 SDK 用 bounds
|
||||||
|
image = Self.transparentPixel
|
||||||
|
|
||||||
addSubview(containerView)
|
addSubview(containerView)
|
||||||
containerView.isUserInteractionEnabled = true
|
containerView.isUserInteractionEnabled = true
|
||||||
|
|
@ -172,6 +174,20 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
layoutMarker()
|
layoutMarker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
showsNameTag = false
|
||||||
|
nameTagView.isHidden = true
|
||||||
|
nameLabel.text = nil
|
||||||
|
onTap = nil
|
||||||
|
layoutMarker()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 高德在 didAdd 后才吃 bounds/centerOffset,加点完成后再 layout 一次
|
||||||
|
func applyAnchor() {
|
||||||
|
layoutMarker()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func handleTap() {
|
@objc private func handleTap() {
|
||||||
guard let ann = annotation as? MemberAnnotation else { return }
|
guard let ann = annotation as? MemberAnnotation else { return }
|
||||||
onTap?(ann)
|
onTap?(ann)
|
||||||
|
|
@ -202,8 +218,6 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
|
|
||||||
bounds = CGRect(x: 0, y: 0, width: totalWidth, height: totalHeight)
|
bounds = CGRect(x: 0, y: 0, width: totalWidth, height: totalHeight)
|
||||||
containerView.frame = bounds
|
containerView.frame = bounds
|
||||||
|
|
||||||
// 色点中心对准 annotation 坐标(负 y 将视图上移)
|
|
||||||
centerOffset = CGPoint(x: 0, y: -(totalHeight / 2 - Self.dotOuterSize / 2))
|
centerOffset = CGPoint(x: 0, y: -(totalHeight / 2 - Self.dotOuterSize / 2))
|
||||||
|
|
||||||
if showsNameTag {
|
if showsNameTag {
|
||||||
|
|
@ -257,6 +271,15 @@ final class MemberAnnotationView: MAAnnotationView {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dot Color
|
// MARK: - Dot Color
|
||||||
|
private static let transparentPixel: UIImage = {
|
||||||
|
UIGraphicsBeginImageContextWithOptions(CGSize(width: 1, height: 1), false, 0)
|
||||||
|
UIColor.clear.setFill()
|
||||||
|
UIRectFill(CGRect(x: 0, y: 0, width: 1, height: 1))
|
||||||
|
let img = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
|
||||||
|
UIGraphicsEndImageContext()
|
||||||
|
return img
|
||||||
|
}()
|
||||||
|
|
||||||
private static func dotColor(for member: CircleMember) -> UIColor {
|
private static func dotColor(for member: CircleMember) -> UIColor {
|
||||||
if member.isCurrentUser {
|
if member.isCurrentUser {
|
||||||
return currentUserDotColor
|
return currentUserDotColor
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,8 @@ final class MinePhotoWallView: UIView {
|
||||||
daysBadge.layoutChain
|
daysBadge.layoutChain
|
||||||
.leftToRightOfView(daysPrefixLab, offset: 4)
|
.leftToRightOfView(daysPrefixLab, offset: 4)
|
||||||
.centerY()
|
.centerY()
|
||||||
|
.width(37)
|
||||||
|
.height(26)
|
||||||
daysSuffixLab.layoutChain
|
daysSuffixLab.layoutChain
|
||||||
.leftToRightOfView(daysBadge, offset: 4)
|
.leftToRightOfView(daysBadge, offset: 4)
|
||||||
.right()
|
.right()
|
||||||
|
|
@ -192,15 +194,31 @@ final class MinePhotoWallView: UIView {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var daysBadge: UILabel = {
|
private lazy var daysBadge: UIView = {
|
||||||
let label = PaddingLabel()
|
let view = UIView()
|
||||||
|
view.addSubview(daysBadgeImg)
|
||||||
|
view.addSubview(daysLab)
|
||||||
|
daysBadgeImg.layoutChain.edges()
|
||||||
|
daysLab.layoutChain
|
||||||
|
.left(3)
|
||||||
|
.right(3)
|
||||||
|
.centerY(view, offset: 3)
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var daysBadgeImg: UIImageView = {
|
||||||
|
let iv = UIImageView(image: UIImage(named: "Mine/circle_days"))
|
||||||
|
iv.contentMode = .scaleAspectFit
|
||||||
|
return iv
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var daysLab: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
label.font = .systemFont(ofSize: 12, weight: .bold)
|
label.font = .systemFont(ofSize: 12, weight: .bold)
|
||||||
label.textColor = UIColor(hexStr: "#16B3FF")
|
label.textColor = UIColor(hexStr: "#16B3FF")
|
||||||
label.backgroundColor = UIColor(hexStr: "#E3F6FF")
|
|
||||||
label.cornerRadius = 4
|
|
||||||
label.clipsToBounds = true
|
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)
|
label.adjustsFontSizeToFitWidth = true
|
||||||
|
label.minimumScaleFactor = 0.6
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -267,7 +285,7 @@ final class MinePhotoWallView: UIView {
|
||||||
daysRow.layoutChain
|
daysRow.layoutChain
|
||||||
.centerY(titleLab)
|
.centerY(titleLab)
|
||||||
.centerX()
|
.centerX()
|
||||||
.height(25)
|
.height(26)
|
||||||
|
|
||||||
idRow.layoutChain
|
idRow.layoutChain
|
||||||
.topToBottomOfView(titleLab, offset: 6)
|
.topToBottomOfView(titleLab, offset: 6)
|
||||||
|
|
@ -424,7 +442,7 @@ final class MinePhotoWallView: UIView {
|
||||||
} else {
|
} else {
|
||||||
titleLab.isHidden = true
|
titleLab.isHidden = true
|
||||||
daysRow.isHidden = false
|
daysRow.isHidden = false
|
||||||
daysBadge.text = model.joinDaysDisplay
|
daysLab.text = model.joinDaysDisplay
|
||||||
}
|
}
|
||||||
idLab.text = "ID:\(model.user_id)"
|
idLab.text = "ID:\(model.user_id)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,14 +133,23 @@ final class MoodStatusView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateGridLayout(for width: CGFloat) {
|
private func updateGridLayout(for width: CGFloat) {
|
||||||
let cardWidth = max(width - 32, 0)
|
let cardSideInset: CGFloat = 15
|
||||||
|
let cardWidth = gridCard.bounds.width > 1
|
||||||
|
? gridCard.bounds.width
|
||||||
|
: max(width - cardSideInset * 2, 0)
|
||||||
guard cardWidth > 0, abs(cardWidth - lastLayoutWidth) > 0.5 else { return }
|
guard cardWidth > 0, abs(cardWidth - lastLayoutWidth) > 0.5 else { return }
|
||||||
lastLayoutWidth = cardWidth
|
lastLayoutWidth = cardWidth
|
||||||
|
|
||||||
collectionLayout.itemSize = CGSize(width: 60, height: 60)
|
let columns: CGFloat = 4
|
||||||
collectionLayout.hSpacing = 20
|
let itemWidth: CGFloat = 60
|
||||||
|
let hSpacing: CGFloat = 20
|
||||||
|
let rowWidth = columns * itemWidth + (columns - 1) * hSpacing
|
||||||
|
let sideInset = max((cardWidth - rowWidth) / 2, 0)
|
||||||
|
|
||||||
|
collectionLayout.itemSize = CGSize(width: itemWidth, height: 60)
|
||||||
|
collectionLayout.hSpacing = hSpacing
|
||||||
collectionLayout.vSpacing = 10
|
collectionLayout.vSpacing = 10
|
||||||
collectionLayout.sectionInset = UIEdgeInsets(top: 23, left: 23, bottom: 0, right: 23)
|
collectionLayout.sectionInset = UIEdgeInsets(top: 23, left: sideInset, bottom: 0, right: sideInset)
|
||||||
collectionLayout.invalidateLayout()
|
collectionLayout.invalidateLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate {
|
||||||
private let imageEditorView = UIView()
|
private let imageEditorView = UIView()
|
||||||
private let voiceEditorView = UIView()
|
private let voiceEditorView = UIView()
|
||||||
private let selectedImageView = UIImageView()
|
private let selectedImageView = UIImageView()
|
||||||
|
private let selectedImageMaskView = UIView()
|
||||||
private let imagePlaceholderLabel = UILabel()
|
private let imagePlaceholderLabel = UILabel()
|
||||||
private let captionField = UITextField()
|
private let captionField = UITextField()
|
||||||
private let captionCountLabel = UILabel()
|
private let captionCountLabel = UILabel()
|
||||||
|
|
@ -108,10 +109,11 @@ final class PigeonMessageView: UIView, UITextFieldDelegate {
|
||||||
func setSelectedImage(_ image: UIImage?) {
|
func setSelectedImage(_ image: UIImage?) {
|
||||||
selectedImageView.image = image
|
selectedImageView.image = image
|
||||||
selectedImageView.isHidden = image == nil
|
selectedImageView.isHidden = image == nil
|
||||||
|
selectedImageMaskView.isHidden = image == nil
|
||||||
imagePlaceholderLabel.isHidden = image != nil
|
imagePlaceholderLabel.isHidden = image != nil
|
||||||
imageThumbnailView.image = image
|
imageThumbnailView.image = image
|
||||||
imageThumbnailButton.isHidden = image == nil
|
imageThumbnailButton.isHidden = image == nil
|
||||||
deleteImageButton.isHidden = image == nil
|
// deleteImageButton.isHidden = image == nil
|
||||||
addTile.alpha = 1
|
addTile.alpha = 1
|
||||||
updateAddTilePosition(hasImage: image != nil)
|
updateAddTilePosition(hasImage: image != nil)
|
||||||
}
|
}
|
||||||
|
|
@ -435,11 +437,16 @@ final class PigeonMessageView: UIView, UITextFieldDelegate {
|
||||||
imageEditorView.layer.cornerRadius = scaled(22)
|
imageEditorView.layer.cornerRadius = scaled(22)
|
||||||
imageEditorView.clipsToBounds = true
|
imageEditorView.clipsToBounds = true
|
||||||
|
|
||||||
|
selectedImageMaskView.backgroundColor = .black.withAlphaComponent(0.2)
|
||||||
|
selectedImageMaskView.isHidden = true
|
||||||
selectedImageView.contentMode = .scaleAspectFill
|
selectedImageView.contentMode = .scaleAspectFill
|
||||||
selectedImageView.clipsToBounds = true
|
selectedImageView.clipsToBounds = true
|
||||||
imageEditorView.addSubview(selectedImageView)
|
imageEditorView.addSubview(selectedImageView)
|
||||||
selectedImageView.layoutChain.edges()
|
selectedImageView.layoutChain.edges()
|
||||||
|
|
||||||
|
imageEditorView.addSubview(selectedImageMaskView)
|
||||||
|
selectedImageMaskView.layoutChain.edges()
|
||||||
|
|
||||||
imagePlaceholderLabel.text = "点击下方添加一张图片"
|
imagePlaceholderLabel.text = "点击下方添加一张图片"
|
||||||
imagePlaceholderLabel.font = .systemFont(ofSize: scaled(14), weight: .medium)
|
imagePlaceholderLabel.font = .systemFont(ofSize: scaled(14), weight: .medium)
|
||||||
imagePlaceholderLabel.textColor = UIColor(hexStr: "#9298A1")
|
imagePlaceholderLabel.textColor = UIColor(hexStr: "#9298A1")
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,24 @@ class PromotionalActivitiesView: UIView {
|
||||||
|
|
||||||
private func setupRx() {
|
private func setupRx() {
|
||||||
navView.onBackTapped = { VipWaivePopView.show() }
|
navView.onBackTapped = { VipWaivePopView.show() }
|
||||||
|
|
||||||
|
backBtn.rx.tap.subscribe(onNext: { _ in
|
||||||
|
AppRouter.shared.popOrDismiss()
|
||||||
|
})
|
||||||
|
.disposed(by: disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
addSubview(scrollView)
|
addSubview(scrollView)
|
||||||
|
addSubview(backBtn)
|
||||||
addSubview(navView)
|
addSubview(navView)
|
||||||
|
|
||||||
|
backBtn.layoutChain
|
||||||
|
.top(kStatusBarHeight + 5)
|
||||||
|
.left(15)
|
||||||
|
.width(36)
|
||||||
|
.height(36)
|
||||||
|
|
||||||
navView.layoutChain
|
navView.layoutChain
|
||||||
.edges(excludingEdge: .bottom)
|
.edges(excludingEdge: .bottom)
|
||||||
.height(kNaviHeight)
|
.height(kNaviHeight)
|
||||||
|
|
@ -44,6 +55,13 @@ class PromotionalActivitiesView: UIView {
|
||||||
|
|
||||||
lazy var navView = BaseNavigationView(title: "超值优惠")
|
lazy var navView = BaseNavigationView(title: "超值优惠")
|
||||||
|
|
||||||
|
lazy var backBtn: UIButton = {
|
||||||
|
let btn = UIButton(type: .custom)
|
||||||
|
btn.setImage(UIImage(named: "Common/back"), for: .normal)
|
||||||
|
btn.extendEdgeInsets = UIEdgeInsets(top: 54, left: 15, bottom: 100, right: 100)
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
lazy var scrollView: UIScrollView = {
|
lazy var scrollView: UIScrollView = {
|
||||||
let view = UIScrollView()
|
let view = UIScrollView()
|
||||||
view.backgroundColor = UIColor(hexStr: "#F6F9FB")
|
view.backgroundColor = UIColor(hexStr: "#F6F9FB")
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,7 @@ extension LocationPickerVC: MAMapViewDelegate {
|
||||||
} else {
|
} else {
|
||||||
view?.annotation = annotation
|
view?.annotation = annotation
|
||||||
}
|
}
|
||||||
view?.image = UIImage(named: "Schedule/location")
|
view?.image = UIImage(named: "IM/location_pin")
|
||||||
view?.centerOffset = CGPoint(x: 0, y: -30)
|
view?.centerOffset = CGPoint(x: 0, y: -30)
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ class CollectionHFlowLayout: UICollectionViewLayout {
|
||||||
var contentWidth: CGFloat = 0
|
var contentWidth: CGFloat = 0
|
||||||
var page = 0
|
var page = 0
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
private var pageWidth: CGFloat {
|
||||||
|
let width = collectionView?.bounds.width ?? 0
|
||||||
|
return width > 1 ? width : UIScreen.main.bounds.width
|
||||||
|
}
|
||||||
|
|
||||||
override func prepare() {
|
override func prepare() {
|
||||||
super.prepare()
|
super.prepare()
|
||||||
attributesArr = []
|
attributesArr = []
|
||||||
|
|
@ -29,6 +34,7 @@ class CollectionHFlowLayout: UICollectionViewLayout {
|
||||||
page = 0
|
page = 0
|
||||||
let pageTotal = rows * colums
|
let pageTotal = rows * colums
|
||||||
guard let itemCount = collectionView?.numberOfItems(inSection: 0) else { return }
|
guard let itemCount = collectionView?.numberOfItems(inSection: 0) else { return }
|
||||||
|
let pageWidth = self.pageWidth
|
||||||
|
|
||||||
for index in 0..<itemCount {
|
for index in 0..<itemCount {
|
||||||
|
|
||||||
|
|
@ -38,13 +44,17 @@ class CollectionHFlowLayout: UICollectionViewLayout {
|
||||||
page = index / pageTotal
|
page = index / pageTotal
|
||||||
|
|
||||||
var x = ((itemSize.width + hSpacing) * CGFloat((index % pageTotal % colums)) + sectionInset.left)
|
var x = ((itemSize.width + hSpacing) * CGFloat((index % pageTotal % colums)) + sectionInset.left)
|
||||||
x = x + CGFloat(page) * (collectionView?.bounds.width ?? UIScreen.main.bounds.width)
|
x = x + CGFloat(page) * pageWidth
|
||||||
let y = (itemSize.height + vSpacing) * CGFloat((index % pageTotal / colums)) + sectionInset.top
|
let y = (itemSize.height + vSpacing) * CGFloat((index % pageTotal / colums)) + sectionInset.top
|
||||||
attributes.frame = CGRect(origin: CGPoint(x: x, y: y), size: itemSize)
|
attributes.frame = CGRect(origin: CGPoint(x: x, y: y), size: itemSize)
|
||||||
attributesArr.append(attributes)
|
attributesArr.append(attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
||||||
|
abs((collectionView?.bounds.width ?? 0) - newBounds.width) > 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,6 +75,6 @@ class CollectionHFlowLayout: UICollectionViewLayout {
|
||||||
override var collectionViewContentSize: CGSize {
|
override var collectionViewContentSize: CGSize {
|
||||||
var height = itemSize.height * CGFloat(rows)
|
var height = itemSize.height * CGFloat(rows)
|
||||||
height = height + CGFloat(rows - 1) * vSpacing + sectionInset.top + sectionInset.bottom
|
height = height + CGFloat(rows - 1) * vSpacing + sectionInset.top + sectionInset.bottom
|
||||||
return CGSize(width: CGFloat(page + 1) * (collectionView?.bounds.width ?? UIScreen.main.bounds.width), height: height)
|
return CGSize(width: CGFloat(page + 1) * pageWidth, height: height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||