- UI改版优化

This commit is contained in:
linshujie 2026-08-21 13:44:48 +08:00
parent 367e422d94
commit d362ca565a
37 changed files with 2973 additions and 682 deletions

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

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

View File

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

View File

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

View File

@ -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 = persisted
} else {
screenSessionStart = Date() screenSessionStart = Date()
Defaults[\.screenSessionStartAt] = screenSessionStart Defaults[\.screenSessionStartAt] = screenSessionStart
} Defaults[\.unlockLastHeartbeatAt] = 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()

View File

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

View File

@ -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,10 +524,7 @@ final class GroupChatVC: BaseViewController {
}) })
.disposed(by: disposeBag) .disposed(by: disposeBag)
Observable.merge( rootView.galleryBtn.rx.tap
rootView.galleryBtn.rx.tap.asObservable(),
rootView.voiceRecordView.addBtn.rx.tap.asObservable()
)
.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.dismissAllPanels()
@ -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)
let text = voiceCapture.latestText
let url = recordFileURL
try? AVAudioSession.sharedInstance().setActive(false) switch action {
case .discard:
if cancel { deleteRecordFile()
self.rootView.voiceRecordView.cancelBtn.isSelected = false rootView.voiceRecordView.reset()
if let url = recordFileURL { try? FileManager.default.removeItem(at: url) } case .sendVoice:
rootView.voiceRecordView.reset()
guard let url, duration >= 1 else {
dl.show(text: "说话时间太短")
deleteRecordFile()
return return
} }
sendVoiceMessage(url: url, durationMs: duration * 1000)
guard let url = recordFileURL, recordDuration >= 1 else { case .convert:
self.dl.show(text: "说话时间太短") deleteRecordFile()
if let url = recordFileURL { try? FileManager.default.removeItem(at: url) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
return 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)
}
}
} }
// Send voice message via OpenIM private func discardPreview() {
let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000) deleteRecordFile()
rootView.voiceRecordView.reset()
rootView.restoreComposerPosition()
}
private func deleteRecordFile() {
if let url = recordFileURL {
try? FileManager.default.removeItem(at: url)
}
recordFileURL = nil
}
private func sendVoiceMessage(url: URL, durationMs: Int) {
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: "",

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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
if let onlineCount {
countLab.text = "\(model.people_no)成员/\(onlineCount)在线"
} else {
countLab.text = "\(model.people_no)成员" 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
}() }()

View File

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

View File

@ -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,33 +122,46 @@ 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
if let last = self.lastGeocodedCoord, reportCurrentLocationIfNeeded()
hypot(coord.latitude - last.latitude, coord.longitude - last.longitude) < 0.001,
!self.lastAddress.isEmpty {
self.publishLocation(coord: coord, address: self.lastAddress, loc: loc)
return
} }
// private func reportCurrentLocationIfNeeded() {
self.geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, error in guard !AppContextManager.shared.userId.isEmpty,
guard let self = self else { return } let loc = lastLocation,
CLLocationCoordinate2DIsValid(loc.coordinate) else { return }
let coord = loc.coordinate
publishLocation(coord: coord, address: lastAddress, loc: loc)
refreshAddressIfNeeded(coord: coord, loc: loc)
}
private func refreshAddressIfNeeded(coord: CLLocationCoordinate2D, loc: CLLocation) {
if let last = lastGeocodedCoord,
hypot(coord.latitude - last.latitude, coord.longitude - last.longitude) < 0.001 {
return
}
geocoder.cancelGeocode()
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
let address = placemarks?.first?.name let address = placemarks?.first?.name
?? placemarks?.first?.thoroughfare ?? placemarks?.first?.thoroughfare
?? placemarks?.first?.locality ?? placemarks?.first?.locality
?? "" ?? ""
DispatchQueue.main.async {
guard let self else { return }
if !address.isEmpty {
self.lastAddress = address 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) {
let work = {
MQTTService.shared.reportLocation( MQTTService.shared.reportLocation(
lat: coord.latitude, lat: coord.latitude,
lon: coord.longitude, lon: coord.longitude,
@ -153,6 +172,12 @@ class HomeViewController: BaseViewController {
accuracy: loc.horizontalAccuracy accuracy: loc.horizontalAccuracy
) )
} }
if Thread.isMainThread {
work()
} else {
DispatchQueue.main.async(execute: work)
}
}
// MARK: - Actions // MARK: - Actions
private func reactiveAction() { private func reactiveAction() {
@ -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 }
trueCoordinateByUserId[userId] = coordinate
let memberToShow: CircleMember? = {
if let idx = members.firstIndex(where: { $0.id == userId }) { if let idx = members.firstIndex(where: { $0.id == userId }) {
let old = members[idx] let old = members[idx]
members[idx] = CircleMember( let updated = CircleMember(
id: old.id, name: old.name, avatar: old.avatar, id: old.id, name: old.name, avatar: old.avatar,
isOnline: true, isOwner: old.isOwner, isOnline: true, isOwner: old.isOwner,
coordinate: coordinate, address: address, coordinate: coordinate, address: address,
heading: old.heading, lastUpdateTime: old.lastUpdateTime, heading: old.heading, lastUpdateTime: old.lastUpdateTime,
battery: old.battery battery: old.battery
) )
} members[idx] = updated
return updated
//
guard let mapView = rootView.mapView else { return }
let memberToShow: CircleMember? = {
if let existing = members.first(where: { $0.id == userId }) {
return existing
} }
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)

View File

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

View File

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

View File

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

View File

@ -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
if groupModel == nil {
requestGroupInfo() 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)
} }

View File

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

View File

@ -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
}() }()

View File

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

View File

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

View File

@ -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()
} }

View File

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

View File

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

View File

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

View File

@ -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,7 +44,7 @@ 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)
@ -46,6 +52,10 @@ class CollectionHFlowLayout: UICollectionViewLayout {
} }
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
abs((collectionView?.bounds.width ?? 0) - newBounds.width) > 0.5
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
@ -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)
} }
} }