diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 56b4f1c5..89aa7cca 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -344,6 +344,8 @@ 55B221230000000100784823 /* MoodStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B221220000000100784822 /* MoodStatusView.swift */; }; 55B221250000000100784825 /* MoodStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B221240000000100784824 /* MoodStatusViewModel.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 */; }; 55BF75352FFF91690055DA57 /* InteractionEmojiCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BF75342FFF91690055DA57 /* InteractionEmojiCell.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 = ""; }; 55B221240000000100784824 /* MoodStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodStatusViewModel.swift; sourceTree = ""; }; 55B221260000000100784826 /* MoodStatusCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodStatusCatalog.swift; sourceTree = ""; }; + 55B222000000000100784830 /* AddEmojiPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEmojiPopView.swift; sourceTree = ""; }; + 55B222020000000100784832 /* AddMessagePopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMessagePopView.swift; sourceTree = ""; }; 55BF752C2FFE53F70055DA57 /* LocationPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionPopView.swift; sourceTree = ""; }; 55BF75342FFF91690055DA57 /* InteractionEmojiCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionEmojiCell.swift; sourceTree = ""; }; 55C219C13024C00100784730 /* SearchLocationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLocationHeaderView.swift; sourceTree = ""; }; @@ -1247,6 +1251,8 @@ 305A76272FCA8C7000227D26 /* GroupMemberView.swift */, 55B2179030217D6600784774 /* HomeView2.swift */, 55B2179230218CE100784774 /* GroupMemberView2.swift */, + 55B222000000000100784830 /* AddEmojiPopView.swift */, + 55B222020000000100784832 /* AddMessagePopView.swift */, 55B219A1302A000100784750 /* UnlockRequestPopView.swift */, 55B219B1302A000100784760 /* ReceiveMessagePopView.swift */, 55B219B3302A000100784762 /* ReceiveMessageReportPopView.swift */, @@ -2448,6 +2454,8 @@ 55B220053031000100784805 /* FeedbackSuccessView.swift in Sources */, 55B221210000000100784821 /* MoodStatusVC.swift in Sources */, 55B221230000000100784823 /* MoodStatusView.swift in Sources */, + 55B222010000000100784831 /* AddEmojiPopView.swift in Sources */, + 55B222030000000100784833 /* AddMessagePopView.swift in Sources */, 55B221250000000100784825 /* MoodStatusViewModel.swift in Sources */, 55B221270000000100784827 /* MoodStatusCatalog.swift in Sources */, 30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */, @@ -2777,6 +2785,7 @@ INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风发送语音消息。"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。"; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "我们需要语音识别权限,以便将语音转换成文字。"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIBackgroundModes = location; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -2831,6 +2840,7 @@ INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风发送语音消息。"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。"; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "我们需要语音识别权限,以便将语音转换成文字。"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIBackgroundModes = location; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -2982,6 +2992,174 @@ }; 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 */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3044,6 +3222,7 @@ isa = XCConfigurationList; buildConfigurations = ( 15B6023456261CCD0E3A673E /* Release */, + 559B7BA43037017D0031C511 /* AdHoc */, 3A68FBB9A55555685B490D5C /* Debug */, ); defaultConfigurationIsVisible = 0; @@ -3054,6 +3233,7 @@ buildConfigurations = ( 3E43591E2FC48D2B003470A5 /* Debug */, 3E43591F2FC48D2B003470A5 /* Release */, + 559B7BA23037017D0031C511 /* AdHoc */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -3063,6 +3243,7 @@ buildConfigurations = ( 3E43591C2FC48D2B003470A5 /* Debug */, 3E43591D2FC48D2B003470A5 /* Release */, + 559B7BA33037017D0031C511 /* AdHoc */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -3071,6 +3252,7 @@ isa = XCConfigurationList; buildConfigurations = ( B66BC4F86D908B8200A8DC8E /* Release */, + 559B7BA53037017D0031C511 /* AdHoc */, A0AD067561B0A604B5DF5049 /* Debug */, ); defaultConfigurationIsVisible = 0; diff --git a/QuickLocation/Assets.xcassets/Home/group_list_header.imageset/组 47987@2x.png b/QuickLocation/Assets.xcassets/Home/group_list_header.imageset/组 47987@2x.png index 451ae031..f902cd4c 100644 Binary files a/QuickLocation/Assets.xcassets/Home/group_list_header.imageset/组 47987@2x.png and b/QuickLocation/Assets.xcassets/Home/group_list_header.imageset/组 47987@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Home/group_list_header.imageset/组 47987@3x.png b/QuickLocation/Assets.xcassets/Home/group_list_header.imageset/组 47987@3x.png index 38d3836f..300a131d 100644 Binary files a/QuickLocation/Assets.xcassets/Home/group_list_header.imageset/组 47987@3x.png and b/QuickLocation/Assets.xcassets/Home/group_list_header.imageset/组 47987@3x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/edit.imageset/Contents.json b/QuickLocation/Assets.xcassets/IM/edit.imageset/Contents.json new file mode 100644 index 00000000..eff7bcc2 --- /dev/null +++ b/QuickLocation/Assets.xcassets/IM/edit.imageset/Contents.json @@ -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" + } +} + diff --git a/QuickLocation/Assets.xcassets/IM/edit.imageset/edit@2x.png b/QuickLocation/Assets.xcassets/IM/edit.imageset/edit@2x.png new file mode 100644 index 00000000..0b8380e9 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/edit.imageset/edit@2x.png differ diff --git a/QuickLocation/Assets.xcassets/IM/edit.imageset/edit@3x.png b/QuickLocation/Assets.xcassets/IM/edit.imageset/edit@3x.png new file mode 100644 index 00000000..e75f0ff7 Binary files /dev/null and b/QuickLocation/Assets.xcassets/IM/edit.imageset/edit@3x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/circle_days.imageset/Contents.json b/QuickLocation/Assets.xcassets/Mine/circle_days.imageset/Contents.json new file mode 100644 index 00000000..1cda8daa --- /dev/null +++ b/QuickLocation/Assets.xcassets/Mine/circle_days.imageset/Contents.json @@ -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 + } +} diff --git a/QuickLocation/Assets.xcassets/Mine/circle_days.imageset/circle_days@2x.png b/QuickLocation/Assets.xcassets/Mine/circle_days.imageset/circle_days@2x.png new file mode 100644 index 00000000..771fcb85 Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/circle_days.imageset/circle_days@2x.png differ diff --git a/QuickLocation/Assets.xcassets/Mine/circle_days.imageset/circle_days@3x.png b/QuickLocation/Assets.xcassets/Mine/circle_days.imageset/circle_days@3x.png new file mode 100644 index 00000000..2aa7dc9a Binary files /dev/null and b/QuickLocation/Assets.xcassets/Mine/circle_days.imageset/circle_days@3x.png differ diff --git a/QuickLocation/Assets.xcassets/SearchLocation/header_bg.imageset/image@2x.png b/QuickLocation/Assets.xcassets/SearchLocation/header_bg.imageset/image@2x.png index a16f2ef8..f3f7ed16 100644 Binary files a/QuickLocation/Assets.xcassets/SearchLocation/header_bg.imageset/image@2x.png and b/QuickLocation/Assets.xcassets/SearchLocation/header_bg.imageset/image@2x.png differ diff --git a/QuickLocation/Assets.xcassets/SearchLocation/header_bg.imageset/image@3x.png b/QuickLocation/Assets.xcassets/SearchLocation/header_bg.imageset/image@3x.png index ad461887..94f94765 100644 Binary files a/QuickLocation/Assets.xcassets/SearchLocation/header_bg.imageset/image@3x.png and b/QuickLocation/Assets.xcassets/SearchLocation/header_bg.imageset/image@3x.png differ diff --git a/QuickLocation/Common/Constant.swift b/QuickLocation/Common/Constant.swift index e79de600..e8b811ff 100644 --- a/QuickLocation/Common/Constant.swift +++ b/QuickLocation/Common/Constant.swift @@ -39,7 +39,7 @@ extension DefaultsKeys { var unlockLastLongitude: DefaultsKey { .init("unlockLastLongitude") } /// 手机报告:按自然日累计屏幕时长(秒),key = yyyy-MM-dd var screenTimeByDay: DefaultsKey<[String: Int]> { .init("screenTimeByDay", defaultValue: [:]) } - /// 手机报告:进行中的解锁 session 起点(持久化,杀进程可续) + /// 手机报告:当前进程屏幕时长 session 起点(仅用于杀进程后结算到最后心跳,不跨进程续计) var screenSessionStartAt: DefaultsKey { .init("screenSessionStartAt") } /// 手机报告:本 App 今日进入前台次数 var appUsageToday: DefaultsKey { .init("appUsageToday", defaultValue: 0) } diff --git a/QuickLocation/Manager/MQTT/MQTTService.swift b/QuickLocation/Manager/MQTT/MQTTService.swift index e6941427..3c68dd8a 100644 --- a/QuickLocation/Manager/MQTT/MQTTService.swift +++ b/QuickLocation/Manager/MQTT/MQTTService.swift @@ -153,7 +153,7 @@ final class MQTTService: NSObject { /// - callback: 可选,该 topic 的专用回调,收到消息时优先于此回调 func subscribe(topic: String, qos: CocoaMQTTQoS = .qos1, callback: ((CocoaMQTT5Message) -> Void)? = nil) { let subscription = MqttSubscription(topic: topic, qos: qos) - subscription.noLocal = true + subscription.noLocal = false subscription.retainAsPublished = true subscription.retainHandling = .none mqtt?.subscribe([subscription]) diff --git a/QuickLocation/Manager/URL/URLManager.swift b/QuickLocation/Manager/URL/URLManager.swift index 5e772fd6..3f9bec0f 100644 --- a/QuickLocation/Manager/URL/URLManager.swift +++ b/QuickLocation/Manager/URL/URLManager.swift @@ -52,7 +52,7 @@ extension DefaultsKeys { openIM_API = "https://imapi.zuom8.cn" // http://38.207.176.65:10002 openIM_WS = "ws://imws.zuom8.cn" // ws://38.207.176.65:10010 #elseif AdHoc - setupApiEnv(-1) + setupApiEnv(1) openIM_API = "https://imapi.zuom8.cn" openIM_WS = "ws://imws.zuom8.cn" #else diff --git a/QuickLocation/Manager/Unlock/UnlockCountManager.swift b/QuickLocation/Manager/Unlock/UnlockCountManager.swift index 3055f5df..818f46f8 100644 --- a/QuickLocation/Manager/Unlock/UnlockCountManager.swift +++ b/QuickLocation/Manager/Unlock/UnlockCountManager.swift @@ -31,8 +31,10 @@ final class UnlockCountManager: NSObject { private var lastGeofenceCenter: CLLocationCoordinate2D? private var lastKnownLocation: CLLocation? private var bootBackgroundTask: UIBackgroundTaskIdentifier = .invalid - /// 当前解锁 session 起点(锁屏结算屏幕时长) + /// 当前解锁 session 起点(锁屏结算屏幕时长,仅当前进程) private var screenSessionStart: Date? + /// 本次进程里用户是否真正打开过 App;杀进程后被定位拉起不算 + private var hasUserActivatedApp = false private override init() { super.init() @@ -81,7 +83,7 @@ final class UnlockCountManager: NSObject { rollDayIfNeeded() let today = todayString() var total = Defaults[\.screenTimeByDay][today] ?? 0 - if let start = effectiveScreenSessionStart { + if let start = screenSessionStart { total += screenTimeSeconds(from: start, to: Date(), onDay: today) } return total @@ -113,8 +115,8 @@ final class UnlockCountManager: NSObject { return } didStart = true + closeOrphanScreenSessionIfNeeded() rollDayIfNeeded() - restoreScreenSessionIfNeeded() let available = UIApplication.shared.isProtectedDataAvailable isSessionLocked = !available @@ -160,9 +162,6 @@ final class UnlockCountManager: NSObject { restartLocationMonitoringIfPossible(reassertContinuous: true) handleNonActiveLaunchIfNeeded(launchOptions) reconcileUnlockIfNeeded(reason: "coldStartAfterLock") - if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked { - startScreenSession() - } Defaults[\.unlockLastHeartbeatAt] = Date() } @@ -205,10 +204,6 @@ final class UnlockCountManager: NSObject { return true } - private var effectiveScreenSessionStart: Date? { - screenSessionStart ?? Defaults[\.screenSessionStartAt] - } - private func dayString(for date: Date) -> String { let formatter = DateFormatter() formatter.calendar = Calendar.current @@ -275,28 +270,32 @@ final class UnlockCountManager: NSObject { return formatter.date(from: key) } - private func restoreScreenSessionIfNeeded() { - guard UIApplication.shared.isProtectedDataAvailable else { return } - guard screenSessionStart == nil, let persisted = Defaults[\.screenSessionStartAt] else { return } - screenSessionStart = persisted + /// 上一进程留下的 session 只结算到最后一次存活心跳,死掉的空档不再计入 + private func closeOrphanScreenSessionIfNeeded() { + guard screenSessionStart == nil else { return } + 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() { guard UIApplication.shared.isProtectedDataAvailable else { return } + guard hasUserActivatedApp || UIApplication.shared.applicationState == .active else { return } rollDayIfNeeded() - if screenSessionStart == nil { - if let persisted = Defaults[\.screenSessionStartAt] { - screenSessionStart = persisted - } else { - screenSessionStart = Date() - Defaults[\.screenSessionStartAt] = screenSessionStart - } - } + guard screenSessionStart == nil else { return } + screenSessionStart = Date() + Defaults[\.screenSessionStartAt] = screenSessionStart + Defaults[\.unlockLastHeartbeatAt] = screenSessionStart } - private func flushScreenSession() { - guard let start = effectiveScreenSessionStart else { return } - addScreenTime(from: start, to: Date()) + private func flushScreenSession(end: Date = Date()) { + guard let start = screenSessionStart else { return } + addScreenTime(from: start, to: end) screenSessionStart = nil Defaults[\.screenSessionStartAt] = nil notifyChange() @@ -478,6 +477,7 @@ final class UnlockCountManager: NSObject { } @objc private func appWillTerminate() { + flushScreenSession() persistLifecycleHeartbeat() forceRefreshGeofenceFromLastKnown(reason: "willTerminate") debugLog("willTerminate protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked])") @@ -485,8 +485,12 @@ final class UnlockCountManager: NSObject { @objc private func appDidBecomeActive() { debugLog("didBecomeActive protected=\(UIApplication.shared.isProtectedDataAvailable) wasLocked=\(Defaults[\.unlockWasLocked]) sessionLocked=\(isSessionLocked)") + hasUserActivatedApp = true endBootBackgroundTask() reconcileUnlockIfNeeded(reason: "becomeActiveAfterLock") + if UIApplication.shared.isProtectedDataAvailable, !isSessionLocked { + startScreenSession() + } recordAppUsage() restartLocationMonitoringIfPossible(reassertContinuous: true) Defaults[\.unlockLastHeartbeatAt] = Date() diff --git a/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift b/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift index 77f11623..eb7d5c3e 100644 --- a/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift +++ b/QuickLocation/Section/Group/CreateGroup/CreateGroupViewModel.swift @@ -79,7 +79,7 @@ class CreateGroupViewModel { // MARK: - 加载数据 func loadData() { - sectionedItems.onNext((tagList + [Self.addTagToken]).mapSection()) + sectionedItems.onNext(tagList.mapSection()) } // MARK: - Request diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index 9f785b4a..cd5d6ae4 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -12,6 +12,7 @@ import RxDataSources import OpenIMSDK import AVFoundation import AudioToolbox +import Speech import HXPHPicker import IQKeyboardManagerSwift import MJRefresh @@ -35,6 +36,7 @@ final class GroupChatVC: BaseViewController { private var cameraPicker: ImagePicker? /// 加载更早历史前记录,用于保持阅读位置 private var pendingOffsetRestore: (oldHeight: CGFloat, oldOffset: CGFloat)? + private var didSetupKeyboard = false // MARK: - Init init(groupId: String) { @@ -93,6 +95,11 @@ final class GroupChatVC: BaseViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) VoicePlayerManager.shared.stop() + if isVoiceRecording { + finishVoiceRecording(action: .discard) + } else if !rootView.voiceRecordView.isHidden { + rootView.voiceRecordView.reset() + } IQKeyboardManager.shared.isEnabled = true IQKeyboardManager.shared.resignOnTouchOutside = true } @@ -104,18 +111,19 @@ final class GroupChatVC: BaseViewController { // MARK: - Keyboard private func setupKeyboard() { - // 键盘升起 + guard !didSetupKeyboard else { return } + didSetupKeyboard = true NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification) .subscribe(onNext: { [weak self] noti in guard let self = self, - // 只在 VC 本身可见时响应,避免导航栈下层 VC 被全局通知唤醒 self.isViewLoaded && self.view.window != nil, let userInfo = noti.userInfo, let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + // 语音转文字预览自己抬界面,不要再顶聊天输入栏 + guard self.rootView.voiceRecordView.isHidden else { return } let height = frame.height let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 - // 收起表情/语音面板 self.rootView.dismissAllPanels(excludeTextField: true) UIView.animate(withDuration: duration) { self.rootView.bottomBar.layoutChain.bottom(height + 20) @@ -123,6 +131,21 @@ final class GroupChatVC: BaseViewController { self.scrollToBottom() }) .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() { @@ -130,16 +153,18 @@ final class GroupChatVC: BaseViewController { rootView.tableView.panGestureRecognizer.rx.event .filter { $0.state == .began } .subscribe(onNext: { [weak self] _ in - self?.rootView.dismissAllPanels() - self?.bubbleMenu?.dismiss() + guard let self = self, self.rootView.voiceRecordView.isHidden else { return } + self.rootView.dismissAllPanels() + self.bubbleMenu?.dismiss() }) .disposed(by: disposeBag) // 点击 cell 收起面板 rootView.tableView.rx.itemSelected .subscribe(onNext: { [weak self] _ in - self?.rootView.dismissAllPanels() - self?.bubbleMenu?.dismiss() + guard let self = self, self.rootView.voiceRecordView.isHidden else { return } + self.rootView.dismissAllPanels() + self.bubbleMenu?.dismiss() }) .disposed(by: disposeBag) } @@ -201,6 +226,7 @@ final class GroupChatVC: BaseViewController { rootView.bringSubviewToFront(rootView.quoteBar) rootView.bringSubviewToFront(rootView.bottomBar) rootView.bringSubviewToFront(rootView.disableIMView) + rootView.bringSubviewToFront(rootView.voiceRecordView) } // MARK: - 顶部加载更早历史 @@ -375,7 +401,6 @@ final class GroupChatVC: BaseViewController { return } self.viewModel.setPendingQuote(oim) - self.rootView.textField.becomeFirstResponder() } menu.onDismiss = { [weak self] in self?.bubbleMenu = nil } let anchor = menuAnchorView(in: cell) @@ -389,6 +414,7 @@ final class GroupChatVC: BaseViewController { rootView.mentionTableView.dataSource = self rootView.textField.rx.controlEvent(.editingChanged) .subscribe(onNext: { [weak self] in + self?.rootView.refreshComposer() self?.handleMentionTrigger() }) .disposed(by: disposeBag) @@ -446,6 +472,7 @@ final class GroupChatVC: BaseViewController { } } rootView.textField.text = current + insert + rootView.refreshComposer() rootView.textField.becomeFirstResponder() } @@ -467,37 +494,16 @@ final class GroupChatVC: BaseViewController { AppRouter.push(Route.groupMemberList, userInfo: ["groupKey": self.viewModel.groupId]) }).disposed(by: disposeBag) - // 语音按钮 + // 语音按钮:切换到按住说话 rootView.voiceBtn.rx.tap.subscribe(onNext: { [weak self] _ in - guard let self = self else { return } - 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() + self?.enterVoiceInputMode() }) .disposed(by: disposeBag) - - // 键盘按钮 - rootView.voiceRecordView.keyboardBtn.rx.tap + + rootView.keyboardBtn.rx.tap .subscribe(onNext: { [weak self] _ in guard let self = self else { return } - self.rootView.dismissAllPanels() + self.rootView.setVoiceInputMode(false) self.rootView.textField.becomeFirstResponder() }) .disposed(by: disposeBag) @@ -510,14 +516,6 @@ final class GroupChatVC: BaseViewController { }) .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) .subscribe(onNext: { [weak self] name in @@ -526,16 +524,13 @@ final class GroupChatVC: BaseViewController { }) .disposed(by: disposeBag) - Observable.merge( - rootView.galleryBtn.rx.tap.asObservable(), - rootView.voiceRecordView.addBtn.rx.tap.asObservable() - ) - .subscribe(onNext: { [weak self] in - guard let self = self else { return } - self.rootView.dismissAllPanels() - self.showAlbum() - }) - .disposed(by: disposeBag) + rootView.galleryBtn.rx.tap + .subscribe(onNext: { [weak self] in + guard let self = self else { return } + self.rootView.dismissAllPanels() + self.showAlbum() + }) + .disposed(by: disposeBag) rootView.cameraBtn.rx.tap .subscribe(onNext: { [weak self] in @@ -571,18 +566,6 @@ final class GroupChatVC: BaseViewController { .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: - 显示表情面板 private func showEmojiPanel() { let show = self.rootView.emojiPanelView.isHidden @@ -660,89 +643,192 @@ final class GroupChatVC: BaseViewController { } // MARK: - Voice Recording - private var audioRecorder: AVAudioRecorder? + private var isVoiceRecording = false + private let voiceCapture = ChatVoiceCapture() private var recordFileURL: URL? private var recordTimer: Timer? 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() { let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceLongPress(_:))) - longPress.minimumPressDuration = 0.2 - rootView.voiceRecordView.speakBtn.addGestureRecognizer(longPress) + longPress.minimumPressDuration = 0.18 + 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) { - let location = gesture.location(in: rootView.voiceRecordView) - let btnCenterX = rootView.voiceRecordView.speakBtn.center.x - let isCancel = location.x < btnCenterX - 100 - + let point = gesture.location(in: rootView.voiceRecordView) switch gesture.state { case .began: - AudioServicesPlaySystemSound(1519) - rootView.voiceRecordView.state = .recording - startRecording() + startVoiceRecording() case .changed: - let wasRecording = rootView.voiceRecordView.state == .recording - rootView.voiceRecordView.state = isCancel ? .canceling : .recording - rootView.voiceRecordView.cancelBtn.isSelected = isCancel - if isCancel && wasRecording { + guard isVoiceRecording else { return } + let target = rootView.voiceRecordView.hoverTarget(at: point) + if target != currentVoiceHover { + currentVoiceHover = target AudioServicesPlaySystemSound(1519) } + rootView.voiceRecordView.setHover(target) case .ended: - stopRecording(cancel: isCancel) + guard isVoiceRecording else { return } + finishVoiceRecording(action: finishAction(for: currentVoiceHover)) + case .cancelled, .failed: + if isVoiceRecording { + finishVoiceRecording(action: .discard) + } default: break } } - private func startRecording() { - let session = AVAudioSession.sharedInstance() - try? session.setCategory(.playAndRecord, mode: .default) - try? session.setActive(true) + private enum VoiceFinishAction { + case discard + case sendVoice + case convert + } - let dir = NSTemporaryDirectory() - let filename = "voice_\(Int(Date().timeIntervalSince1970)).wav" - recordFileURL = URL(fileURLWithPath: dir + filename) - - let settings: [String: Any] = [ - 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 finishAction(for hover: VoiceRecordView.HoverTarget) -> VoiceFinishAction { + switch hover { + case .cancel: return .discard + case .convert: return .convert + case .none: return .sendVoice } } - private func stopRecording(cancel: Bool) { - audioRecorder?.stop() + private func startVoiceRecording() { + 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 = nil - rootView.voiceRecordView.stopRotating() - - try? AVAudioSession.sharedInstance().setActive(false) + let duration = max(recordDuration, voiceCapture.duration) + voiceCapture.stop(finishRecognition: action == .convert) + let text = voiceCapture.latestText + let url = recordFileURL - if cancel { - self.rootView.voiceRecordView.cancelBtn.isSelected = false - if let url = recordFileURL { try? FileManager.default.removeItem(at: url) } - return + switch action { + case .discard: + deleteRecordFile() + rootView.voiceRecordView.reset() + case .sendVoice: + rootView.voiceRecordView.reset() + guard let url, duration >= 1 else { + dl.show(text: "说话时间太短") + deleteRecordFile() + return + } + sendVoiceMessage(url: url, durationMs: duration * 1000) + case .convert: + deleteRecordFile() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self, !self.isVoiceRecording, !self.rootView.voiceRecordView.isHidden else { return } + let latest = self.voiceCapture.latestText.isEmpty ? text : self.voiceCapture.latestText + self.rootView.voiceRecordView.showPreview(text: latest) + } } + } - guard let url = recordFileURL, recordDuration >= 1 else { - self.dl.show(text: "说话时间太短") - if let url = recordFileURL { try? FileManager.default.removeItem(at: url) } - return + private func discardPreview() { + deleteRecordFile() + rootView.voiceRecordView.reset() + rootView.restoreComposerPosition() + } + + private func deleteRecordFile() { + if let url = recordFileURL { + try? FileManager.default.removeItem(at: url) } + recordFileURL = nil + } - // Send voice message via OpenIM - let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: recordDuration * 1000) + private func sendVoiceMessage(url: URL, durationMs: Int) { + let msg = OIMMessageInfo.createSoundMessage(fromFullPath: url.path, duration: durationMs) viewModel.cacheMessage(msg) OIMManager.manager.sendMessage(msg, recvID: "", diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index 51c7c17e..c24fceb2 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -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) { let needsReset = !emojiPanelView.isHidden - || !voiceRecordView.isHidden || textField.isFirstResponder || !mentionPickerView.isHidden guard needsReset else { return } stopVisibleEmojiAnimations() emojiPanelView.isHidden = true - voiceRecordView.isHidden = true mentionPickerView.isHidden = true if !excludeTextField { textField.resignFirstResponder() } - UIView.animate(withDuration: 0.25) { + restoreComposerPosition() + } + + func restoreComposerPosition(animated: Bool = true) { + let update = { 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) emojiPanelView.addSubview(emojiCollectionView) emojiPanelView.addSubview(emojiPageControl) - bottomBar.addSubview(voiceBtn) bottomBar.addSubview(textField) + bottomBar.addSubview(holdSpeakBtn) bottomBar.addSubview(emojiBtn) + bottomBar.addSubview(voiceBtn) bottomBar.addSubview(sendBtn) + bottomBar.addSubview(keyboardBtn) quickActionBar.addSubview(galleryBtn) quickActionBar.addSubview(cameraBtn) quickActionBar.addSubview(locationBtn) @@ -341,13 +381,17 @@ class GroupChatView: UIView { bottomBarCornerView.layoutChain.edges() - voiceBtn.layoutChain - .left(13).centerY() + keyboardBtn.layoutChain + .right(12).centerY() .width(28).height(28) sendBtn.layoutChain - .right(10).centerY() - .width(56).height(40) + .right(8).centerY() + .width(36).height(36) + + voiceBtn.layoutChain + .right(12).centerY() + .width(28).height(28) emojiBtn.layoutChain .rightToLeftOfView(sendBtn, offset: -8) @@ -355,16 +399,18 @@ class GroupChatView: UIView { .width(30).height(30) textField.layoutChain - .leftToRightOfView(voiceBtn, offset: 10) + .left(16) .rightToLeftOfView(emojiBtn, offset: -10) .centerY() .height(36) - voiceRecordView.layoutChain - .edgesHorzontal() - .bottom(-252) - .height(252) -// .heightToWidth(252/375) + holdSpeakBtn.layoutChain + .left(16) + .rightToLeftOfView(keyboardBtn, offset: -10) + .centerY() + .height(36) + + voiceRecordView.layoutChain.edges() emojiPanelView.layoutChain .edgesHorzontal() @@ -640,33 +686,44 @@ class GroupChatView: UIView { lazy var textField: UITextField = { let tf = UITextField() - tf.font = .systemFont(ofSize: 14) - tf.backgroundColor = .white - tf.cornerRadius = 18 - tf.placeholder = "输入消息..." + tf.font = .systemFont(ofSize: 16) + tf.textColor = UIColor(hexStr: "#293445") + tf.backgroundColor = .clear + tf.placeholder = "发消息或者按住说话" tf.returnKeyType = .send - tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 36)) - tf.leftViewMode = .always + tf.tintColor = UIColor(hexStr: "#16B3FF") 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 = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/emoji_input"), for: .normal) 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 = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "IM/send"), for: .normal) - btn.backgroundColor = UIColor(hexStr: "#18B5F4") - btn.cornerRadius = 14 + btn.backgroundColor = UIColor(hexStr: "#16B3FF") + btn.cornerRadius = 10 + btn.isHidden = true return btn }() @@ -747,6 +804,7 @@ class GroupChatView: UIView { backgroundColor = UIColor(hexStr: "#F5F5F5") setupUI() setupRx() + refreshComposer() } required init?(coder: NSCoder) { @@ -1171,8 +1229,9 @@ class TextSendMsgCell: UITableViewCell { .topToBottomOfView(senderNameView, offset: 5) .rightToView(senderNameView) .left(60, relation: .greaterThanOrEqual) - .width(100, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) + contentLabel.setContentHuggingPriority(.required, for: .horizontal) + contentLabel.setContentCompressionResistancePriority(.required, for: .horizontal) contentLabel.translatesAutoresizingMaskIntoConstraints = false contentLabel.layoutChain @@ -1180,6 +1239,7 @@ class TextSendMsgCell: UITableViewCell { .edgesHorzontal(12) quoteBlock.translatesAutoresizingMaskIntoConstraints = false + quoteBlock.setContentHuggingPriority(.defaultLow, for: .horizontal) let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4) let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) quoteTopConstraint = qTop @@ -1188,7 +1248,6 @@ class TextSendMsgCell: UITableViewCell { qTop, quoteBlock.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor), quoteBlock.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 60), - quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor), quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), qH ]) @@ -1304,8 +1363,9 @@ class TextReceivedMsgCell: UITableViewCell { .topToBottomOfView(senderNameView, offset: 5) .leftToView(senderNameView) .right(60, relation: .greaterThanOrEqual) - .width(100, relation: .greaterThanOrEqual) .height(30, relation: .greaterThanOrEqual) + contentLabel.setContentHuggingPriority(.required, for: .horizontal) + contentLabel.setContentCompressionResistancePriority(.required, for: .horizontal) contentLabel.translatesAutoresizingMaskIntoConstraints = false contentLabel.layoutChain @@ -1313,6 +1373,7 @@ class TextReceivedMsgCell: UITableViewCell { .edgesHorzontal(12) quoteBlock.translatesAutoresizingMaskIntoConstraints = false + quoteBlock.setContentHuggingPriority(.defaultLow, for: .horizontal) // 引用在 bubbleView 下方 let qTop = quoteBlock.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: 4) let qH = quoteBlock.heightAnchor.constraint(equalToConstant: 0) @@ -1322,7 +1383,6 @@ class TextReceivedMsgCell: UITableViewCell { qTop, quoteBlock.leadingAnchor.constraint(equalTo: senderNameView.leadingAnchor), quoteBlock.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -60), - quoteBlock.widthAnchor.constraint(lessThanOrEqualTo: bubbleView.widthAnchor), quoteBlock.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), qH ]) diff --git a/QuickLocation/Section/Group/GroupChat/VoiceRecordView.swift b/QuickLocation/Section/Group/GroupChat/VoiceRecordView.swift index 84d769be..c3a30f10 100644 --- a/QuickLocation/Section/Group/GroupChat/VoiceRecordView.swift +++ b/QuickLocation/Section/Group/GroupChat/VoiceRecordView.swift @@ -6,222 +6,591 @@ // import UIKit -import Lottie +import AVFoundation +import Speech -final class VoiceRecordView: UIView { +final class VoiceRecordView: UIView, UIGestureRecognizerDelegate { - enum State { + enum Phase { + case hidden case recording - case canceling + case cancelHover + case convertHover + case preview + case failed } - var state: State = .recording { - didSet { - updateUI() - } - } - - private func updateUI() { - switch state { - case .recording: - tipsLab.text = "松开发送" - startRotating() - case .canceling: - tipsLab.text = "松开取消" - } + enum HoverTarget { + case none + case cancel + case convert } - lazy var bgView: UIView = { - let v = UIView() - v.backgroundColor = .clear - v.layer.shadowColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.1).cgColor - v.layer.shadowOffset = CGSize(width: 0, height: 0) - v.layer.shadowOpacity = 1 - v.layer.shadowRadius = 8 - return v - }() + private(set) var phase: Phase = .hidden + var onPreviewCancel: (() -> Void)? + var onPreviewSend: ((String) -> Void)? - lazy var topContainer: UIView = { - let v = UIView() - v.backgroundColor = .white - return v - }() - - lazy var keyboardBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "IM/keyboard"), for: .normal) - return btn - }() - - lazy var titleView: UIView = { - let v = UIView() - v.layer.shadowColor = UIColor(red: 0.06, green: 0.16, blue: 0.27, alpha: 0.1).cgColor - v.layer.shadowOffset = CGSize(width: 0, height: 0) - v.layer.shadowOpacity = 1 - v.layer.shadowRadius = 9 - - let cornerView = UIView() - cornerView.backgroundColor = .white - cornerView.cornerRadius = 19 - - let label = UILabel() - label.text = "正在录入语音..." - label.font = .systemFont(ofSize: 12) - label.textColor = ThemeManager.shared.color.subTitleColor - label.textAlignment = .center - - v.addSubview(cornerView) - v.addSubview(label) - - cornerView.layoutChain.edges() - label.layoutChain.centerX().centerY() - - return v - }() - - lazy var emojiBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "IM/emoji_off"), for: .normal) - btn.extendEdgeInsets = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 0) - return btn - }() - - lazy var addBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "IM/add_off"), for: .normal) - btn.extendEdgeInsets = UIEdgeInsets(top: 15, left: 0, bottom: 15, right: 15) - return btn - }() - - lazy var containerView: UIView = { + private let overlayView: UIView = { let view = UIView() - view.backgroundColor = .white + view.backgroundColor = UIColor.black.withAlphaComponent(0.65) return view }() - - lazy var tipsLab: UILabel = { + + private let hillView = VoiceRecordHillView() + + private let contentView = UIView() + + private let hintLabel: UILabel = { let label = UILabel() - label.text = " " - label.font = .systemFont(ofSize: 12, weight: .medium) - label.textColor = ThemeManager.shared.color.titleAuxColor + label.font = .systemFont(ofSize: 16, weight: .medium) + label.textColor = .white label.textAlignment = .center + label.text = "松开 发送" return label }() - - lazy var speakingImgView: UIImageView = { - let view = UIImageView(image: UIImage(named: "IM/speaking")) - view.isHidden = true + + private let bubbleView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#4DA3FF") + view.layer.cornerRadius = 28 + view.clipsToBounds = true return view }() - - lazy var speakBtn: UIView = { - let view = UIView() - view.backgroundColor = UIColor(hexStr: "#E7F7FF") - view.cornerRadius = 48 - - let icon = UIImageView(image: UIImage(named: "IM/speak")) - view.addSubview(icon) - icon.layoutChain + + private let tailView = VoiceBubbleTailView() + + private let dotsView = VoiceRecordingDotsView() + + private let textView: UITextView = { + let view = UITextView() + view.backgroundColor = .clear + view.textColor = .white + view.font = .systemFont(ofSize: 18, weight: .medium) + view.isScrollEnabled = false + view.isEditable = false + view.textContainerInset = .zero + view.textContainer.lineFragmentPadding = 0 + view.tintColor = .white + view.textAlignment = .left + return view + }() + + private let waveformView = VoiceRecordWaveformView() + + private let durationLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .medium) + label.textColor = .white + label.text = "0\"" + return label + }() + + private let editButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "IM/edit")?.withRenderingMode(.alwaysOriginal), for: .normal) + button.adjustsImageWhenHighlighted = false + button.isHidden = true + return button + }() + + let cancelBtn = VoiceRecordCircleButton() + let convertBtn = VoiceRecordCircleButton() + let sendBtn = VoiceRecordCircleButton() + + private var bubbleWidthConstraint: NSLayoutConstraint? + private var bubbleHeightConstraint: NSLayoutConstraint? + private var bubbleCenterXConstraint: NSLayoutConstraint? + private var tailCenterXConstraint: NSLayoutConstraint? + private var bubbleContentConstraints: [NSLayoutConstraint] = [] + private var recognizedText: String = "" + private var duration: Int = 0 + private var keyboardObserver: NSObjectProtocol? + + override init(frame: CGRect) { + super.init(frame: frame) + isHidden = true + isUserInteractionEnabled = false + backgroundColor = .clear + setupUI() + bindActions() + observeKeyboard() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let keyboardObserver { + NotificationCenter.default.removeObserver(keyboardObserver) + } + } + + func beginRecording() { + recognizedText = "" + duration = 0 + textView.text = "" + textView.isEditable = false + textView.resignFirstResponder() + waveformView.reset() + waveformView.barColor = .white + durationLabel.text = "0\"" + contentView.transform = .identity + isHidden = false + isUserInteractionEnabled = false + apply(phase: .recording, animated: false) + } + + func setHover(_ target: HoverTarget) { + guard phase == .recording || phase == .cancelHover || phase == .convertHover else { return } + let next: Phase + switch target { + case .none: next = .recording + case .cancel: next = .cancelHover + case .convert: next = .convertHover + } + guard phase != next else { return } + apply(phase: next) + } + + func updateDuration(_ seconds: Int) { + duration = max(0, min(60, seconds)) + durationLabel.text = "\(duration)\"" + } + + func pushLevel(_ level: CGFloat) { + waveformView.push(level: level) + } + + func updateRecognizedText(_ text: String) { + recognizedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + if phase == .convertHover { + refreshConvertHoverContent() + } + } + + func showPreview(text: String) { + recognizedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + textView.text = recognizedText + isHidden = false + isUserInteractionEnabled = true + apply(phase: recognizedText.isEmpty ? .failed : .preview, animated: false) + } + + func reset() { + textView.resignFirstResponder() + textView.isEditable = false + recognizedText = "" + duration = 0 + waveformView.reset() + contentView.layer.removeAllAnimations() + contentView.transform = .identity + isUserInteractionEnabled = false + isHidden = true + apply(phase: .hidden, animated: false) + } + + func hoverTarget(at point: CGPoint) -> HoverTarget { + let cancelFrame = cancelBtn.convert(cancelBtn.bounds.insetBy(dx: -28, dy: -28), to: self) + let convertFrame = convertBtn.convert(convertBtn.bounds.insetBy(dx: -28, dy: -28), to: self) + if !cancelBtn.isHidden, cancelFrame.contains(point) { return .cancel } + if !convertBtn.isHidden, convertFrame.contains(point) { return .convert } + return .none + } + + var previewText: String { + textView.text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var isPreviewing: Bool { + phase == .preview || phase == .failed + } + + private func bindActions() { + cancelBtn.addTarget(self, action: #selector(handlePreviewCancel), for: .touchUpInside) + sendBtn.addTarget(self, action: #selector(handlePreviewSend), for: .touchUpInside) + editButton.addTarget(self, action: #selector(handleEdit), for: .touchUpInside) + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTap)) + tap.cancelsTouchesInView = false + tap.delegate = self + addGestureRecognizer(tap) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard textView.isFirstResponder else { return false } + var view = touch.view + while let current = view { + if current is UIControl { return false } + if current === textView || current === bubbleView { return false } + view = current.superview + } + return true + } + + @objc private func handleBackgroundTap() { + guard textView.isFirstResponder else { return } + endEditing(true) + } + + @objc private func handlePreviewCancel() { + guard phase == .preview || phase == .failed else { return } + contentView.layer.removeAllAnimations() + contentView.transform = .identity + endEditing(true) + onPreviewCancel?() + } + + @objc private func handlePreviewSend() { + guard phase == .preview else { return } + let text = previewText + guard !text.isEmpty else { return } + contentView.layer.removeAllAnimations() + contentView.transform = .identity + endEditing(true) + onPreviewSend?(text) + } + + @objc private func handleEdit() { + guard phase == .preview else { return } + if textView.isFirstResponder { + textView.resignFirstResponder() + return + } + textView.isEditable = true + textView.isUserInteractionEnabled = true + textView.becomeFirstResponder() + } + + private func setupUI() { + addSubview(overlayView) + addSubview(hillView) + addSubview(contentView) + contentView.addSubview(hintLabel) + contentView.addSubview(cancelBtn) + contentView.addSubview(convertBtn) + contentView.addSubview(sendBtn) + contentView.addSubview(tailView) + contentView.addSubview(bubbleView) + bubbleView.addSubview(dotsView) + bubbleView.addSubview(textView) + bubbleView.addSubview(waveformView) + bubbleView.addSubview(durationLabel) + bubbleView.addSubview(editButton) + + [overlayView, hillView, contentView, hintLabel, cancelBtn, convertBtn, sendBtn, tailView, bubbleView, + dotsView, textView, waveformView, durationLabel, editButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + + overlayView.layoutChain.edges() + contentView.layoutChain.edges() + + let hillSize = kScreenWidth + 180 + NSLayoutConstraint.activate([ + hillView.centerXAnchor.constraint(equalTo: centerXAnchor), + hillView.widthAnchor.constraint(equalToConstant: hillSize), + hillView.heightAnchor.constraint(equalToConstant: hillSize), + hillView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: hillSize - 118) + ]) + + hintLabel.layoutChain .centerX() - .centerY() - - return view - }() - - lazy var cancelLab: UILabel = { - let label = UILabel() - label.text = "左滑取消" - label.font = .systemFont(ofSize: 12, weight: .regular) - label.textColor = ThemeManager.shared.color.contentColor - return label - }() - - lazy var cancelBtn: UIButton = { - let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "IM/cancel_off"), for: .normal) - btn.setImage(UIImage(named: "IM/cancel"), for: .selected) - btn.isUserInteractionEnabled = false - return btn - }() + .bottom(46 + kSafeBottomMargin) + .height(22) + + cancelBtn.layoutChain + .left(48) + .bottomToTopOfView(hintLabel, offset: -80) + .width(80) + .height(80) + + convertBtn.layoutChain + .right(48) + .bottomToView(cancelBtn) + .width(80) + .height(80) + + sendBtn.layoutChain + .right(48) + .bottomToView(cancelBtn) + .width(80) + .height(80) + + bubbleCenterXConstraint = bubbleView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + bubbleWidthConstraint = bubbleView.widthAnchor.constraint(equalToConstant: 228) + bubbleHeightConstraint = bubbleView.heightAnchor.constraint(equalToConstant: 58) + NSLayoutConstraint.activate([ + bubbleView.bottomAnchor.constraint(equalTo: cancelBtn.topAnchor, constant: -26), + bubbleCenterXConstraint!, + bubbleWidthConstraint!, + bubbleHeightConstraint! + ]) + + tailCenterXConstraint = tailView.centerXAnchor.constraint(equalTo: bubbleView.centerXAnchor) + NSLayoutConstraint.activate([ + tailView.topAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -1), + tailView.widthAnchor.constraint(equalToConstant: 16), + tailView.heightAnchor.constraint(equalToConstant: 9), + tailCenterXConstraint! + ]) + + cancelBtn.configure(title: "取消", normalColor: UIColor(hexStr: "#E4EDF2"), normalTitleColor: UIColor(hexStr: "#2C3A4B")) + convertBtn.configure(title: "转文字", normalColor: UIColor(hexStr: "#E4EDF2"), normalTitleColor: UIColor(hexStr: "#2C3A4B")) + sendBtn.configure(title: "发送", normalColor: UIColor(hexStr: "#4DA3FF"), normalTitleColor: .white) + sendBtn.isHidden = true + } + + private func observeKeyboard() { + keyboardObserver = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + queue: .main + ) { [weak self] note in + self?.handleKeyboard(note) + } + } + + private func handleKeyboard(_ note: Notification) { + guard !isHidden, phase == .preview || phase == .failed, + let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { return } + let duration = (note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 + let curveRaw = (note.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt) ?? 7 + let endInView = convert(frame, from: nil) + let overlap = max(0, bounds.maxY - endInView.minY) + UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: curveRaw << 16)) { + self.contentView.transform = overlap > 0 ? CGAffineTransform(translationX: 0, y: -overlap) : .identity + } + } + + private func apply(phase: Phase, animated: Bool = true) { + let changed = self.phase != phase + self.phase = phase + switch phase { + case .hidden: + dotsView.stop() + case .recording: + hintLabel.text = "松开 发送" + hintLabel.isHidden = false + setBubbleColor(UIColor(hexStr: "#4DA3FF")) + cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30")) + convertBtn.setEmphasized(false, color: UIColor(hexStr: "#4DA3FF")) + convertBtn.isHidden = false + sendBtn.isHidden = true + cancelBtn.isUserInteractionEnabled = false + sendBtn.isUserInteractionEnabled = false + layoutRecordingBubble(showText: false) + case .cancelHover: + hintLabel.text = "松开 取消" + hintLabel.isHidden = false + setBubbleColor(UIColor(hexStr: "#FF3B30")) + cancelBtn.setEmphasized(true, color: UIColor(hexStr: "#FF3B30")) + convertBtn.setEmphasized(false, color: UIColor(hexStr: "#4DA3FF")) + convertBtn.isHidden = false + sendBtn.isHidden = true + layoutRecordingBubble(showText: false) + case .convertHover: + hintLabel.text = "松开 编辑文字" + hintLabel.isHidden = false + setBubbleColor(UIColor(hexStr: "#4DA3FF")) + cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30")) + convertBtn.setEmphasized(true, color: UIColor(hexStr: "#4DA3FF")) + convertBtn.isHidden = false + sendBtn.isHidden = true + refreshConvertHoverContent() + case .preview: + hintLabel.text = "松开 取消" + hintLabel.isHidden = false + setBubbleColor(UIColor(hexStr: "#4DA3FF")) + cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30")) + convertBtn.isHidden = true + sendBtn.isHidden = false + sendBtn.configure(title: "发送", normalColor: UIColor(hexStr: "#4DA3FF"), normalTitleColor: .white) + sendBtn.setEmphasized(false, color: UIColor(hexStr: "#4DA3FF")) + sendBtn.isEnabled = true + cancelBtn.isUserInteractionEnabled = true + sendBtn.isUserInteractionEnabled = true + layoutPreviewBubble(failed: false) + case .failed: + hintLabel.text = "松开 取消" + hintLabel.isHidden = false + setBubbleColor(UIColor(hexStr: "#FF3B30")) + cancelBtn.setEmphasized(false, color: UIColor(hexStr: "#FF3B30")) + convertBtn.isHidden = true + sendBtn.isHidden = false + sendBtn.configure(title: "发送", normalColor: UIColor(hexStr: "#C4473E"), normalTitleColor: .white) + sendBtn.setEmphasized(false, color: UIColor(hexStr: "#C4473E")) + sendBtn.isEnabled = false + cancelBtn.isUserInteractionEnabled = true + sendBtn.isUserInteractionEnabled = false + layoutPreviewBubble(failed: true) + } + let sendScale: CGAffineTransform = phase == .preview + ? CGAffineTransform(scaleX: 1.22, y: 1.22) + : .identity + if animated, changed, phase != .hidden { + UIView.animate(withDuration: 0.18, delay: 0, options: [.allowUserInteraction, .curveEaseOut]) { + self.layoutIfNeeded() + self.sendBtn.transform = sendScale + } + } else { + layoutIfNeeded() + sendBtn.transform = sendScale + } + } + + private func refreshConvertHoverContent() { + textView.text = recognizedText + layoutRecordingBubble(showText: true) + } + + private func layoutRecordingBubble(showText: Bool) { + let showLiveText = showText && !recognizedText.isEmpty + dotsView.isHidden = !showText + if showText { dotsView.start() } else { dotsView.stop() } + textView.isHidden = !showLiveText + textView.isEditable = false + textView.isUserInteractionEnabled = false + textView.textAlignment = .left + editButton.isHidden = true + waveformView.isHidden = false + durationLabel.isHidden = false + durationLabel.textColor = .white + waveformView.barColor = .white + waveformView.style = showText ? .mini : .full + + if showText { + bubbleWidthConstraint?.constant = min(kScreenWidth - 48, 318) + bubbleHeightConstraint?.constant = showLiveText ? 118 : 96 + bubbleView.layer.cornerRadius = 24 + bubbleCenterXConstraint?.constant = 8 + tailCenterXConstraint?.constant = 52 + } else if phase == .cancelHover { + bubbleWidthConstraint?.constant = 228 + bubbleHeightConstraint?.constant = 58 + bubbleView.layer.cornerRadius = 29 + bubbleCenterXConstraint?.constant = -36 + tailCenterXConstraint?.constant = -8 + } else { + bubbleWidthConstraint?.constant = 228 + bubbleHeightConstraint?.constant = 58 + bubbleView.layer.cornerRadius = 29 + bubbleCenterXConstraint?.constant = 0 + tailCenterXConstraint?.constant = 0 + } + installBubbleContentConstraints(showText: showText, preview: false, failed: false) + } + + private func layoutPreviewBubble(failed: Bool) { + dotsView.stop() + dotsView.isHidden = true + textView.isHidden = false + waveformView.isHidden = true + durationLabel.isHidden = true + editButton.isHidden = failed + textView.isEditable = false + textView.isUserInteractionEnabled = !failed + textView.text = failed ? "未识别到文字" : recognizedText + textView.textAlignment = failed ? .center : .left + bubbleWidthConstraint?.constant = min(kScreenWidth - 48, 318) + bubbleHeightConstraint?.constant = failed ? 72 : max(108, previewTextHeight() + 44) + bubbleView.layer.cornerRadius = 24 + bubbleCenterXConstraint?.constant = 8 + tailCenterXConstraint?.constant = 52 + installBubbleContentConstraints(showText: false, preview: true, failed: failed) + } + + private func previewTextHeight() -> CGFloat { + let width = min(kScreenWidth - 48, 318) - 52 + let text = recognizedText as NSString + let rect = text.boundingRect( + with: CGSize(width: width, height: 160), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: UIFont.systemFont(ofSize: 18, weight: .medium)], + context: nil + ) + return ceil(rect.height) + } + + private func installBubbleContentConstraints(showText: Bool, preview: Bool, failed: Bool) { + NSLayoutConstraint.deactivate(bubbleContentConstraints) + bubbleContentConstraints.removeAll() + + if preview { + if failed { + bubbleContentConstraints.append(contentsOf: [ + textView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18), + textView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -18), + textView.centerYAnchor.constraint(equalTo: bubbleView.centerYAnchor) + ]) + } else { + bubbleContentConstraints.append(contentsOf: [ + textView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18), + textView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -44), + textView.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 16), + editButton.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -18), + editButton.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -16), + editButton.widthAnchor.constraint(equalToConstant: 22), + editButton.heightAnchor.constraint(equalToConstant: 22) + ]) + } + } else if showText { + bubbleContentConstraints.append(contentsOf: [ + dotsView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18), + dotsView.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: 18), + dotsView.widthAnchor.constraint(equalToConstant: 28), + dotsView.heightAnchor.constraint(equalToConstant: 10), + textView.leadingAnchor.constraint(equalTo: dotsView.trailingAnchor, constant: 8), + textView.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -18), + textView.centerYAnchor.constraint(equalTo: dotsView.centerYAnchor), + durationLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -16), + durationLabel.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -14), + waveformView.trailingAnchor.constraint(equalTo: durationLabel.leadingAnchor, constant: -8), + waveformView.centerYAnchor.constraint(equalTo: durationLabel.centerYAnchor), + waveformView.widthAnchor.constraint(equalToConstant: 92), + waveformView.heightAnchor.constraint(equalToConstant: 16) + ]) + } else { + bubbleContentConstraints.append(contentsOf: [ + durationLabel.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -16), + durationLabel.centerYAnchor.constraint(equalTo: bubbleView.centerYAnchor), + waveformView.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: 18), + waveformView.trailingAnchor.constraint(equalTo: durationLabel.leadingAnchor, constant: -8), + waveformView.centerYAnchor.constraint(equalTo: bubbleView.centerYAnchor), + waveformView.heightAnchor.constraint(equalToConstant: 28) + ]) + } + NSLayoutConstraint.activate(bubbleContentConstraints) + } + + private func setBubbleColor(_ color: UIColor) { + bubbleView.backgroundColor = color + tailView.fillColor = color + } + +} + +final class VoiceRecordHillView: UIView { + private let gradientLayer = CAGradientLayer() + private let rimLayer = CAShapeLayer() - // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear - - addSubview(bgView) - bgView.addSubview(topContainer) - topContainer.addSubview(keyboardBtn) - topContainer.addSubview(titleView) - topContainer.addSubview(emojiBtn) - topContainer.addSubview(addBtn) - - bgView.addSubview(containerView) - containerView.addSubview(tipsLab) - containerView.addSubview(speakBtn) - containerView.addSubview(speakingImgView) - containerView.addSubview(cancelLab) - containerView.addSubview(cancelBtn) - - bgView.layoutChain.edges() - - topContainer.layoutChain - .edges(excludingEdge: .bottom) - .height(58) - - keyboardBtn.layoutChain - .left(22) - .centerY() - .width(36) - .height(36) - - addBtn.layoutChain - .right(22) - .centerY() - .width(24) - .height(24) - - emojiBtn.layoutChain - .rightToLeftOfView(addBtn, offset: -8) - .centerY() - .width(24) - .height(24) - - titleView.layoutChain - .top(10) - .leftToRightOfView(keyboardBtn, offset: 7) - .rightToLeftOfView(emojiBtn, offset: -12) - .bottom(10) - - containerView.layoutChain - .topToBottomOfView(topContainer, offset: 0) - .edges(excludingEdge: .top) - - tipsLab.layoutChain - .top(18) - .centerX() - - speakBtn.layoutChain - .height(96) - .widthToHeight(1) - .centerY() - .centerX() - - speakingImgView.layoutChain - .topToView(speakBtn, offset: -6) - .leftToView(speakBtn, offset: -6) - .rightToView(speakBtn, offset: 6) - .bottomToView(speakBtn, offset: 6) - - cancelBtn.layoutChain - .rightToLeftOfView(speakBtn, offset: -47) - .centerY(speakBtn) - .width(41) - .heightToWidth(1) - - cancelLab.layoutChain - .bottomToTopOfView(cancelBtn, offset: -7) - .centerX(cancelBtn) + isOpaque = false + clipsToBounds = true + gradientLayer.colors = [ + UIColor(white: 0.455, alpha: 1).cgColor, + UIColor(white: 0.75, alpha: 1).cgColor + ] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 0.26) + layer.addSublayer(gradientLayer) + rimLayer.fillColor = UIColor.clear.cgColor + rimLayer.strokeColor = UIColor.white.withAlphaComponent(0.92).cgColor + rimLayer.lineWidth = 1.5 + layer.addSublayer(rimLayer) } required init?(coder: NSCoder) { @@ -230,33 +599,318 @@ final class VoiceRecordView: UIView { override func layoutSubviews() { super.layoutSubviews() - topContainer.setNeedsLayout() - topContainer.layoutIfNeeded() - topContainer.setCornerRadius(corners: [.topRight, .topLeft], withCornerRadii: CGSize(width: 20, height: 20)) + layer.cornerRadius = bounds.width / 2 + gradientLayer.frame = bounds + rimLayer.frame = bounds + rimLayer.path = UIBezierPath(ovalIn: bounds.insetBy(dx: 1, dy: 1)).cgPath } } -extension VoiceRecordView { - func startRotating() { - guard speakingImgView.isHidden == true else { return } - speakingImgView.isHidden = false - // 避免重复添加动画 - speakingImgView.layer.removeAnimation(forKey: "rotationAnimation") - - let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z") - rotationAnimation.fromValue = 0 - rotationAnimation.toValue = Double.pi * 2 // 360° - rotationAnimation.duration = 1.0 - rotationAnimation.repeatCount = .infinity // 无限循环 - rotationAnimation.isRemovedOnCompletion = false - rotationAnimation.fillMode = .forwards - - speakingImgView.layer.add(rotationAnimation, forKey: "rotationAnimation") +final class VoiceRecordCircleButton: UIButton { + private let ringView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.layer.borderWidth = 8 + view.isHidden = true + view.isUserInteractionEnabled = false + return view + }() + + private var normalColor: UIColor = UIColor(hexStr: "#F3F6F8") + private var normalTitleColor: UIColor = UIColor(hexStr: "#2C3A4B") + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel?.font = .systemFont(ofSize: 17, weight: .medium) + layer.masksToBounds = false + addSubview(ringView) + ringView.translatesAutoresizingMaskIntoConstraints = false + ringView.layoutChain + .centerX() + .centerY() + .widthToView(self, offset: 22) + .heightToView(self, offset: 22) } - - func stopRotating() { - tipsLab.text = " " - speakingImgView.isHidden = true - speakingImgView.layer.removeAnimation(forKey: "rotationAnimation") + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = min(bounds.width, bounds.height) / 2 + ringView.layer.cornerRadius = min(ringView.bounds.width, ringView.bounds.height) / 2 + } + + func configure(title: String, normalColor: UIColor, normalTitleColor: UIColor) { + self.normalColor = normalColor + self.normalTitleColor = normalTitleColor + setTitle(title, for: .normal) + setTitleColor(normalTitleColor, for: .normal) + backgroundColor = normalColor + } + + func setEmphasized(_ emphasized: Bool, color: UIColor) { + if emphasized { + backgroundColor = color + setTitleColor(.white, for: .normal) + ringView.isHidden = false + ringView.layer.borderColor = color.withAlphaComponent(0.35).cgColor + transform = CGAffineTransform(scaleX: 1.16, y: 1.16) + } else { + backgroundColor = normalColor + setTitleColor(normalTitleColor, for: .normal) + ringView.isHidden = true + transform = .identity + } + } +} + +final class VoiceBubbleTailView: UIView { + var fillColor: UIColor = UIColor(hexStr: "#4DA3FF") { + didSet { setNeedsDisplay() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: rect.maxX, y: 0)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.close() + fillColor.setFill() + path.fill() + } +} + +final class VoiceRecordWaveformView: UIView { + enum Style { + case full + case mini + } + + var style: Style = .full { + didSet { setNeedsDisplay() } + } + + private var levels: [CGFloat] = Array(repeating: 0.18, count: 21) + var barColor: UIColor = .white { + didSet { setNeedsDisplay() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func push(level: CGFloat) { + levels.removeFirst() + levels.append(min(1, max(0.12, level))) + setNeedsDisplay() + } + + func reset() { + levels = Array(repeating: 0.18, count: 21) + setNeedsDisplay() + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext(), !levels.isEmpty else { return } + let count = levels.count + let mid = CGFloat(count - 1) / 2 + let spacing = rect.width / CGFloat(count) + for (index, level) in levels.enumerated() { + let dist = abs(CGFloat(index) - mid) / mid + let envelope = CGFloat(exp(-Double(dist * dist * 3.8))) + let height = max(style == .mini ? 3 : 4, rect.height * envelope * max(level, 0.16)) + let x = CGFloat(index) * spacing + spacing / 2 + let lineWidth: CGFloat + if dist < 0.12 { + lineWidth = style == .mini ? 3.2 : 5 + } else if dist < 0.38 { + lineWidth = style == .mini ? 2.4 : 3.4 + } else { + lineWidth = style == .mini ? 2 : 2.6 + } + context.setStrokeColor(barColor.withAlphaComponent(0.45 + 0.55 * envelope).cgColor) + context.setLineWidth(lineWidth) + context.setLineCap(.round) + context.move(to: CGPoint(x: x, y: rect.midY - height / 2)) + context.addLine(to: CGPoint(x: x, y: rect.midY + height / 2)) + context.strokePath() + } + } +} + +final class VoiceRecordingDotsView: UIView { + private let dots = [UIView(), UIView(), UIView()] + private var isAnimating = false + + override init(frame: CGRect) { + super.init(frame: frame) + dots.enumerated().forEach { index, dot in + dot.backgroundColor = UIColor.white.withAlphaComponent(index == 0 ? 1 : 0.55) + dot.layer.cornerRadius = 3 + addSubview(dot) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + let size: CGFloat = 6 + let gap: CGFloat = 5 + dots.enumerated().forEach { index, dot in + dot.frame = CGRect(x: CGFloat(index) * (size + gap), y: (bounds.height - size) / 2, width: size, height: size) + } + } + + func start() { + guard !isAnimating else { return } + isAnimating = true + animateDots() + } + + func stop() { + isAnimating = false + layer.removeAllAnimations() + dots.forEach { $0.layer.removeAllAnimations() } + dots.enumerated().forEach { index, dot in + dot.alpha = index == 0 ? 1 : 0.55 + } + } + + private func animateDots() { + guard isAnimating else { return } + for (index, dot) in dots.enumerated() { + UIView.animate(withDuration: 0.35, delay: Double(index) * 0.12, options: [.repeat, .autoreverse]) { + dot.alpha = 0.25 + } + } + } +} + +final class ChatVoiceCapture: @unchecked Sendable { + private let engine = AVAudioEngine() + private var audioFile: AVAudioFile? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")) + private var startDate: Date? + private var tapInstalled = false + + private(set) var latestText = "" + var onLevel: ((CGFloat) -> Void)? + var onPartialText: ((String) -> Void)? + + var duration: Int { + guard let startDate else { return 0 } + return max(0, Int(Date().timeIntervalSince(startDate))) + } + + func start(fileURL: URL, enableSpeech: Bool) throws { + stop(finishRecognition: false) + latestText = "" + startDate = Date() + + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth]) + try session.setActive(true, options: .notifyOthersOnDeactivation) + + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.sampleRate > 0, format.channelCount > 0 else { + throw NSError(domain: "ChatVoiceCapture", code: -1, userInfo: [NSLocalizedDescriptionKey: "invalid audio format"]) + } + + audioFile = try AVAudioFile(forWriting: fileURL, settings: format.settings) + + if enableSpeech, let recognizer, recognizer.isAvailable { + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + request.taskHint = .dictation + recognitionRequest = request + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, _ in + guard let text = result?.bestTranscription.formattedString, !text.isEmpty else { return } + DispatchQueue.main.async { + self?.latestText = text + self?.onPartialText?(text) + } + } + } + + input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + guard let self else { return } + try? self.audioFile?.write(from: buffer) + self.recognitionRequest?.append(buffer) + let level = ChatVoiceCapture.rms(from: buffer) + DispatchQueue.main.async { + self.onLevel?(level) + } + } + tapInstalled = true + engine.prepare() + try engine.start() + } + + func stop(finishRecognition: Bool) { + if tapInstalled { + engine.inputNode.removeTap(onBus: 0) + tapInstalled = false + } + if engine.isRunning { + engine.stop() + } + if finishRecognition { + recognitionRequest?.endAudio() + recognitionRequest = nil + } else { + recognitionTask?.cancel() + recognitionRequest = nil + recognitionTask = nil + latestText = "" + } + audioFile = nil + startDate = nil + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + static func rms(from buffer: AVAudioPCMBuffer) -> CGFloat { + let n = Int(buffer.frameLength) + guard n > 0 else { return 0.12 } + if let data = buffer.floatChannelData?[0] { + var sum: Float = 0 + for i in 0.. 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 + } + } + } +} diff --git a/QuickLocation/Section/Home/AddMessagePopView.swift b/QuickLocation/Section/Home/AddMessagePopView.swift new file mode 100644 index 00000000..5090ef4b --- /dev/null +++ b/QuickLocation/Section/Home/AddMessagePopView.swift @@ -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) + } +} diff --git a/QuickLocation/Section/Home/GroupListPopView.swift b/QuickLocation/Section/Home/GroupListPopView.swift index fa7806e6..e0f2247e 100644 --- a/QuickLocation/Section/Home/GroupListPopView.swift +++ b/QuickLocation/Section/Home/GroupListPopView.swift @@ -11,10 +11,10 @@ class GroupListPopView: UIView { 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 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 tagText = UIColor(hexStr: "#D5923F") @@ -65,7 +65,7 @@ class GroupListPopView: UIView { bg.backgroundColor = UIColor(hexStr: "#FDFDFD") view.addSubview(bg) bg.layoutChain - .top(80) + .top(88) .edges(excludingEdge: .top) return view @@ -74,8 +74,8 @@ class GroupListPopView: UIView { lazy var headerImageView: UIImageView = { let view = UIImageView() view.image = UIImage(named: "Home/group_list_header") - view.contentMode = .scaleAspectFill - view.clipsToBounds = true + view.contentMode = .scaleAspectFit + view.clipsToBounds = false return view }() @@ -99,7 +99,7 @@ class GroupListPopView: UIView { btn.setTitleColor(Self.primaryBlue, for: .normal) btn.titleLabel?.font = FontManager.boboBold(18) btn.backgroundColor = .white - btn.cornerRadius = 16 + btn.cornerRadius = 25 btn.layer.borderWidth = 1 btn.layer.borderColor = Self.primaryBlue.cgColor btn.addTarget(self, action: #selector(cancelAction), for: .touchUpInside) @@ -112,7 +112,7 @@ class GroupListPopView: UIView { btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = FontManager.boboBold(18) btn.backgroundColor = Self.primaryBlue - btn.cornerRadius = 16 + btn.cornerRadius = 25 btn.addTarget(self, action: #selector(confirmAction), for: .touchUpInside) return btn }() @@ -137,19 +137,19 @@ class GroupListPopView: UIView { headerImageView.layoutChain .top() .edgesHorzontal() - .heightToWidth(127/375) + .heightToWidth(271.0 / 750.0) cancelBtn.layoutChain .left(20) .bottom(20 + kSafeBottomMargin) - .height(44) + .height(50) .widthToView(confirmBtn) confirmBtn.layoutChain .leftToRightOfView(cancelBtn, offset: 12) .right(20) .bottomToView(cancelBtn) - .height(44) + .height(50) tableView.layoutChain .topToBottomOfView(headerImageView, offset: -20) @@ -245,7 +245,10 @@ extension GroupListPopView: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: GroupListCell = tableView.dequeueReusableCell(for: indexPath) 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 } @@ -258,10 +261,14 @@ extension GroupListPopView: UITableViewDataSource, UITableViewDelegate { // MARK: - GroupListCell class GroupListCell: UITableViewCell { - func configure(model: GroupInfoModel, isSelected: Bool) { + func configure(model: GroupInfoModel, isSelected: Bool, onlineCount: Int? = nil) { avaterImgView.image = model.groupIcon nameLab.text = model.name - countLab.text = "共\(model.people_no)成员" + if let onlineCount { + countLab.text = "共\(model.people_no)成员/\(onlineCount)在线" + } else { + countLab.text = "共\(model.people_no)成员" + } tagStack.arrangedSubviews.forEach { tagStack.removeArrangedSubview($0) @@ -320,8 +327,8 @@ class GroupListCell: UITableViewCell { avaterImgView.layoutChain .left(12) .centerY() - .width(44) - .height(44) + .width(48) + .height(48) checkIcon.layoutChain .right(12) @@ -352,7 +359,7 @@ class GroupListCell: UITableViewCell { lazy var cardView: UIView = { let view = UIView() view.backgroundColor = .white - view.cornerRadius = 12 + view.cornerRadius = 16 view.clipsToBounds = true return view }() @@ -360,7 +367,7 @@ class GroupListCell: UITableViewCell { lazy var avaterImgView: UIImageView = { let view = UIImageView() view.contentMode = .scaleAspectFill - view.cornerRadius = 8 + view.cornerRadius = 10 view.clipsToBounds = true return view }() diff --git a/QuickLocation/Section/Home/GroupMemberView2.swift b/QuickLocation/Section/Home/GroupMemberView2.swift index 77199bd2..d89a5977 100644 --- a/QuickLocation/Section/Home/GroupMemberView2.swift +++ b/QuickLocation/Section/Home/GroupMemberView2.swift @@ -13,7 +13,11 @@ import SwiftyUserDefaults class GroupMemberView2: UIView { private var navBarHeightConstraint: NSLayoutConstraint? + private var todayTrackTopToInteraction: NSLayoutConstraint? + private var todayTrackTopToMember: NSLayoutConstraint? private var currentMemberModel: GroupMemberModel? + private var emojiEchoByUserId: [String: String] = [:] + private var messageEchoByUserId: [String: String] = [:] private static let batteryNormalColor = UIColor(hexStr: "#47DD00") private static let batteryLowColor = UIColor(hexStr: "#F85A5D") @@ -44,6 +48,55 @@ class GroupMemberView2: UIView { setupUnknownDeviceInfo(battery: model.battery) } 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) { @@ -261,10 +314,20 @@ class GroupMemberView2: UIView { memberView.layoutChain .edges(excludingEdge: .bottom) + scrollContentView.addSubview(interactionView) + interactionView.isHidden = true + interactionView.layoutChain + .topToBottomOfView(memberView, offset: 12) + .edgesHorzontal() + scrollContentView.addSubview(todayTrackView) todayTrackView.layoutChain - .topToBottomOfView(memberView, offset: 15) + .topToBottomOfView(interactionView, offset: 15) + todayTrackTopToInteraction = todayTrackView.jh_lastConstraint + todayTrackTopToInteraction?.isActive = false + todayTrackView.layoutChain .edgesHorzontal() + todayTrackTopToMember = todayTrackView.jh_pinEdge(.top, toEdge: .bottom, of: memberView, offset: 12) scrollContentView.addSubview(phoneReportView) phoneReportView.layoutChain @@ -300,11 +363,19 @@ class GroupMemberView2: UIView { .leftToRightOfView(icon, offset: 6) .centerY(icon) - view.addSubview(changeGroupBtn) - changeGroupBtn.layoutChain + let groupBtnView = UIView() + groupBtnView.backgroundColor = .white + groupBtnView.cornerRadius = 8 + view.addSubview(groupBtnView) + groupBtnView.addSubview(changeGroupBtn) + groupBtnView.layoutChain .centerY(icon) .right(15) - .height(20) + .width(70) + .height(24) + + changeGroupBtn.layoutChain + .edges() let memberView = UIView() memberView.backgroundColor = .white @@ -458,8 +529,8 @@ class GroupMemberView2: UIView { lazy var memberNameLab: UILabel = { let label = UILabel() label.text = " " - label.font = FontManager.youSheBiaoTiHei(12) - label.textColor = UIColor(hexStr: "#353B4F") + label.font = FontManager.youSheBiaoTiHei(14) + label.textColor = UIColor(hexStr: "#293445") return label }() @@ -714,6 +785,120 @@ class GroupMemberView2: UIView { 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: - 今日轨迹 lazy var todayTrackView: UIView = { let view = UIView() diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index ba6d537b..82e11ce4 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -46,6 +46,10 @@ class HomeViewController: BaseViewController { #if !targetEnvironment(simulator) /// 自己持有的地图点,避免只靠 mapView.annotations 对不上同一实例 private var annotationByUserId: [String: MemberAnnotation] = [:] + /// 散开前的真实坐标,annotation.coordinate 可能是展示偏移 + private var trueCoordinateByUserId: [String: CLLocationCoordinate2D] = [:] + private let overlapClusterRadius: CLLocationDistance = 20 + private let overlapSpreadRadius: CLLocationDistance = 12 #endif /// 最新位置(供 MQTT 定时上报) private var lastLocation: CLLocation? @@ -54,6 +58,8 @@ class HomeViewController: BaseViewController { private var subscribedMemberIds: [String] = [] /// 底部成员面板是否显示 private var isMemberPanelShown = false + /// 地图是否自动跟随当前用户;滑动查看后关闭,点击定位按钮后重新打开 + private var isAutoFollowingUser = true private var isSOSPlaying = false private var sosReceiveAlertView: SOSReceiveAlertView? private var sosAudioStopWorkItem: DispatchWorkItem? @@ -116,42 +122,61 @@ class HomeViewController: BaseViewController { private func startLocationTimer() { locationTimer?.invalidate() - locationTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in - guard let self = self, let loc = self.lastLocation else { return } - let coord = loc.coordinate + let timer = Timer(timeInterval: 5, repeats: true) { [weak self] _ in + self?.reportCurrentLocationIfNeeded() + } + RunLoop.main.add(timer, forMode: .common) + locationTimer = timer + reportCurrentLocationIfNeeded() + } - // 坐标变化不大时复用上次地址 - if let last = self.lastGeocodedCoord, - 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() { + guard !AppContextManager.shared.userId.isEmpty, + let loc = lastLocation, + CLLocationCoordinate2DIsValid(loc.coordinate) else { return } + let coord = loc.coordinate + publishLocation(coord: coord, address: lastAddress, loc: loc) + refreshAddressIfNeeded(coord: coord, loc: loc) + } - // 反向地理编码获取地址 - self.geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, error in - guard let self = self else { return } - let address = placemarks?.first?.name - ?? placemarks?.first?.thoroughfare - ?? placemarks?.first?.locality - ?? "" - self.lastAddress = address + 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 + ?? placemarks?.first?.thoroughfare + ?? placemarks?.first?.locality + ?? "" + DispatchQueue.main.async { + guard let self else { return } + if !address.isEmpty { + self.lastAddress = address + } self.lastGeocodedCoord = coord - self.publishLocation(coord: coord, address: address, loc: loc) } } } private func publishLocation(coord: CLLocationCoordinate2D, address: String, loc: CLLocation) { - MQTTService.shared.reportLocation( - lat: coord.latitude, - lon: coord.longitude, - addr: address, - speed: loc.speed, - bearing: loc.course, - altitude: loc.altitude, - accuracy: loc.horizontalAccuracy - ) + let work = { + MQTTService.shared.reportLocation( + lat: coord.latitude, + lon: coord.longitude, + addr: address, + speed: loc.speed, + bearing: loc.course, + altitude: loc.altitude, + accuracy: loc.horizontalAccuracy + ) + } + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } } // MARK: - Actions @@ -242,16 +267,23 @@ class HomeViewController: BaseViewController { self?.openTodayTrackDetail() }).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 self?.openPhoneReportDetail() }).disposed(by: disposeBag) - // 地图回到自己 + // 地图回到自己并重新开启自动跟随 rootView.locationView.rx.tapGesture.subscribe { _ in - if let ann = self.currentUserAnnotation { - self.selectMember(userId: ann.member.id, centerMap: true, dismissPanel: false) - } + self.startFollowingCurrentUser() }.disposed(by: disposeBag) // User Config刷新 @@ -612,8 +644,20 @@ class HomeViewController: BaseViewController { // 其他成员从 select_group_employee 取,过滤掉当前用户 var others: [CircleMember] = [] + let now = Date() 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 } others.append(m) } @@ -637,7 +681,12 @@ class HomeViewController: BaseViewController { } 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 { mapView.removeAnnotations(toRemove) } @@ -657,30 +706,10 @@ class HomeViewController: BaseViewController { mapView.addAnnotations(annotations) annotations.forEach { rememberAnnotation($0) } } - - if currentUserAnnotation == nil, - annotationByUserId["current"] == nil, - let me = currentUserMember, - 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) - } + // 自己的针只等高德 didUpdate 再加,不用苹果 GPS / 缓存点 + applyDisplayCoordinates() + refreshAnnotationScreenPositions() + bringAnnotationToFront(userId: selectedMemberId) #endif } @@ -708,6 +737,70 @@ class HomeViewController: BaseViewController { 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() { let members = viewModel.memberList.map { $0.toJSON() } var userInfo: [String: Any] = [ @@ -740,13 +833,40 @@ class HomeViewController: BaseViewController { } } - #if !targetEnvironment(simulator) - if dismissPanel { - rootView.dismissGroupMemberView() - } +// if dismissPanel { +// rootView.dismissGroupMemberView() +// } + if centerMap { + let isSelf = userId == "current" || viewModel.isCurrentUser(id: userId) + if !isSelf { + stopFollowingCurrentUser() + } 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 } @@ -843,6 +963,9 @@ extension HomeViewController { isOnline: isOnline, lastUpdateTime: firstPoint.time ) updateOnlineCount() + self.refreshSelectedMemberInfoIfNeeded(userId: userId) + // 自己的地图点由 GPS 维护(id = "current"),MQTT 回环不再加点,避免叠两个自己 + if userId == AppContextManager.shared.userId { return } // 经纬度无变化跳过地图标注更新 let shouldUpdateMap: Bool = { if let existing = self.members.first(where: { $0.id == userId }) { @@ -858,7 +981,6 @@ extension HomeViewController { } else if !isOnline { self.removeAnnotation(userId: userId) } - self.refreshSelectedMemberInfoIfNeeded(userId: userId) } case "disconnect": // 离线 self.removeAnnotation(userId: userId) @@ -876,47 +998,18 @@ extension HomeViewController { let emoteUserId = parts[1] if firstChar == "1" || firstChar == "2" { // 表情 -// let emojiFileName = firstChar == "1" ? "normal_" : "fun_" - let nickName = self.viewModel.getUserNickName(id: emoteUserId) - let fullText = nickName + "对你发送了表情" - 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 -// } -// } + let emojiFileName = firstChar == "1" ? "normal_\(lastChar)" : "fun_\(lastChar)" + DispatchQueue.main.async { [weak self] in + self?.applyIncomingEmote(from: emoteUserId, emojiName: emojiFileName, message: nil) + } } else if firstChar == "3" { // 文字 let textIdx = lastChar.integer let texts = QuickMessageView.messageList guard textIdx < texts.count else { break } - let nickName = self.viewModel.getUserNickName(id: emoteUserId) - let fullText = nickName + "对你说:" + 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 -// } -// } + DispatchQueue.main.async { [weak self] in + self?.applyIncomingEmote(from: emoteUserId, emojiName: nil, message: texts[textIdx]) + } } case "bubble": // 气泡 guard let userId = msg.data?.user_id else { return } @@ -1003,6 +1096,166 @@ extension HomeViewController { 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() + 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 /// 当前用户地图点 id 是 "current",MQTT 里是真实 user_id private func annotationIds(matching userId: String) -> Set { @@ -1062,43 +1315,56 @@ extension HomeViewController { #endif } - /// 更新指定成员的标注位置(只更新坐标,头像/名称不变) + /// 更新指定成员的标注位置(用 MQTT 坐标,不依赖接口 last_position) private func updateAnnotation(userId: String, coordinate: CLLocationCoordinate2D, address: String) { #if !targetEnvironment(simulator) if isMemberInBubble(userId) { removeAnnotation(userId: userId) return } - // 更新 members 数组 - if let idx = members.firstIndex(where: { $0.id == userId }) { - let old = members[idx] - members[idx] = CircleMember( - id: old.id, name: old.name, avatar: old.avatar, - isOnline: true, isOwner: old.isOwner, - coordinate: coordinate, address: address, - heading: old.heading, lastUpdateTime: old.lastUpdateTime, - battery: old.battery - ) - } + guard CLLocationCoordinate2DIsValid(coordinate) else { return } + trueCoordinateByUserId[userId] = coordinate - // 更新地图标注 - guard let mapView = rootView.mapView else { return } let memberToShow: CircleMember? = { - if let existing = members.first(where: { $0.id == userId }) { - return existing + if let idx = members.firstIndex(where: { $0.id == userId }) { + let old = members[idx] + let updated = CircleMember( + id: old.id, name: old.name, avatar: old.avatar, + isOnline: true, isOwner: old.isOwner, + coordinate: coordinate, address: address, + heading: old.heading, lastUpdateTime: old.lastUpdateTime, + battery: old.battery + ) + members[idx] = updated + return updated } guard let model = viewModel.memberList.first(where: { $0.user_id == userId }) else { return nil } - let created = CircleMember(member: model, isOwner: viewModel.isGroupOwn(id: model.user_id)) - guard CLLocationCoordinate2DIsValid(created.coordinate) else { return nil } + let created = CircleMember( + 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) return created }() - if let oldMember = memberToShow, oldMember.isOnline { - removeAnnotation(userId: userId) - let newAnn = MemberAnnotation(member: oldMember) + + guard let member = memberToShow, let mapView = rootView.mapView else { return } + if let existing = annotationByUserId[userId] { + existing.coordinate = coordinate + } else { + let newAnn = MemberAnnotation(member: member) mapView.addAnnotation(newAnn) rememberAnnotation(newAnn) } + applyDisplayCoordinates() + bringAnnotationToFront(userId: selectedMemberId) #endif } } @@ -1114,7 +1380,9 @@ extension HomeViewController: MAMapViewDelegate { 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 if annotationView == nil { annotationView = MemberAnnotationView(annotation: memberAnnotation, reuseIdentifier: identifier) @@ -1131,10 +1399,25 @@ extension HomeViewController: MAMapViewDelegate { if memberAnnotation.member.isCurrentUser { 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 } + func mapView(_ mapView: MAMapView!, didAddAnnotationViews views: [Any]!) { + for case let view as MemberAnnotationView in views ?? [] { + view.applyAnchor() + } + } + func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) { guard let ann = view.annotation as? MemberAnnotation else { return } mapView.deselectAnnotation(ann, animated: false) @@ -1149,16 +1432,23 @@ extension HomeViewController: MAMapViewDelegate { rootView.updateGPSSignal(bars: strength.barCount) // 地图标注 + let isFirstFix = lastLocation == nil lastLocation = location + if isFirstFix { + reportCurrentLocationIfNeeded() + } Defaults[\.currentLatitude] = location.coordinate.latitude Defaults[\.currentLongitude] = location.coordinate.longitude let coordinate = location.coordinate guard CLLocationCoordinate2DIsValid(coordinate) else { return } + trueCoordinateByUserId["current"] = coordinate if isMemberInBubble("current") { removeAnnotation(userId: "current") } else if let ann = currentUserAnnotation { ann.coordinate = coordinate + applyDisplayCoordinates() + bringAnnotationToFront(userId: selectedMemberId) } else if let me = currentUserMember { let updatedMe = CircleMember( id: "current", @@ -1176,13 +1466,19 @@ extension HomeViewController: MAMapViewDelegate { let annotation = MemberAnnotation(member: updatedMe) mapView.addAnnotation(annotation) rememberAnnotation(annotation) + applyDisplayCoordinates() + bringAnnotationToFront(userId: selectedMemberId) } - if !isMemberPanelShown { - mapView.setCenter(coordinate, animated: true) - mapView.setUserTrackingMode(.follow, animated: true) + if isAutoFollowingUser, mapView.userTrackingMode != .follow { + mapView.setUserTrackingMode(.follow, animated: false) } } + + func mapView(_ mapView: MAMapView!, mapWillMoveByUser wasUserAction: Bool) { + guard wasUserAction else { return } + stopFollowingCurrentUser() + } } // 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 #if !targetEnvironment(simulator) // MARK: - CLLocationManagerDelegate (heading only) diff --git a/QuickLocation/Section/Home/InteractionEmojiCell.swift b/QuickLocation/Section/Home/InteractionEmojiCell.swift index bfd27aa4..9485ea4a 100644 --- a/QuickLocation/Section/Home/InteractionEmojiCell.swift +++ b/QuickLocation/Section/Home/InteractionEmojiCell.swift @@ -34,6 +34,13 @@ final class InteractionEmojiCell: UICollectionViewCell { static let normalFileNames: [String] = normalPngMap.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 = { let v = UIImageView() v.contentMode = .scaleAspectFit diff --git a/QuickLocation/Section/Home/MemberPhoneReportView.swift b/QuickLocation/Section/Home/MemberPhoneReportView.swift index 77a8f6a5..7b43befb 100644 --- a/QuickLocation/Section/Home/MemberPhoneReportView.swift +++ b/QuickLocation/Section/Home/MemberPhoneReportView.swift @@ -29,7 +29,6 @@ final class MemberPhoneReportView: UIView { static let leftPanelAspect: CGFloat = 147 / 164 static let panelGap: CGFloat = 13 static let cardInset: CGFloat = 12 - static let usageCardAspect: CGFloat = 69 / 146 static let usageUnlockGap: CGFloat = 9 } @@ -162,7 +161,6 @@ final class MemberPhoneReportView: UIView { usageCard.layoutChain .top() .edgesHorzontal() - .heightToWidth(Layout.usageCardAspect) usageIcon.image = UIImage(named: "Home/member_usage_count") usageIcon.contentMode = .scaleAspectFit @@ -204,7 +202,7 @@ final class MemberPhoneReportView: UIView { unlockCard.layoutChain .topToBottomOfView(usageCard, offset: Layout.usageUnlockGap) .edgesHorzontal() - .heightToWidth(Layout.usageCardAspect) + .heightToView(usageCard) .bottom() unlockIcon.image = UIImage(named: "Home/member_daily_unlock") diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationHeaderView.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationHeaderView.swift index 177908c3..a1fce517 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationHeaderView.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationHeaderView.swift @@ -25,6 +25,9 @@ final class SearchLocationHeaderView: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func startMarqueeAnimation() { + marqueeScrollView.isHidden = true + return + marqueeScrollView.layer.removeAllAnimations() marqueeScrollView.contentOffset.x = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in @@ -46,6 +49,7 @@ final class SearchLocationHeaderView: UIView { addSubview(marqueeScrollView) heroImageView.backgroundColor = .clear + heroImageView.contentMode = .scaleAspectFit heroImageView.image = UIImage(named: "SearchLocation/header_bg") heroImageView.layoutChain .top() @@ -54,18 +58,16 @@ final class SearchLocationHeaderView: UIView { .height(190) let count = Int.random(in: 8000...15000) - let usageText = "有 \(count) 人正在使用此功能" - let attr = NSMutableAttributedString(string: usageText) - if let range = usageText.range(of: "\(count)") { - attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), - range: NSRange(range, in: usageText)) - } - usageLab.attributedText = attr + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.groupingSeparator = "," + let countText = formatter.string(from: NSNumber(value: count)) ?? "\(count)" + usageLab.text = "有\(countText)人正在使用此功能" usageLab.font = .systemFont(ofSize: 14, weight: .medium) usageLab.textColor = UIColor(hexStr: "#999999") usageLab.textAlignment = .center usageLab.layoutChain - .topToBottomOfView(heroImageView, offset: 8) + .topToBottomOfView(heroImageView, offset: 42) .centerX() headlineLab.text = "实时查看TA的位置" @@ -73,7 +75,7 @@ final class SearchLocationHeaderView: UIView { headlineLab.textColor = UIColor(hexStr: "#293445") headlineLab.textAlignment = .center headlineLab.layoutChain - .topToBottomOfView(usageLab, offset: 16) + .topToBottomOfView(usageLab, offset: 37) .centerX() subheadLab.text = "为你安心守护出行安全" @@ -81,17 +83,14 @@ final class SearchLocationHeaderView: UIView { subheadLab.textColor = UIColor(hexStr: "#293445") subheadLab.textAlignment = .center subheadLab.layoutChain - .topToBottomOfView(headlineLab, offset: 6) + .topToBottomOfView(headlineLab, offset: 18) .centerX() + .bottom() + marqueeScrollView.isHidden = true marqueeScrollView.backgroundColor = .clear marqueeScrollView.showsHorizontalScrollIndicator = false marqueeScrollView.isScrollEnabled = false - marqueeScrollView.layoutChain - .topToBottomOfView(subheadLab, offset: 12) - .edgesHorzontal() - .height(36) - .bottom() } private func randomPhoneNumber() -> String { diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift index 7efe47ca..a5ca136e 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultVC.swift @@ -25,22 +25,37 @@ class SearchLocationResultVC: BaseViewController { override func viewDidLoad() { super.viewDidLoad() - - let isVip = AppContextManager.shared.vip > 1 - rootView.unlockVipView.isHidden = isVip - rootView.scrollView.isHidden = !isVip - reactiveAction() + applyResultPresentation() requestPhoneArea() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) 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() { rootView.actionBtn.rx.tap.subscribe(onNext: { _ in + guard self.isVipUnlocked() else { + AppRouter.push(Route.vipRecharge) + return + } switch self.code { case 0: NotificationCenter.default.post(name: .ShowMemberLocationNotification, @@ -84,8 +99,6 @@ class SearchLocationResultVC: BaseViewController { } private func handleCode() { - guard AppContextManager.shared.vip > 1 else { return } - rootView.actionBtn.isHidden = true rootView.inviteView.isHidden = true @@ -114,7 +127,9 @@ class SearchLocationResultVC: BaseViewController { ) rootView.actionBtn.setTitle("邀请TA加入", for: .normal) rootView.actionBtn.isHidden = false - requestGroupInfo() + if groupModel == nil { + requestGroupInfo() + } default: break } @@ -123,12 +138,10 @@ class SearchLocationResultVC: BaseViewController { private func requestPhoneArea() { SystemService.phoneArea(phone: phone).subscribe(onNext: { response in guard let data = response.data else { return } - self.handleCode() let province = data["Province"].safeString let city = data["City"].safeString - let area = "\(province)·\(city)" - self.rootView.phoneAreaLab.text = area - self.rootView.unlockVipPhoneAreaLab.text = area + self.rootView.phoneAreaLab.text = "\(province)·\(city)" + self.applyResultPresentation() }).disposed(by: disposeBag) } diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift index 3c3c6116..f15976a5 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationResultView.swift @@ -4,18 +4,13 @@ // import UIKit -import RxSwift -import RxCocoa import Lottie class SearchLocationResultView: UIView { - var disposeBag = DisposeBag() - let headerView = SearchLocationHeaderView() private func setupUI() { - addSubview(unlockVipView) addSubview(navBgView) addSubview(navView) addSubview(scrollView) @@ -28,8 +23,6 @@ class SearchLocationResultView: UIView { .edges(excludingEdge: .bottom) .height(kNaviHeight) - unlockVipView.layoutChain.edges() - scrollView.layoutChain .topToBottomOfView(navView) .edges(excludingEdge: .top) @@ -104,11 +97,19 @@ class SearchLocationResultView: UIView { locationRow.addSubview(pinIcon) 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 .leftToRightOfView(pinIcon, offset: 6) .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() let pillContainer = UIView() @@ -152,6 +153,15 @@ class SearchLocationResultView: UIView { 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 = { let label = UILabel() label.font = .systemFont(ofSize: 16, weight: .medium) @@ -241,65 +251,6 @@ class SearchLocationResultView: UIView { 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) { super.init(frame: .zero) backgroundColor = .white diff --git a/QuickLocation/Section/Home/SearchLocation/SearchLocationView.swift b/QuickLocation/Section/Home/SearchLocation/SearchLocationView.swift index b09b53b3..70ba01da 100644 --- a/QuickLocation/Section/Home/SearchLocation/SearchLocationView.swift +++ b/QuickLocation/Section/Home/SearchLocation/SearchLocationView.swift @@ -108,9 +108,9 @@ class SearchLocationView: UIView { contentView.addSubview(searchInputView) searchInputView.layoutChain - .topToBottomOfView(headerView, offset: 20) + .topToBottomOfView(headerView, offset: 45) .edgesHorzontal(15) - .bottom(kSafeBottomMargin + 30) + .bottom(kSafeBottomMargin + 30, relation: .greaterThanOrEqual) return view }() diff --git a/QuickLocation/Section/Map/MemberAnnotationView.swift b/QuickLocation/Section/Map/MemberAnnotationView.swift index fbfdf42b..00573284 100644 --- a/QuickLocation/Section/Map/MemberAnnotationView.swift +++ b/QuickLocation/Section/Map/MemberAnnotationView.swift @@ -131,6 +131,8 @@ final class MemberAnnotationView: MAAnnotationView { canShowCallout = false isEnabled = true isUserInteractionEnabled = true + // 不设 image 时高德按默认大头针锚点,自定义 bounds 会偏;给 1px 透明图让 SDK 用 bounds + image = Self.transparentPixel addSubview(containerView) containerView.isUserInteractionEnabled = true @@ -172,6 +174,20 @@ final class MemberAnnotationView: MAAnnotationView { 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() { guard let ann = annotation as? MemberAnnotation else { return } onTap?(ann) @@ -202,8 +218,6 @@ final class MemberAnnotationView: MAAnnotationView { bounds = CGRect(x: 0, y: 0, width: totalWidth, height: totalHeight) containerView.frame = bounds - - // 色点中心对准 annotation 坐标(负 y 将视图上移) centerOffset = CGPoint(x: 0, y: -(totalHeight / 2 - Self.dotOuterSize / 2)) if showsNameTag { @@ -257,6 +271,15 @@ final class MemberAnnotationView: MAAnnotationView { } // 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 { if member.isCurrentUser { return currentUserDotColor diff --git a/QuickLocation/Section/Mine/MinePhotoWallView.swift b/QuickLocation/Section/Mine/MinePhotoWallView.swift index 3e23aab1..d32ca167 100644 --- a/QuickLocation/Section/Mine/MinePhotoWallView.swift +++ b/QuickLocation/Section/Mine/MinePhotoWallView.swift @@ -175,6 +175,8 @@ final class MinePhotoWallView: UIView { daysBadge.layoutChain .leftToRightOfView(daysPrefixLab, offset: 4) .centerY() + .width(37) + .height(26) daysSuffixLab.layoutChain .leftToRightOfView(daysBadge, offset: 4) .right() @@ -192,15 +194,31 @@ final class MinePhotoWallView: UIView { return label }() - private lazy var daysBadge: UILabel = { - let label = PaddingLabel() + private lazy var daysBadge: UIView = { + 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.textColor = UIColor(hexStr: "#16B3FF") - label.backgroundColor = UIColor(hexStr: "#E3F6FF") - label.cornerRadius = 4 - label.clipsToBounds = true label.textAlignment = .center - label.insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.6 return label }() @@ -267,7 +285,7 @@ final class MinePhotoWallView: UIView { daysRow.layoutChain .centerY(titleLab) .centerX() - .height(25) + .height(26) idRow.layoutChain .topToBottomOfView(titleLab, offset: 6) @@ -424,7 +442,7 @@ final class MinePhotoWallView: UIView { } else { titleLab.isHidden = true daysRow.isHidden = false - daysBadge.text = model.joinDaysDisplay + daysLab.text = model.joinDaysDisplay } idLab.text = "ID:\(model.user_id)" } diff --git a/QuickLocation/Section/Mine/MoodStatus/MoodStatusView.swift b/QuickLocation/Section/Mine/MoodStatus/MoodStatusView.swift index 8d58d25d..4fb1f5f1 100644 --- a/QuickLocation/Section/Mine/MoodStatus/MoodStatusView.swift +++ b/QuickLocation/Section/Mine/MoodStatus/MoodStatusView.swift @@ -133,14 +133,23 @@ final class MoodStatusView: UIView { } 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 } lastLayoutWidth = cardWidth - collectionLayout.itemSize = CGSize(width: 60, height: 60) - collectionLayout.hSpacing = 20 + let columns: CGFloat = 4 + 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.sectionInset = UIEdgeInsets(top: 23, left: 23, bottom: 0, right: 23) + collectionLayout.sectionInset = UIEdgeInsets(top: 23, left: sideInset, bottom: 0, right: sideInset) collectionLayout.invalidateLayout() } diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift index 3fd1edec..0c6948db 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift @@ -48,6 +48,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { private let imageEditorView = UIView() private let voiceEditorView = UIView() private let selectedImageView = UIImageView() + private let selectedImageMaskView = UIView() private let imagePlaceholderLabel = UILabel() private let captionField = UITextField() private let captionCountLabel = UILabel() @@ -108,10 +109,11 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { func setSelectedImage(_ image: UIImage?) { selectedImageView.image = image selectedImageView.isHidden = image == nil + selectedImageMaskView.isHidden = image == nil imagePlaceholderLabel.isHidden = image != nil imageThumbnailView.image = image imageThumbnailButton.isHidden = image == nil - deleteImageButton.isHidden = image == nil +// deleteImageButton.isHidden = image == nil addTile.alpha = 1 updateAddTilePosition(hasImage: image != nil) } @@ -435,11 +437,16 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { imageEditorView.layer.cornerRadius = scaled(22) imageEditorView.clipsToBounds = true + selectedImageMaskView.backgroundColor = .black.withAlphaComponent(0.2) + selectedImageMaskView.isHidden = true selectedImageView.contentMode = .scaleAspectFill selectedImageView.clipsToBounds = true imageEditorView.addSubview(selectedImageView) selectedImageView.layoutChain.edges() - + + imageEditorView.addSubview(selectedImageMaskView) + selectedImageMaskView.layoutChain.edges() + imagePlaceholderLabel.text = "点击下方添加一张图片" imagePlaceholderLabel.font = .systemFont(ofSize: scaled(14), weight: .medium) imagePlaceholderLabel.textColor = UIColor(hexStr: "#9298A1") diff --git a/QuickLocation/Section/PopupWindow/PromotionalActivitiesView.swift b/QuickLocation/Section/PopupWindow/PromotionalActivitiesView.swift index 5927b982..0e6e86f2 100644 --- a/QuickLocation/Section/PopupWindow/PromotionalActivitiesView.swift +++ b/QuickLocation/Section/PopupWindow/PromotionalActivitiesView.swift @@ -20,13 +20,24 @@ class PromotionalActivitiesView: UIView { private func setupRx() { navView.onBackTapped = { VipWaivePopView.show() } + + backBtn.rx.tap.subscribe(onNext: { _ in + AppRouter.shared.popOrDismiss() + }) + .disposed(by: disposeBag) } private func setupUI() { addSubview(scrollView) - + addSubview(backBtn) addSubview(navView) + backBtn.layoutChain + .top(kStatusBarHeight + 5) + .left(15) + .width(36) + .height(36) + navView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) @@ -44,6 +55,13 @@ class PromotionalActivitiesView: UIView { 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 = { let view = UIScrollView() view.backgroundColor = UIColor(hexStr: "#F6F9FB") diff --git a/QuickLocation/Section/Schedule/LocationPicker/LocationPickerVC.swift b/QuickLocation/Section/Schedule/LocationPicker/LocationPickerVC.swift index 57b77331..e2b85d55 100644 --- a/QuickLocation/Section/Schedule/LocationPicker/LocationPickerVC.swift +++ b/QuickLocation/Section/Schedule/LocationPicker/LocationPickerVC.swift @@ -317,7 +317,7 @@ extension LocationPickerVC: MAMapViewDelegate { } else { view?.annotation = annotation } - view?.image = UIImage(named: "Schedule/location") + view?.image = UIImage(named: "IM/location_pin") view?.centerOffset = CGPoint(x: 0, y: -30) return view } diff --git a/QuickLocation/UIKit/CollectionHFlowLayout.swift b/QuickLocation/UIKit/CollectionHFlowLayout.swift index 4d61b918..458c9fab 100644 --- a/QuickLocation/UIKit/CollectionHFlowLayout.swift +++ b/QuickLocation/UIKit/CollectionHFlowLayout.swift @@ -22,6 +22,11 @@ class CollectionHFlowLayout: UICollectionViewLayout { var contentWidth: CGFloat = 0 var page = 0 // MARK: - Lifecycle + private var pageWidth: CGFloat { + let width = collectionView?.bounds.width ?? 0 + return width > 1 ? width : UIScreen.main.bounds.width + } + override func prepare() { super.prepare() attributesArr = [] @@ -29,6 +34,7 @@ class CollectionHFlowLayout: UICollectionViewLayout { page = 0 let pageTotal = rows * colums guard let itemCount = collectionView?.numberOfItems(inSection: 0) else { return } + let pageWidth = self.pageWidth for index in 0.. Bool { + abs((collectionView?.bounds.width ?? 0) - newBounds.width) > 0.5 + } @@ -65,6 +75,6 @@ class CollectionHFlowLayout: UICollectionViewLayout { override var collectionViewContentSize: CGSize { var height = itemSize.height * CGFloat(rows) 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) } }