diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 1ea7fde2..966e7b9f 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ 305A768E2FCA8C7000227D26 /* SignPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74CD2FCA8C7000227D26 /* SignPlugin.swift */; }; 305A768F2FCA8C7000227D26 /* SystemAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74CE2FCA8C7000227D26 /* SystemAPI.swift */; }; 305A76902FCA8C7000227D26 /* UserAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74CF2FCA8C7000227D26 /* UserAPI.swift */; }; + 55B230033032000100785004 /* PigeonAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B230023032000100785003 /* PigeonAPI.swift */; }; + 55B230013032000100785002 /* UploadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B230003032000100785001 /* UploadService.swift */; }; + 55B230053032000100785006 /* PigeonService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B230043032000100785005 /* PigeonService.swift */; }; 305A76912FCA8C7000227D26 /* Constant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74D22FCA8C7000227D26 /* Constant.swift */; }; 305A76922FCA8C7000227D26 /* LogUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74D32FCA8C7000227D26 /* LogUtils.swift */; }; 305A76932FCA8C7000227D26 /* AddImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A74D52FCA8C7000227D26 /* AddImageCell.swift */; }; @@ -87,6 +90,7 @@ 305A76D22FCA8C7000227D26 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A75292FCA8C7000227D26 /* ThemeManager.swift */; }; 305A76D32FCA8C7000227D26 /* URLManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A752B2FCA8C7000227D26 /* URLManager.swift */; }; 305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A752E2FCA8C7000227D26 /* GroupModel.swift */; }; + 55B230073032000100785008 /* PigeonModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B230063032000100785007 /* PigeonModel.swift */; }; 305A76D52FCA8C7000227D26 /* SystemResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A752F2FCA8C7000227D26 /* SystemResponse.swift */; }; 305A76D62FCA8C7000227D26 /* ImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A75312FCA8C7000227D26 /* ImagePlugin.swift */; }; 305A76D72FCA8C7000227D26 /* NotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305A75322FCA8C7000227D26 /* NotEmpty.swift */; }; @@ -374,6 +378,10 @@ C15D41A1770F5B4A02E5FC59 /* AppRestrictShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB29D0618C4BE76F2CA5261 /* AppRestrictShared.swift */; }; C3C4ACC1AE3CAF5BA21328B7 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 226CA98495C2884EBB1D1372 /* Foundation.framework */; }; C6A99DEE361AB31477F88475 /* LockDistractView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC46E80B848E4012D192E4CE /* LockDistractView.swift */; }; + 55B230093032000100785010 /* LockDistractPermissionPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B230083032000100785009 /* LockDistractPermissionPopView.swift */; }; + 55B232013034000100785031 /* PigeonUploadPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B232003034000100785030 /* PigeonUploadPopView.swift */; }; + 55B232033034000100785033 /* PigeonWatermarkCameraVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B232023034000100785032 /* PigeonWatermarkCameraVC.swift */; }; + 55B232053034000100785035 /* PigeonWatermarkCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B232043034000100785034 /* PigeonWatermarkCameraView.swift */; }; CC53DE2CA88E529129547A52 /* ShieldConfigurationExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0C47E0012491DB1371FD5E53 /* ShieldConfigurationExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D3C101B198090A9F4D93E44F /* FamilyControls.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4CD6B965A124276661A84C4C /* FamilyControls.framework */; }; D60B79067C80CD6AF50D63FE /* FamilyControls.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4CD6B965A124276661A84C4C /* FamilyControls.framework */; }; @@ -436,6 +444,9 @@ 305A74CD2FCA8C7000227D26 /* SignPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignPlugin.swift; sourceTree = ""; }; 305A74CE2FCA8C7000227D26 /* SystemAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAPI.swift; sourceTree = ""; }; 305A74CF2FCA8C7000227D26 /* UserAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAPI.swift; sourceTree = ""; }; + 55B230023032000100785003 /* PigeonAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonAPI.swift; sourceTree = ""; }; + 55B230003032000100785001 /* UploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadService.swift; sourceTree = ""; }; + 55B230043032000100785005 /* PigeonService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonService.swift; sourceTree = ""; }; 305A74D12FCA8C7000227D26 /* adhoc.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = adhoc.xcconfig; sourceTree = ""; }; 305A74D22FCA8C7000227D26 /* Constant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constant.swift; sourceTree = ""; }; 305A74D32FCA8C7000227D26 /* LogUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogUtils.swift; sourceTree = ""; }; @@ -507,6 +518,7 @@ 305A75292FCA8C7000227D26 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; 305A752B2FCA8C7000227D26 /* URLManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLManager.swift; sourceTree = ""; }; 305A752E2FCA8C7000227D26 /* GroupModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupModel.swift; sourceTree = ""; }; + 55B230063032000100785007 /* PigeonModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonModel.swift; sourceTree = ""; }; 305A752F2FCA8C7000227D26 /* SystemResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemResponse.swift; sourceTree = ""; }; 305A75312FCA8C7000227D26 /* ImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePlugin.swift; sourceTree = ""; }; 305A75322FCA8C7000227D26 /* NotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotEmpty.swift; sourceTree = ""; }; @@ -797,6 +809,10 @@ 98E91E51811235EAD0985421 /* DeviceActivityMonitorExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DeviceActivityMonitorExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; A1B2C3D52FDABC8C009215C1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; AC46E80B848E4012D192E4CE /* LockDistractView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LockDistractView.swift; sourceTree = ""; }; + 55B230083032000100785009 /* LockDistractPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockDistractPermissionPopView.swift; sourceTree = ""; }; + 55B232003034000100785030 /* PigeonUploadPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonUploadPopView.swift; sourceTree = ""; }; + 55B232023034000100785032 /* PigeonWatermarkCameraVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonWatermarkCameraVC.swift; sourceTree = ""; }; + 55B232043034000100785034 /* PigeonWatermarkCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonWatermarkCameraView.swift; sourceTree = ""; }; BE3D39F325FC7624C1D914D6 /* ITunesSearchService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ITunesSearchService.swift; path = AppRestrict/ITunesSearchService.swift; sourceTree = ""; }; BFFBDE92B94E7004F6B642AD /* AppRestrictShieldSettingsVC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppRestrictShieldSettingsVC.swift; path = AppRestrict/AppRestrictShieldSettingsVC.swift; sourceTree = ""; }; D0112B7865829D5B6A4A0BD0 /* AppRestrictVC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppRestrictVC.swift; path = AppRestrict/AppRestrictVC.swift; sourceTree = ""; }; @@ -914,6 +930,7 @@ 305A74CD2FCA8C7000227D26 /* SignPlugin.swift */, 305A74CE2FCA8C7000227D26 /* SystemAPI.swift */, 305A74CF2FCA8C7000227D26 /* UserAPI.swift */, + 55B230023032000100785003 /* PigeonAPI.swift */, 30BAB8502FCD331C00C33B5C /* GroupAPI.swift */, 30D891F42FE22E0600E958FD /* OrderAPI.swift */, 30D74AB52FEA34FF0050EB2C /* ItineraryAPI.swift */, @@ -1205,6 +1222,7 @@ isa = PBXGroup; children = ( 305A752E2FCA8C7000227D26 /* GroupModel.swift */, + 55B230063032000100785007 /* PigeonModel.swift */, 305A752F2FCA8C7000227D26 /* SystemResponse.swift */, 30EFF3DF2FDA9CE300EB35D4 /* EmergencyContactModel.swift */, ); @@ -1391,6 +1409,8 @@ children = ( 305A763B2FCA8C7000227D26 /* SystemService.swift */, 305A763C2FCA8C7000227D26 /* UserService.swift */, + 55B230003032000100785001 /* UploadService.swift */, + 55B230043032000100785005 /* PigeonService.swift */, 30BAB8522FCD337C00C33B5C /* GroupService.swift */, 30D891F62FE22E6E00E958FD /* OrderService.swift */, 30D74AB72FEA36A50050EB2C /* ItineraryService.swift */, @@ -2058,6 +2078,9 @@ 55B219C3302A000100784772 /* PigeonMessageView.swift */, 55B219C5302A000100784775 /* PigeonMessageHistoryVC.swift */, 55B219C7302A000100784777 /* PigeonMessageHistoryView.swift */, + 55B232003034000100785030 /* PigeonUploadPopView.swift */, + 55B232023034000100785032 /* PigeonWatermarkCameraVC.swift */, + 55B232043034000100785034 /* PigeonWatermarkCameraView.swift */, ); path = PigeonMessage; sourceTree = ""; @@ -2105,6 +2128,7 @@ children = ( F9CFFB14BA2ECC259151BCDA /* LockDistractVC.swift */, AC46E80B848E4012D192E4CE /* LockDistractView.swift */, + 55B230083032000100785009 /* LockDistractPermissionPopView.swift */, ); path = LockDistract; sourceTree = ""; @@ -2356,6 +2380,9 @@ 305A768F2FCA8C7000227D26 /* SystemAPI.swift in Sources */, 30C4C0222FDC0ED3009215C1 /* GroupInfoVC.swift in Sources */, 305A76902FCA8C7000227D26 /* UserAPI.swift in Sources */, + 55B230033032000100785004 /* PigeonAPI.swift in Sources */, + 55B230013032000100785002 /* UploadService.swift in Sources */, + 55B230053032000100785006 /* PigeonService.swift in Sources */, 30EFF3DE2FDA982C00EB35D4 /* EmergencyContactAddVC.swift in Sources */, 30D87CDF2FDFF1A100E958FD /* QuickMessageView.swift in Sources */, 305A76912FCA8C7000227D26 /* Constant.swift in Sources */, @@ -2452,6 +2479,9 @@ 55B219C4302A000100784773 /* PigeonMessageView.swift in Sources */, 55B219C6302A000100784776 /* PigeonMessageHistoryVC.swift in Sources */, 55B219C8302A000100784778 /* PigeonMessageHistoryView.swift in Sources */, + 55B232013034000100785031 /* PigeonUploadPopView.swift in Sources */, + 55B232033034000100785033 /* PigeonWatermarkCameraVC.swift in Sources */, + 55B232053034000100785035 /* PigeonWatermarkCameraView.swift in Sources */, 30B819F22FF3CE3C00FAB693 /* ItineraryTraceView.swift in Sources */, 305A76B72FCA8C7000227D26 /* UIView+Extension.swift in Sources */, 305A76B82FCA8C7000227D26 /* UIViewController+Extension.swift in Sources */, @@ -2531,6 +2561,7 @@ 55B217D13022F0A000784727 /* UnlockCountManager.swift in Sources */, 305A76D32FCA8C7000227D26 /* URLManager.swift in Sources */, 305A76D42FCA8C7000227D26 /* GroupModel.swift in Sources */, + 55B230073032000100785008 /* PigeonModel.swift in Sources */, 305A798C2FCAB99300227D26 /* HomeViewModel.swift in Sources */, 30EFF3D12FDA69EC00EB35D4 /* AvatarIconListVC.swift in Sources */, 30B74B4B2FF390EF00F6744D /* DrivingAPI.swift in Sources */, @@ -2687,6 +2718,7 @@ EA13C1E21EC94958AC9755A8 /* FamilyActivityPickerHost.swift in Sources */, B52F13A261ED273A3A58183F /* SelectActivityVC.swift in Sources */, C6A99DEE361AB31477F88475 /* LockDistractView.swift in Sources */, + 55B230093032000100785010 /* LockDistractPermissionPopView.swift in Sources */, E3D4129BDA33540BA4E1292E /* LockDistractVC.swift in Sources */, E866CAAE7D4E74EB7AD608B9 /* ITunesSearchService.swift in Sources */, DB67C2EB2521042829A3DBA5 /* PairGuideStepsView.swift in Sources */, diff --git a/QuickLocation/API/PigeonAPI.swift b/QuickLocation/API/PigeonAPI.swift new file mode 100644 index 00000000..f389a199 --- /dev/null +++ b/QuickLocation/API/PigeonAPI.swift @@ -0,0 +1,73 @@ +// +// PigeonAPI.swift +// QuickLocation +// + +import Moya +internal import Alamofire + +enum PigeonAPI { + /// 发送飞鸽传书 + /// - Parameters: + /// - groupKey: 圈子 key + /// - toUsers: 收件人加密 id + /// - msgType: 1 图片 2 语音 + /// - msg: 上传返回的 file id + /// - textMsg: 留言文本 + /// - expireTime: 过期秒数,0 表示服务端默认 7 天 + case send(groupKey: String, toUsers: [String], msgType: Int, msg: String, textMsg: String, expireTime: Int) + /// 已发送的传书记录 + case sent(groupKey: String) + /// 收件箱 + case inbox + /// 阅后即焚 + case burn(messageUUID: String) +} + +extension PigeonAPI: MultiTargetProtocol { + var path: String { + switch self { + case .send: + return "mapi/pigeon/send" + case .sent: + return "mapi/pigeon/sent" + case .inbox: + return "mapi/pigeon/inbox/messages" + case .burn: + return "mapi/pigeon/burn" + } + } + + var method: Moya.Method { + switch self { + case .sent, .inbox: + return .get + case .send, .burn: + return .post + } + } + + var task: Moya.Task { + switch self { + case let .send(groupKey, toUsers, msgType, msg, textMsg, expireTime): + var params = Parameters() + params["group_key"] = groupKey + params["to_user"] = toUsers + params["msg_type"] = msgType + params["msg"] = msg + params["text_msg"] = textMsg + params["expire_time"] = expireTime + return .requestParameters(parameters: params, encoding: JSONEncoding()) + case let .sent(groupKey): + var params = Parameters() + if !groupKey.isEmpty { + params["group_key"] = groupKey + } + return .requestParameters(parameters: params, encoding: URLEncoding()) + case .inbox: + return .requestParameters(parameters: Parameters(), encoding: URLEncoding()) + case let .burn(messageUUID): + return .requestParameters(parameters: ["message_uuid": messageUUID], encoding: JSONEncoding()) + } + } +} diff --git a/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/Contents.json b/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/Contents.json similarity index 100% rename from QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/Contents.json rename to QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/Contents.json diff --git a/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@2x.png b/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@2x.png similarity index 100% rename from QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@2x.png rename to QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@2x.png diff --git a/QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@3x.png b/QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@3x.png similarity index 100% rename from QuickLocation/Assets.xcassets/Home/unlock_receive.imageset/unlock_receive@3x.png rename to QuickLocation/Assets.xcassets/Home/pigeon_msg.imageset/unlock_receive@3x.png diff --git a/QuickLocation/Assets.xcassets/LockDistract/avatar_lock.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/avatar_lock.imageset/Contents.json new file mode 100644 index 00000000..1f104887 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/avatar_lock.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "avatar_lock@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "avatar_lock@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/avatar_lock.imageset/avatar_lock@2x.png b/QuickLocation/Assets.xcassets/LockDistract/avatar_lock.imageset/avatar_lock@2x.png new file mode 100644 index 00000000..1b4335af Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/avatar_lock.imageset/avatar_lock@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/avatar_lock.imageset/avatar_lock@3x.png b/QuickLocation/Assets.xcassets/LockDistract/avatar_lock.imageset/avatar_lock@3x.png new file mode 100644 index 00000000..e7a35374 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/avatar_lock.imageset/avatar_lock@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/header_hero.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/header_hero.imageset/Contents.json new file mode 100644 index 00000000..b2524bc8 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/header_hero.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "header_hero@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "header_hero@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/header_hero.imageset/header_hero@2x.png b/QuickLocation/Assets.xcassets/LockDistract/header_hero.imageset/header_hero@2x.png new file mode 100644 index 00000000..bc52ed06 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/header_hero.imageset/header_hero@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/header_hero.imageset/header_hero@3x.png b/QuickLocation/Assets.xcassets/LockDistract/header_hero.imageset/header_hero@3x.png new file mode 100644 index 00000000..0e373c50 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/header_hero.imageset/header_hero@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/horn_bird.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/horn_bird.imageset/Contents.json new file mode 100644 index 00000000..a9b7bc5c --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/horn_bird.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "horn_bird@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "horn_bird@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/horn_bird.imageset/horn_bird@2x.png b/QuickLocation/Assets.xcassets/LockDistract/horn_bird.imageset/horn_bird@2x.png new file mode 100644 index 00000000..a1fdaa69 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/horn_bird.imageset/horn_bird@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/horn_bird.imageset/horn_bird@3x.png b/QuickLocation/Assets.xcassets/LockDistract/horn_bird.imageset/horn_bird@3x.png new file mode 100644 index 00000000..df632eab Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/horn_bird.imageset/horn_bird@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/info_circle.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/info_circle.imageset/Contents.json new file mode 100644 index 00000000..1815614b --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/info_circle.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "info_circle@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "info_circle@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/info_circle.imageset/info_circle@2x.png b/QuickLocation/Assets.xcassets/LockDistract/info_circle.imageset/info_circle@2x.png new file mode 100644 index 00000000..64ceca9c Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/info_circle.imageset/info_circle@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/info_circle.imageset/info_circle@3x.png b/QuickLocation/Assets.xcassets/LockDistract/info_circle.imageset/info_circle@3x.png new file mode 100644 index 00000000..1cdae1bd Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/info_circle.imageset/info_circle@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_1.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_1.imageset/Contents.json new file mode 100644 index 00000000..ac7d7719 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_1.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "lock_icon_1@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "lock_icon_1@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_1.imageset/lock_icon_1@2x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_1.imageset/lock_icon_1@2x.png new file mode 100644 index 00000000..337e7b49 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_1.imageset/lock_icon_1@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_1.imageset/lock_icon_1@3x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_1.imageset/lock_icon_1@3x.png new file mode 100644 index 00000000..358dc785 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_1.imageset/lock_icon_1@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_2.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_2.imageset/Contents.json new file mode 100644 index 00000000..72c236b8 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_2.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "lock_icon_2@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "lock_icon_2@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_2.imageset/lock_icon_2@2x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_2.imageset/lock_icon_2@2x.png new file mode 100644 index 00000000..38cc97b6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_2.imageset/lock_icon_2@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_2.imageset/lock_icon_2@3x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_2.imageset/lock_icon_2@3x.png new file mode 100644 index 00000000..e8dfe89c Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_2.imageset/lock_icon_2@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_3.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_3.imageset/Contents.json new file mode 100644 index 00000000..d763e570 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_3.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "lock_icon_3@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "lock_icon_3@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_3.imageset/lock_icon_3@2x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_3.imageset/lock_icon_3@2x.png new file mode 100644 index 00000000..e93e4a0e Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_3.imageset/lock_icon_3@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_3.imageset/lock_icon_3@3x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_3.imageset/lock_icon_3@3x.png new file mode 100644 index 00000000..0d2e8069 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_3.imageset/lock_icon_3@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_4.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_4.imageset/Contents.json new file mode 100644 index 00000000..9b9ae61c --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_4.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "lock_icon_4@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "lock_icon_4@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_4.imageset/lock_icon_4@2x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_4.imageset/lock_icon_4@2x.png new file mode 100644 index 00000000..f4775458 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_4.imageset/lock_icon_4@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_4.imageset/lock_icon_4@3x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_4.imageset/lock_icon_4@3x.png new file mode 100644 index 00000000..aa204616 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_4.imageset/lock_icon_4@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_5.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_5.imageset/Contents.json new file mode 100644 index 00000000..6e177b57 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_5.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "lock_icon_5@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "lock_icon_5@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_5.imageset/lock_icon_5@2x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_5.imageset/lock_icon_5@2x.png new file mode 100644 index 00000000..b6d4b44a Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_5.imageset/lock_icon_5@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/lock_icon_5.imageset/lock_icon_5@3x.png b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_5.imageset/lock_icon_5@3x.png new file mode 100644 index 00000000..041f4270 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/lock_icon_5.imageset/lock_icon_5@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/locked_empty.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/locked_empty.imageset/Contents.json new file mode 100644 index 00000000..f624ce47 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/locked_empty.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "locked_empty@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "locked_empty@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/locked_empty.imageset/locked_empty@2x.png b/QuickLocation/Assets.xcassets/LockDistract/locked_empty.imageset/locked_empty@2x.png new file mode 100644 index 00000000..359d1714 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/locked_empty.imageset/locked_empty@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/locked_empty.imageset/locked_empty@3x.png b/QuickLocation/Assets.xcassets/LockDistract/locked_empty.imageset/locked_empty@3x.png new file mode 100644 index 00000000..c8f60e05 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/locked_empty.imageset/locked_empty@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/member_next.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/member_next.imageset/Contents.json new file mode 100644 index 00000000..0b85e4a6 --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/member_next.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "member_prev@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "member_prev@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/member_next.imageset/member_prev@2x.png b/QuickLocation/Assets.xcassets/LockDistract/member_next.imageset/member_prev@2x.png new file mode 100644 index 00000000..3a0b304c Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/member_next.imageset/member_prev@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/member_next.imageset/member_prev@3x.png b/QuickLocation/Assets.xcassets/LockDistract/member_next.imageset/member_prev@3x.png new file mode 100644 index 00000000..ab0df9fc Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/member_next.imageset/member_prev@3x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/member_prev.imageset/Contents.json b/QuickLocation/Assets.xcassets/LockDistract/member_prev.imageset/Contents.json new file mode 100644 index 00000000..bc731eac --- /dev/null +++ b/QuickLocation/Assets.xcassets/LockDistract/member_prev.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "member_next@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "member_next@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/LockDistract/member_prev.imageset/member_next@2x.png b/QuickLocation/Assets.xcassets/LockDistract/member_prev.imageset/member_next@2x.png new file mode 100644 index 00000000..572e06a2 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/member_prev.imageset/member_next@2x.png differ diff --git a/QuickLocation/Assets.xcassets/LockDistract/member_prev.imageset/member_next@3x.png b/QuickLocation/Assets.xcassets/LockDistract/member_prev.imageset/member_next@3x.png new file mode 100644 index 00000000..28468349 Binary files /dev/null and b/QuickLocation/Assets.xcassets/LockDistract/member_prev.imageset/member_next@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/camera.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/camera.imageset/Contents.json new file mode 100644 index 00000000..33f5c00c --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/camera.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "camera@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "camera@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/camera.imageset/camera@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/camera.imageset/camera@2x.png new file mode 100644 index 00000000..83f870da Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/camera.imageset/camera@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/camera.imageset/camera@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/camera.imageset/camera@3x.png new file mode 100644 index 00000000..9d86172a Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/camera.imageset/camera@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/camera_switch.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/camera_switch.imageset/Contents.json new file mode 100644 index 00000000..eee4a068 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/camera_switch.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "camera_switch@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "camera_switch@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/camera_switch.imageset/camera_switch@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/camera_switch.imageset/camera_switch@2x.png new file mode 100644 index 00000000..a9b30afc Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/camera_switch.imageset/camera_switch@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/camera_switch.imageset/camera_switch@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/camera_switch.imageset/camera_switch@3x.png new file mode 100644 index 00000000..7d8f89ae Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/camera_switch.imageset/camera_switch@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/flash.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/flash.imageset/Contents.json new file mode 100644 index 00000000..c19fdb4f --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/flash.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "flash@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "flash@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/flash.imageset/flash@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/flash.imageset/flash@2x.png new file mode 100644 index 00000000..8f4f990a Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/flash.imageset/flash@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/flash.imageset/flash@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/flash.imageset/flash@3x.png new file mode 100644 index 00000000..57a8bbc8 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/flash.imageset/flash@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@2x.png index 2ae19f9b..bdce166f 100644 Binary files a/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@2x.png and b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@3x.png index d999c0b5..3e220c35 100644 Binary files a/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@3x.png and b/QuickLocation/Assets.xcassets/PigeonMessage/gallery.imageset/gallery@3x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/upload_header.imageset/Contents.json b/QuickLocation/Assets.xcassets/PigeonMessage/upload_header.imageset/Contents.json new file mode 100644 index 00000000..0d1cba21 --- /dev/null +++ b/QuickLocation/Assets.xcassets/PigeonMessage/upload_header.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "scale" : "1x" }, + { "filename" : "upload_header@2x.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "upload_header@3x.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/upload_header.imageset/upload_header@2x.png b/QuickLocation/Assets.xcassets/PigeonMessage/upload_header.imageset/upload_header@2x.png new file mode 100644 index 00000000..d5b744a6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/upload_header.imageset/upload_header@2x.png differ diff --git a/QuickLocation/Assets.xcassets/PigeonMessage/upload_header.imageset/upload_header@3x.png b/QuickLocation/Assets.xcassets/PigeonMessage/upload_header.imageset/upload_header@3x.png new file mode 100644 index 00000000..433716c6 Binary files /dev/null and b/QuickLocation/Assets.xcassets/PigeonMessage/upload_header.imageset/upload_header@3x.png differ diff --git a/QuickLocation/Manager/URL/URLManager.swift b/QuickLocation/Manager/URL/URLManager.swift index 1d2aabf0..3ce1f64d 100644 --- a/QuickLocation/Manager/URL/URLManager.swift +++ b/QuickLocation/Manager/URL/URLManager.swift @@ -86,7 +86,7 @@ extension DefaultsKeys { case -1: // UAT return "https://jsapi.zuom8.cn/" default: // SIT - return "http://172.16.10.24:9243/" + return "http://172.16.10.23:9243/" } } diff --git a/QuickLocation/Model/GroupModel.swift b/QuickLocation/Model/GroupModel.swift index e00cd763..48a22732 100644 --- a/QuickLocation/Model/GroupModel.swift +++ b/QuickLocation/Model/GroupModel.swift @@ -314,6 +314,8 @@ struct GroupMemberModel: Mappable, Equatable { } /// 昵称 var nick_name: String = "" + /// 备注 + var remark: String = "" /// 会员等级 1普通 2白银 3永久 4钻石 var level: Int = 1 var vipIcon: UIImage { @@ -377,6 +379,7 @@ struct GroupMemberModel: Mappable, Equatable { head_pic <- map["head_pic"] level <- (map["level"], kStrTransformInt) nick_name <- map["nick_name"] + remark <- map["remark"] battery <- map["battery"] last_position <- map["last_position"] is_online <- map["is_online"] diff --git a/QuickLocation/Model/PigeonModel.swift b/QuickLocation/Model/PigeonModel.swift new file mode 100644 index 00000000..c5d15ca7 --- /dev/null +++ b/QuickLocation/Model/PigeonModel.swift @@ -0,0 +1,125 @@ +// +// PigeonModel.swift +// QuickLocation +// + +import ObjectMapper +import UIKit + +struct PigeonSentResponse: BaseModelProtocol { + var code: String? + var message: String? + var list: [PigeonSentMessage] = [] + + init?(map: Map) {} + + mutating func mapping(map: Map) { + code <- (map["code"], kIntTransformStr) + message <- map["message"] + list <- map["data"] + } +} + +struct PigeonSentMessage: Mappable { + var message_uuid: String = "" + var group_key: String = "" + var from_user: PigeonSentUser? + var to_user: [PigeonSentUser] = [] + var msg_type: Int = 0 + var msg: String = "" + var text_msg: String = "" + var expire_time: Int64 = 0 + var send_time: Int64 = 0 + + init?(map: Map) {} + + mutating func mapping(map: Map) { + message_uuid <- map["message_uuid"] + group_key <- map["group_key"] + from_user <- map["from_user"] + to_user <- map["to_user"] + msg_type <- map["msg_type"] + msg <- map["msg"] + text_msg <- map["text_msg"] + expire_time <- map["expire_time"] + send_time <- map["send_time"] + } + + var mediaURL: URL? { + let value = msg.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.lowercased().hasPrefix("http"), let url = URL(string: value) else { + return nil + } + return url + } + + func toHistoryItem() -> PigeonHistoryItem { + PigeonHistoryItem( + id: message_uuid.isEmpty ? UUID().uuidString : message_uuid, + dateText: Self.dateText(from: send_time), + timeText: Self.timeText(from: send_time), + kind: msg_type == 2 ? .voice : .image, + caption: text_msg, + image: nil, + mediaURL: mediaURL, + senderAvatar: from_user?.avatarImage, + receiverAvatars: to_user.map(\.avatarImage), + duration: 0 + ) + } + + private static func date(from timestamp: Int64) -> Date { + Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + private static func dateText(from timestamp: Int64) -> String { + let date = date(from: timestamp) + let calendar = Calendar.current + let month = calendar.component(.month, from: date) + let day = calendar.component(.day, from: date) + let weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"] + let weekday = weekdays[calendar.component(.weekday, from: date) - 1] + return "\(month).\(day) \(weekday)" + } + + private static func timeText(from timestamp: Int64) -> String { + let date = date(from: timestamp) + let calendar = Calendar.current + let hour = calendar.component(.hour, from: date) + let minute = calendar.component(.minute, from: date) + let period = hour < 12 ? "上午" : "下午" + let hour12 = hour % 12 == 0 ? 12 : hour % 12 + return String(format: "%@ %d:%02d", period, hour12, minute) + } +} + +struct PigeonSentUser: Mappable { + var user_id: String = "" + var nick_name: String = "" + var remark: String = "" + var head_pic: String = "" + + init?(map: Map) {} + + mutating func mapping(map: Map) { + user_id <- map["user_id"] + nick_name <- map["nick_name"] + remark <- map["remark"] + head_pic <- map["head_pic"] + } + + var displayName: String { + let remarkName = remark.trimmingCharacters(in: .whitespacesAndNewlines) + if !remarkName.isEmpty { return remarkName } + return nick_name + } + + var avatarImage: UIImage { + let iconName = head_pic.trimmingCharacters(in: .whitespacesAndNewlines) + guard !iconName.isEmpty, + let image = UIImage(named: "UserIcon/\(iconName)") else { + return UIImage(named: "UserIcon/1") ?? UIImage() + } + return image + } +} diff --git a/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift b/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift index 4d9d3444..2deb4709 100644 --- a/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift +++ b/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift @@ -8,7 +8,6 @@ import UIKit import RxSwift import RxCocoa -import TagListView final class GroupSettingView: UIView { @@ -706,7 +705,14 @@ final class GroupSettingTagRow: UIView { private let titleLab = UILabel() private let arrowImageView = UIImageView(image: UIImage(named: "GroupSetting/arrow_right")) - private let tagListView = TagListView() + private let tagStack: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 6 + stack.isUserInteractionEnabled = false + return stack + }() private let dividerView = UIView() private var tagTrailingToArrow: NSLayoutConstraint? @@ -722,13 +728,24 @@ final class GroupSettingTagRow: UIView { } func configure(tags: [String], editable: Bool) { - tagListView.removeAllTags() - tagListView.addTags(tags) - tagListView.tagViews.forEach { $0.layer.cornerRadius = 7 } + tagStack.arrangedSubviews.forEach { + tagStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + if tags.count > 2 { + tagStack.addArrangedSubview(GroupSettingTagChip(text: "···", compact: true)) + tags.suffix(2).forEach { tagStack.addArrangedSubview(GroupSettingTagChip(text: $0, compact: false)) } + } else { + tags.forEach { tagStack.addArrangedSubview(GroupSettingTagChip(text: $0, compact: false)) } + } arrowImageView.isHidden = !editable - tagTrailingToArrow?.isActive = editable - tagTrailingToRow?.isActive = !editable - tagListView.invalidateIntrinsicContentSize() + tagTrailingToArrow?.isActive = false + tagTrailingToRow?.isActive = false + if editable { + tagTrailingToArrow?.isActive = true + } else { + tagTrailingToRow?.isActive = true + } } private func setupUI() { @@ -741,36 +758,29 @@ final class GroupSettingTagRow: UIView { arrowImageView.isHidden = true dividerView.backgroundColor = UIColor(hexStr: "#EDEEF0") - tagListView.textFont = .systemFont(ofSize: 14, weight: .regular) - tagListView.textColor = UIColor(hexStr: "#16B3FF") - tagListView.tagBackgroundColor = UIColor(hexStr: "#E4F6FF") - tagListView.paddingX = 12 - tagListView.paddingY = 5 - tagListView.alignment = .right - tagListView.setContentHuggingPriority(.defaultLow, for: .horizontal) - tagListView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + tagStack.setContentHuggingPriority(.required, for: .horizontal) + tagStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - [titleLab, arrowImageView, tagListView, dividerView].forEach { + [titleLab, arrowImageView, tagStack, dividerView].forEach { addSubview($0) $0.translatesAutoresizingMaskIntoConstraints = false } - tagTrailingToArrow = tagListView.trailingAnchor.constraint(equalTo: arrowImageView.leadingAnchor, constant: -10) - tagTrailingToRow = tagListView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16) + tagTrailingToArrow = tagStack.trailingAnchor.constraint(equalTo: arrowImageView.leadingAnchor, constant: -10) + tagTrailingToRow = tagStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16) tagTrailingToRow?.isActive = true NSLayoutConstraint.activate([ titleLab.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), - titleLab.topAnchor.constraint(equalTo: topAnchor, constant: 18), + titleLab.centerYAnchor.constraint(equalTo: centerYAnchor), arrowImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), - arrowImageView.centerYAnchor.constraint(equalTo: titleLab.centerYAnchor), + arrowImageView.centerYAnchor.constraint(equalTo: centerYAnchor), arrowImageView.widthAnchor.constraint(equalToConstant: 16), arrowImageView.heightAnchor.constraint(equalToConstant: 16), - tagListView.leadingAnchor.constraint(equalTo: titleLab.trailingAnchor, constant: 20), - tagListView.topAnchor.constraint(equalTo: topAnchor, constant: 12), - tagListView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12), - tagListView.heightAnchor.constraint(greaterThanOrEqualToConstant: 28), - heightAnchor.constraint(greaterThanOrEqualToConstant: 62), + tagStack.leadingAnchor.constraint(greaterThanOrEqualTo: titleLab.trailingAnchor, constant: 12), + tagStack.centerYAnchor.constraint(equalTo: centerYAnchor), + tagStack.heightAnchor.constraint(equalToConstant: 28), + heightAnchor.constraint(equalToConstant: 62), dividerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), dividerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), dividerView.bottomAnchor.constraint(equalTo: bottomAnchor), @@ -779,6 +789,42 @@ final class GroupSettingTagRow: UIView { } } +private final class GroupSettingTagChip: UIView { + + private let label = UILabel() + private let insets: UIEdgeInsets + + init(text: String, compact: Bool) { + insets = compact + ? UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8) + : UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12) + super.init(frame: .zero) + isUserInteractionEnabled = false + backgroundColor = UIColor(hexStr: "#E4F6FF") + layer.cornerRadius = 8 + clipsToBounds = true + + label.text = text + label.font = .systemFont(ofSize: 14, weight: .regular) + label.textColor = UIColor(hexStr: "#16B3FF") + label.textAlignment = .center + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: insets.left), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -insets.right), + label.topAnchor.constraint(equalTo: topAnchor, constant: insets.top), + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -insets.bottom) + ]) + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + final class GroupSettingActionRow: UIView { init(title: String, diff --git a/QuickLocation/Section/Group/GroupViewController.swift b/QuickLocation/Section/Group/GroupViewController.swift index 377861ec..d200dbf4 100644 --- a/QuickLocation/Section/Group/GroupViewController.swift +++ b/QuickLocation/Section/Group/GroupViewController.swift @@ -179,6 +179,12 @@ final class GroupViewController: BaseViewController { self?.rootView.cycleScrollView.imageURLStringsGroup = config.groupBannerList }) .disposed(by: disposeBag) + + NotificationCenter.default.rx.notification(.RefreshGroupInfoNotification) + .subscribe(onNext: { [weak self] _ in + self?.refreshItineraryPage() + }) + .disposed(by: disposeBag) } private func bindItineraryPage() { @@ -237,8 +243,7 @@ final class GroupViewController: BaseViewController { GroupListPopView.show(groupModel: groupModel) { [weak self] groupKey in guard let self = self, let key = groupKey else { return } GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) - .subscribe(onNext: { [weak self] _ in - self?.refreshItineraryPage() + .subscribe(onNext: { _ in NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) }) .disposed(by: self.disposeBag) diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift index c66233ca..feb482e5 100644 --- a/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoVC.swift @@ -94,7 +94,7 @@ final class MemberInfoVC: BaseViewController { } else { rootView.renderRows([ MemberInfoRow(title: "昵称", value: member?.nick_name ?? "", kind: .text), - MemberInfoRow(title: "备注", value: member?.nick_name ?? "", kind: .disclosure) { + MemberInfoRow(title: "备注", value: member?.remark ?? "", kind: .disclosure) { DLToast.show(text: "敬请期待") }, MemberInfoRow(title: "关系", value: "未绑定", kind: .disclosure) { diff --git a/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift b/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift index d154c460..2e16e46b 100644 --- a/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift +++ b/QuickLocation/Section/Group/MemberInfo/MemberInfoView.swift @@ -335,7 +335,7 @@ extension CommonGroupsPopVC: UITableViewDataSource { private final class CommonGroupReadonlyCell: UITableViewCell { private let cardView = UIView() private let iconView = UIImageView() - private let nameLabel = MemberInfoTagLabel() + private let nameLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -359,13 +359,8 @@ private final class CommonGroupReadonlyCell: UITableViewCell { iconView.contentMode = .scaleAspectFill iconView.layer.cornerRadius = 8 iconView.clipsToBounds = true + nameLabel.textColor = UIColor(hexStr: "#293445") nameLabel.font = .systemFont(ofSize: 14, weight: .medium) - nameLabel.textColor = UIColor(hexStr: "#D5923F") - nameLabel.backgroundColor = UIColor(hexStr: "#FFF9DD") - nameLabel.numberOfLines = 1 - nameLabel.lineBreakMode = .byTruncatingTail - nameLabel.layer.cornerRadius = 7 - nameLabel.clipsToBounds = true } required init?(coder: NSCoder) { diff --git a/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift b/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift index 260c8633..c4b79c9a 100644 --- a/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift +++ b/QuickLocation/Section/Home/Bubble/CreateBubbleSetupView.swift @@ -97,7 +97,7 @@ final class CreateBubbleSetupView: UIView { titleLab.textAlignment = .center titleLab.layoutChain.topToBottomOfView(heroView, offset: 25).centerX() - subtitleLab.text = "设置气泡时间" + subtitleLab.text = "设置气泡后会隐藏您的位置" subtitleLab.font = .systemFont(ofSize: 18, weight: .bold) subtitleLab.textColor = UIColor(hexStr: "#9CA3AF") subtitleLab.layoutChain.topToBottomOfView(titleLab, offset: 19).centerX() diff --git a/QuickLocation/Section/Home/GroupListPopView.swift b/QuickLocation/Section/Home/GroupListPopView.swift index aebb858a..108b6663 100644 --- a/QuickLocation/Section/Home/GroupListPopView.swift +++ b/QuickLocation/Section/Home/GroupListPopView.swift @@ -29,6 +29,7 @@ class GroupListPopView: UIView { private var selectedGroupKey: String = "" private var currentGroupOnlineCount: Int? + private var onlineCountGroupKey: String = "" private var groupList: [GroupInfoModel] = [] { didSet { @@ -188,6 +189,7 @@ extension GroupListPopView { /// 通过 window 从底部弹出圈子列表 static func show(groupModel: GroupModel, currentMembers: [GroupMemberModel]? = nil, + selectedKey: String? = nil, completion: @escaping ((String?) -> Void)) { guard let superView = kKeyWindow else { return } @@ -199,6 +201,10 @@ extension GroupListPopView { pop.frame = CGRect(x: 0, y: 0, width: kScreenWidth, height: kScreenHeight) pop.currentGroupOnlineCount = currentMembers.map { $0.filter(\.is_online).count } pop.groupModel = groupModel + let highlightKey = (selectedKey?.isEmpty == false) ? selectedKey! : groupModel.default_group_key + pop.selectedGroupKey = highlightKey + pop.onlineCountGroupKey = highlightKey + pop.tableView.reloadData() pop.completion = { text in completion(text) GroupListPopView.dismiss() @@ -248,7 +254,7 @@ extension GroupListPopView: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: GroupListCell = tableView.dequeueReusableCell(for: indexPath) let model = groupList[indexPath.row] - let onlineCount: Int? = (model.group_key == groupModel?.default_group_key) + let onlineCount: Int? = (model.group_key == onlineCountGroupKey) ? currentGroupOnlineCount : nil cell.configure(model: model, isSelected: selectedGroupKey == model.group_key, onlineCount: onlineCount) diff --git a/QuickLocation/Section/Home/HomeView2.swift b/QuickLocation/Section/Home/HomeView2.swift index a297775b..580f3aa6 100644 --- a/QuickLocation/Section/Home/HomeView2.swift +++ b/QuickLocation/Section/Home/HomeView2.swift @@ -65,7 +65,7 @@ class HomeView2: UIView { }) .disposed(by: disposeBag) - unlockReceiveBtn.rx.tap + pigeonMsgBtn.rx.tap .subscribe(onNext: { [weak self] in self?.onUnlockReceiveTap?() }) @@ -236,7 +236,7 @@ class HomeView2: UIView { // MARK: - 解锁操作 lazy var actionStackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [unlockRequestBtn, unlockReceiveBtn]) + let stackView = UIStackView(arrangedSubviews: [unlockRequestBtn, pigeonMsgBtn]) stackView.axis = .vertical stackView.alignment = .center stackView.distribution = .fill @@ -245,7 +245,7 @@ class HomeView2: UIView { unlockRequestBtn.layoutChain .width(51) .height(50) - unlockReceiveBtn.layoutChain + pigeonMsgBtn.layoutChain .width(50) .height(50) @@ -259,10 +259,11 @@ class HomeView2: UIView { return btn }() - lazy var unlockReceiveBtn: UIButton = { + lazy var pigeonMsgBtn: UIButton = { let btn = UIButton(type: .custom) - btn.setImage(UIImage(named: "Home/unlock_receive")?.withRenderingMode(.alwaysOriginal), for: .normal) + btn.setImage(UIImage(named: "Home/pigeon_msg")?.withRenderingMode(.alwaysOriginal), for: .normal) btn.imageView?.contentMode = .scaleAspectFit + btn.isHidden = true return btn }() diff --git a/QuickLocation/Section/Home/HomeViewController.swift b/QuickLocation/Section/Home/HomeViewController.swift index 87494ee0..b10b2e84 100644 --- a/QuickLocation/Section/Home/HomeViewController.swift +++ b/QuickLocation/Section/Home/HomeViewController.swift @@ -37,6 +37,7 @@ class HomeViewController: BaseViewController { private var viewModel = HomeViewModel() /// 当前选中的成员(用于 GroupMemberListCell 选中态) private var selectedMemberId: String = "" + private var inboxMessages: [PigeonSentMessage] = [] private let locationManager = CLLocationManager() private var currentHeading: Double = 0 @@ -109,6 +110,7 @@ class HomeViewController: BaseViewController { let manager = AuthorizeManager.manager(type: .locationAlways) rootView.warningView.isHidden = manager?.authorizeStatus() == .authorized + refreshPigeonInbox() } override func viewDidDisappear(_ animated: Bool) { @@ -198,9 +200,14 @@ class HomeViewController: BaseViewController { rootView.onUnlockReceiveTap = { [weak self] in guard let self else { return } - ReceiveMessagePopView.show(items: self.makeMockReceiveMessages()) { _ in - // 预留阅后即焚完成回调,后续接入消息销毁接口。 - } + let items = self.inboxMessages.map(self.makeReceiveDisplayItem) + guard !items.isEmpty else { return } + ReceiveMessagePopView.show( + items: items, + onRequestBurn: { [weak self] item, done in + self?.burnInboxMessage(item, done: done) + } + ) } // 气泡 @@ -307,6 +314,7 @@ class HomeViewController: BaseViewController { self?.requestGroupInfo() } self.requestNotice() + self.refreshPigeonInbox() self.popupQueue = nil self.popupQueue = PopupQueueManager() self.popupQueue?.start(from: self) @@ -457,54 +465,59 @@ class HomeViewController: BaseViewController { } } - private func makeMockReceiveMessages() -> [ReceiveMessageDisplayItem] { - var candidates = viewModel.memberList - let fallbackImage = UIImage(named: "UserIcon/1") - let mockMessages = [ - "想对你说的话都藏在这里,请查收呀", - "今天也要开开心心,注意安全哦" - ] - - guard !candidates.isEmpty else { - return [ - ReceiveMessageDisplayItem( - senderName: "圈子成员", - avatar: fallbackImage, - image: fallbackImage, - message: mockMessages[0] - ), - ReceiveMessageDisplayItem( - senderName: "圈子成员", - avatar: fallbackImage, - image: fallbackImage, - message: mockMessages[1], - kind: .voice, - duration: 10 - ) - ] + private func refreshPigeonInbox() { + guard Defaults[\.loginToken] != nil else { + inboxMessages = [] + rootView.pigeonMsgBtn.isHidden = true + return } + PigeonService.inbox() + .subscribe(onNext: { [weak self] response in + guard let self else { return } + self.inboxMessages = response.list + self.rootView.pigeonMsgBtn.isHidden = response.list.isEmpty + }, onError: { [weak self] _ in + self?.rootView.pigeonMsgBtn.isHidden = self?.inboxMessages.isEmpty ?? true + }) + .disposed(by: disposeBag) + } - if let selectedIndex = candidates.firstIndex(where: { $0.user_id == selectedMemberId }) { - let selectedMember = candidates.remove(at: selectedIndex) - candidates.insert(selectedMember, at: 0) - } + private func makeReceiveDisplayItem(_ message: PigeonSentMessage) -> ReceiveMessageDisplayItem { + ReceiveMessageDisplayItem( + messageUUID: message.message_uuid, + senderName: message.from_user?.displayName ?? "圈子成员", + avatar: message.from_user?.avatarImage, + image: nil, + imageURL: message.msg_type == 2 ? nil : message.mediaURL, + message: message.text_msg, + kind: message.msg_type == 2 ? .voice : .image, + duration: 0, + audioURL: message.msg_type == 2 ? message.mediaURL : nil + ) + } - while candidates.count < 2 { - candidates.append(contentsOf: candidates) - } - - return Array(candidates.prefix(2)).enumerated().map { index, member in - let memberImage = member.userIcon - let displayImage = memberImage.size == .zero ? fallbackImage : memberImage - return ReceiveMessageDisplayItem( - senderName: member.nick_name, - avatar: displayImage, - image: displayImage, - message: mockMessages[index], - kind: index == 1 ? .voice : .image, - duration: index == 1 ? 10 : 0 - ) + private func burnInboxMessage(_ item: ReceiveMessageDisplayItem, done: @escaping (Bool) -> Void) { + guard !item.messageUUID.isEmpty else { + done(true) + return } + DLToast.showLoading() + PigeonService.burn(messageUUID: item.messageUUID) + .subscribe(onNext: { [weak self] _ in + DLToast.dismiss() + guard let self else { + done(true) + return + } + self.inboxMessages.removeAll { $0.message_uuid == item.messageUUID } + self.rootView.pigeonMsgBtn.isHidden = self.inboxMessages.isEmpty + done(true) + }, onError: { error in + DLToast.dismiss() + DLToast.show(text: error.localizedDescription) + done(false) + }) + .disposed(by: disposeBag) } private func requestUserInfo(completion: (() -> Void)? = nil) { @@ -585,8 +598,8 @@ class HomeViewController: BaseViewController { } GroupService.operate(opType: "setdefault", requestData: ["group_key" : groupKey]) - .subscribe(onNext: { [weak self] _ in - self?.requestGroupInfo() + .subscribe(onNext: { _ in + NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) }, onError: { error in DLToast.showError(text: error.localizedDescription) }) diff --git a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift index 74e2c1e5..78b0dd5f 100644 --- a/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift +++ b/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailView.swift @@ -98,7 +98,7 @@ final class PhoneReportDetailView: UIView { .heightToWidth(160/375) navView.layoutChain.top().edgesHorzontal().height(kNaviHeight) - navView.addRightButton(switchGroupBtn) +// navView.addRightButton(switchGroupBtn) addSubview(scrollView) scrollView.showsVerticalScrollIndicator = false diff --git a/QuickLocation/Section/Home/ReceiveMessagePopView.swift b/QuickLocation/Section/Home/ReceiveMessagePopView.swift index a5145656..01c98148 100644 --- a/QuickLocation/Section/Home/ReceiveMessagePopView.swift +++ b/QuickLocation/Section/Home/ReceiveMessagePopView.swift @@ -5,6 +5,7 @@ import UIKit import AVFoundation +import Kingfisher enum ReceiveMessageKind { case image @@ -12,26 +13,32 @@ enum ReceiveMessageKind { } struct ReceiveMessageDisplayItem { + let messageUUID: String let senderName: String let avatar: UIImage? let image: UIImage? + let imageURL: URL? let message: String let kind: ReceiveMessageKind let duration: TimeInterval let audioURL: URL? init( + messageUUID: String = "", senderName: String, avatar: UIImage?, image: UIImage?, + imageURL: URL? = nil, message: String, kind: ReceiveMessageKind = .image, duration: TimeInterval = 0, audioURL: URL? = nil ) { + self.messageUUID = messageUUID self.senderName = senderName self.avatar = avatar self.image = image + self.imageURL = imageURL self.message = message self.kind = kind self.duration = duration @@ -47,6 +54,7 @@ final class ReceiveMessagePopView: UIView { private var messages: [ReceiveMessageDisplayItem] = [] private var item: ReceiveMessageDisplayItem? + private var onRequestBurn: ((ReceiveMessageDisplayItem, @escaping (Bool) -> Void) -> Void)? private var onBurn: ((ReceiveMessageDisplayItem) -> Void)? private var isBurning = false private var effectLayers: [CALayer] = [] @@ -75,7 +83,8 @@ final class ReceiveMessagePopView: UIView { static func show( items: [ReceiveMessageDisplayItem], - onBurn: ((ReceiveMessageDisplayItem) -> Void)? = nil + onBurn: ((ReceiveMessageDisplayItem) -> Void)? = nil, + onRequestBurn: ((ReceiveMessageDisplayItem, @escaping (Bool) -> Void) -> Void)? = nil ) { guard let firstItem = items.first, let window = kKeyWindow else { return } @@ -84,6 +93,7 @@ final class ReceiveMessagePopView: UIView { popup.resetBurnState() popup.messages = items popup.item = firstItem + popup.onRequestBurn = onRequestBurn popup.onBurn = onBurn popup.configure(item: firstItem) @@ -94,9 +104,11 @@ final class ReceiveMessagePopView: UIView { popup.overlayView.alpha = 0 popup.letterView.alpha = 0 popup.reportButton.alpha = 0 + popup.closeButton.alpha = 0 UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut]) { popup.overlayView.alpha = 1 popup.reportButton.alpha = 1 + popup.closeButton.alpha = 1 } popup.animateInitialFlyIn() } @@ -109,6 +121,13 @@ final class ReceiveMessagePopView: UIView { private let letterView = UIView() + private let closeButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "Group/close"), for: .normal) + button.extendEdgeInsets = UIEdgeInsets(top: 15, left: 20, bottom: 20, right: 20) + return button + }() + private let reportButton: UIButton = { let button = UIButton(type: .custom) var configuration = UIButton.Configuration.plain() @@ -239,6 +258,7 @@ final class ReceiveMessagePopView: UIView { private func setupUI() { addSubview(overlayView) addSubview(letterView) + addSubview(closeButton) addSubview(reportButton) overlayView.layoutChain.edges() @@ -252,6 +272,12 @@ final class ReceiveMessagePopView: UIView { .edgesHorzontal(26) .centerY() + closeButton.layoutChain + .topToBottomOfView(letterView, offset: 16) + .centerX() + .width(22) + .height(22) + letterView.addSubview(cardBackgroundView) letterView.addSubview(titleImageView) letterView.addSubview(avatarView) @@ -333,6 +359,7 @@ final class ReceiveMessagePopView: UIView { burnButton.addTarget(self, action: #selector(tapBurn), for: .touchUpInside) reportButton.addTarget(self, action: #selector(tapReport), for: .touchUpInside) + closeButton.addTarget(self, action: #selector(tapClose), for: .touchUpInside) let overlayTap = UITapGestureRecognizer(target: self, action: #selector(tapPlay)) voiceOverlay.addGestureRecognizer(overlayTap) } @@ -341,7 +368,12 @@ final class ReceiveMessagePopView: UIView { stopVoicePlayback(reset: true) nameLabel.text = item.senderName.isEmpty ? "圈子成员" : item.senderName avatarView.image = item.avatar ?? UIImage(named: "UserIcon/1") - messageImageView.image = item.image + messageImageView.kf.cancelDownloadTask() + if let imageURL = item.imageURL { + messageImageView.kf.setImage(with: imageURL, placeholder: item.image) + } else { + messageImageView.image = item.image + } messageLabel.text = String(item.message.prefix(20)) let isVoice = item.kind == .voice @@ -468,6 +500,10 @@ final class ReceiveMessagePopView: UIView { ReceiveMessageReportPopView.show() } + @objc private func tapClose() { + dismiss(animated: true) + } + private func startVoicePlayback() { if audioPlayer == nil { prepareAudioPlayer() @@ -557,12 +593,29 @@ final class ReceiveMessagePopView: UIView { } @objc private func tapBurn() { - guard !isBurning else { return } - isBurning = true + guard !isBurning, let item else { return } burnButton.isUserInteractionEnabled = false voiceOverlay.isUserInteractionEnabled = false stopVoicePlayback(reset: false) - playBurnAnimation() + + let start = { [weak self] in + guard let self else { return } + self.isBurning = true + self.playBurnAnimation() + } + + guard let onRequestBurn else { + start() + return + } + onRequestBurn(item) { success in + if success { + start() + } else { + self.burnButton.isUserInteractionEnabled = true + self.voiceOverlay.isUserInteractionEnabled = true + } + } } private func playBurnAnimation() { @@ -836,6 +889,7 @@ final class ReceiveMessagePopView: UIView { self.resetBurnState() self.messages.removeAll() self.item = nil + self.onRequestBurn = nil self.onBurn = nil } @@ -848,6 +902,7 @@ final class ReceiveMessagePopView: UIView { self.overlayView.alpha = 0 self.letterView.alpha = 0 self.reportButton.alpha = 0 + self.closeButton.alpha = 0 self.letterView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) } completion: { _ in completion() diff --git a/QuickLocation/Section/Home/SOS/SOSPracticeView.swift b/QuickLocation/Section/Home/SOS/SOSPracticeView.swift index 26db470a..b10af6f9 100644 --- a/QuickLocation/Section/Home/SOS/SOSPracticeView.swift +++ b/QuickLocation/Section/Home/SOS/SOSPracticeView.swift @@ -160,7 +160,7 @@ final class SOSPracticeView: UIView, UIScrollViewDelegate { contentView.widthAnchor.constraint(equalTo: verticalScrollView.frameLayoutGuide.widthAnchor), contentView.heightAnchor.constraint(greaterThanOrEqualTo: verticalScrollView.frameLayoutGuide.heightAnchor), - imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 36), + imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 94), imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), imageView.widthAnchor.constraint(equalToConstant: 240), imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor), diff --git a/QuickLocation/Section/Home/SOS/SOSView.swift b/QuickLocation/Section/Home/SOS/SOSView.swift index a723bc12..61d4b3d9 100644 --- a/QuickLocation/Section/Home/SOS/SOSView.swift +++ b/QuickLocation/Section/Home/SOS/SOSView.swift @@ -43,7 +43,6 @@ final class SOSView: UIView { override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = UIColor(hexStr: "#F9F9F9") setupUI() setupActions() configureContacts([]) @@ -51,6 +50,7 @@ final class SOSView: UIView { let hasCompletedPractice = Defaults[\.sosIsPracticeList].contains(AppContextManager.shared.userId) sosPracticeView.isHidden = hasCompletedPractice + backgroundColor = hasCompletedPractice ? UIColor(hexStr: "#F9F9F9") : .white } required init?(coder: NSCoder) { @@ -357,6 +357,7 @@ final class SOSView: UIView { contactScrollView.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12), contactScrollView.trailingAnchor.constraint(equalTo: contactPanel.trailingAnchor, constant: -12), contactScrollView.bottomAnchor.constraint(equalTo: contactPanel.bottomAnchor, constant: -9), + contactScrollView.heightAnchor.constraint(equalToConstant: 46), contactStack.topAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.topAnchor), contactStack.leadingAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.leadingAnchor), @@ -570,9 +571,9 @@ private final class SOSEmergencyContactCard: UIView { override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white - layer.cornerRadius = 13 + layer.cornerRadius = 14 layer.borderWidth = 0.5 - layer.borderColor = UIColor(hexStr: "#E6E6E6").cgColor + layer.borderColor = UIColor(hexStr: "#EDEDED").cgColor phoneBadgeView.contentMode = .scaleAspectFit addSubview(phoneBadgeView) diff --git a/QuickLocation/Section/LockDistract/LockDistractPermissionPopView.swift b/QuickLocation/Section/LockDistract/LockDistractPermissionPopView.swift new file mode 100644 index 00000000..04dc00a2 --- /dev/null +++ b/QuickLocation/Section/LockDistract/LockDistractPermissionPopView.swift @@ -0,0 +1,216 @@ +// +// LockDistractPermissionPopView.swift +// QuickLocation +// + +import UIKit +import RxSwift +import RxCocoa + +final class LockDistractPermissionPopView: UIView { + + private static let shared = LockDistractPermissionPopView(frame: CGRect(origin: .zero, size: kScreenSize)) + + private var disposeBag = DisposeBag() + + static func show() { + guard let superView = kKeyWindow else { return } + let pop = LockDistractPermissionPopView.shared + if pop.superview != nil { + pop.removeFromSuperview() + } + pop.frame = superView.bounds + pop.alpha = 0 + superView.addSubview(pop) + superView.bringSubviewToFront(pop) + pop.layoutIfNeeded() + UIView.animate(withDuration: 0.25) { + pop.alpha = 1 + } + } + + static func dismiss() { + guard shared.superview != nil else { return } + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) { + shared.alpha = 0 + } completion: { _ in + shared.removeFromSuperview() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + bind() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func bind() { + knowBtn.rx.tap + .subscribe(onNext: { _ in + LockDistractPermissionPopView.dismiss() + }) + .disposed(by: disposeBag) + + let tap = UITapGestureRecognizer(target: self, action: #selector(tapMask)) + tap.delegate = self + addGestureRecognizer(tap) + } + + @objc private func tapMask() { + LockDistractPermissionPopView.dismiss() + } + + private func setupUI() { + addSubview(dimView) + addSubview(card) + addSubview(birdView) + card.addSubview(gradientView) + card.addSubview(titleLab) + card.addSubview(row1) + card.addSubview(row2) + card.addSubview(pathLab) + card.addSubview(knowBtn) + + dimView.layoutChain.edges() + + card.layoutChain + .centerY() + .edgesHorzontal(38) + + birdView.layoutChain + .bottomToTopOfView(card, offset: 28) + .centerX() + .width(120) + .height(88) + + gradientView.layoutChain + .top() + .edgesHorzontal() + .height(72) + + titleLab.layoutChain + .top(36) + .centerX() + + row1.layoutChain + .topToBottomOfView(titleLab, offset: 20) + .left(22) + .right(22) + + row2.layoutChain + .topToBottomOfView(row1, offset: 14) + .left(22) + .right(22) + + pathLab.layoutChain + .topToBottomOfView(row2, offset: 8) + .left(40) + .right(22) + + knowBtn.layoutChain + .topToBottomOfView(pathLab, offset: 22) + .edgesHorzontal(20) + .height(48) + .bottom(20) + } + + private lazy var dimView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.55) + return view + }() + + private lazy var card: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 28 + view.clipsToBounds = true + return view + }() + + private lazy var gradientView: UIView = { + GradientHostView() + }() + + private lazy var birdView: UIImageView = { + let iv = UIImageView(image: UIImage(named: "LockDistract/horn_bird")) + iv.contentMode = .scaleAspectFit + return iv + }() + + private lazy var titleLab: UILabel = { + let lab = UILabel() + lab.text = "锁定权限说明" + lab.font = FontManager.boboBold(20) + lab.textColor = UIColor(hexStr: "#293445") + return lab + }() + + private lazy var row1: UIView = { + makeBulletRow("需要Ta开启App配对权限,不然无法锁定") + }() + + private lazy var row2: UIView = { + makeBulletRow("权限开启位置") + }() + + private lazy var pathLab: UILabel = { + let lab = UILabel() + lab.text = "【我的】 – 【权限检测】 – 【配对设置】" + lab.font = .systemFont(ofSize: 13, weight: .medium) + lab.textColor = UIColor(hexStr: "#8A94A6") + lab.numberOfLines = 0 + return lab + }() + + private lazy var knowBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("我知道了", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.titleLabel?.font = FontManager.boboBold(16) + btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + btn.cornerRadius = 16 + return btn + }() + + private func makeBulletRow(_ text: String) -> UIView { + let row = UIView() + let star = UIImageView(image: UIImage(named: "LockDistract/section_star")) + star.contentMode = .scaleAspectFit + let lab = UILabel() + lab.text = text + lab.font = .systemFont(ofSize: 14, weight: .medium) + lab.textColor = UIColor(hexStr: "#293445") + lab.numberOfLines = 0 + row.addSubview(star) + row.addSubview(lab) + star.layoutChain.left().top(2).width(12).height(12) + lab.layoutChain.leftToRightOfView(star, offset: 6).right().top().bottom() + return row + } +} + +private final class GradientHostView: UIView { + override class var layerClass: AnyClass { CAGradientLayer.self } + + override init(frame: CGRect) { + super.init(frame: frame) + guard let layer = layer as? CAGradientLayer else { return } + layer.colors = [ + UIColor(hexStr: "#E7FFF2").cgColor, + UIColor.white.cgColor + ] + layer.startPoint = CGPoint(x: 0.5, y: 0) + layer.endPoint = CGPoint(x: 0.5, y: 1) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +extension LockDistractPermissionPopView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + !card.frame.contains(touch.location(in: self)) + } +} diff --git a/QuickLocation/Section/LockDistract/LockDistractVC.swift b/QuickLocation/Section/LockDistract/LockDistractVC.swift index e4e2342d..c0baf5a0 100644 --- a/QuickLocation/Section/LockDistract/LockDistractVC.swift +++ b/QuickLocation/Section/LockDistract/LockDistractVC.swift @@ -11,6 +11,7 @@ final class LockDistractVC: BaseViewController { private var rootView: LockDistractView! private var groupModel: GroupModel? + private var activeGroupKey: String = "" private var members: [GroupMemberModel] = [] private var selectedIndex = 0 @@ -52,9 +53,9 @@ final class LockDistractVC: BaseViewController { .disposed(by: disposeBag) // 未获取 / 锁定:本期不接业务 - rootView.notFetchedView.rx.tapGesture + rootView.notFetchedView.rx.tap .subscribe(onNext: { _ in - /// TODO + LockDistractPermissionPopView.show() }).disposed(by: disposeBag) } @@ -62,24 +63,30 @@ final class LockDistractVC: BaseViewController { GroupService.groupInfo().subscribe(onNext: { [weak self] response in guard let self, let model = response.model else { return } self.groupModel = model - guard !model.default_group_key.isEmpty else { - self.applyMembers([]) - return - } - GroupService.groupUsers(groupKey: model.default_group_key) - .subscribe(onNext: { [weak self] response in - guard response.isValid(for: model.default_group_key) else { - DLToast.showError(text: response.message ?? "获取圈子成员失败") - return - } - self?.applyMembers(response.list) - }, onError: { error in - DLToast.showError(text: error.localizedDescription) - }) - .disposed(by: self.disposeBag) + self.activeGroupKey = model.default_group_key + self.updateGroupName() + self.loadUsers(for: self.activeGroupKey) }).disposed(by: disposeBag) } + private func loadUsers(for groupKey: String) { + guard !groupKey.isEmpty else { + applyMembers([]) + return + } + GroupService.groupUsers(groupKey: groupKey) + .subscribe(onNext: { [weak self] response in + guard response.isValid(for: groupKey) else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + self?.applyMembers(response.list) + }, onError: { error in + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) + } + private func applyMembers(_ members: [GroupMemberModel]) { self.members = members selectedIndex = 0 @@ -97,10 +104,18 @@ final class LockDistractVC: BaseViewController { private func refreshMember() { guard members.indices.contains(selectedIndex) else { rootView.configureMember(name: " ", avatar: nil) + rootView.configureLocked(false) return } let m = members[selectedIndex] - rootView.configureMember(name: m.nick_name, avatar: m.userIcon) + let name = m.remark.isEmpty ? m.nick_name : m.remark + rootView.configureMember(name: name, avatar: m.userIcon) + rootView.configureLocked(false) + } + + private func updateGroupName() { + let name = groupModel?.groups.first(where: { $0.group_key == activeGroupKey })?.name ?? "" + rootView.setGroupName(name) } private func switchGroup() { @@ -108,14 +123,15 @@ final class LockDistractVC: BaseViewController { loadGroupMembers() return } - GroupListPopView.show(groupModel: groupModel, currentMembers: members) { [weak self] groupKey in - guard let self, let key = groupKey else { return } - GroupService.operate(opType: "setdefault", requestData: ["group_key": key]) - .subscribe(onNext: { [weak self] _ in - NotificationCenter.default.post(name: .RefreshGroupInfoNotification, object: nil) - self?.loadGroupMembers() - }) - .disposed(by: self.disposeBag) + GroupListPopView.show( + groupModel: groupModel, + currentMembers: members, + selectedKey: activeGroupKey + ) { [weak self] groupKey in + guard let self, let key = groupKey, key != self.activeGroupKey else { return } + self.activeGroupKey = key + self.updateGroupName() + self.loadUsers(for: key) } } } diff --git a/QuickLocation/Section/LockDistract/LockDistractView.swift b/QuickLocation/Section/LockDistract/LockDistractView.swift index 3228a6f3..b2b45a23 100644 --- a/QuickLocation/Section/LockDistract/LockDistractView.swift +++ b/QuickLocation/Section/LockDistract/LockDistractView.swift @@ -11,28 +11,29 @@ final class LockDistractView: UIView { var disposeBag = DisposeBag() - let wallpaperNames = [ - "LockDistract/wallpaper_1", - "LockDistract/wallpaper_2", - "LockDistract/wallpaper_3" + let lockIconNames = [ + "LockDistract/lock_icon_1", + "LockDistract/lock_icon_2", + "LockDistract/lock_icon_3", + "LockDistract/lock_icon_4", + "LockDistract/lock_icon_5" ] - private(set) var selectedWallpaperIndex = 0 + private(set) var selectedLockIconIndex = 0 + private(set) var selectedAppIndexes: Set = [] + private(set) var isLocked = false private let copyMaxLength = 20 - // MARK: - Init - override init(frame: CGRect) { super.init(frame: frame) backgroundColor = UIColor(hexStr: "#FAFAFA") setupUI() setupCopyLimit() + applyLockedState() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - // MARK: - Public - func configureMember(name: String, avatar: UIImage?) { memberNameLab.text = name.isEmpty ? " " : name memberAvatar.image = avatar ?? UIImage(named: "Common/default_avatar") @@ -42,69 +43,84 @@ final class LockDistractView: UIView { todayCountLab.text = "\(count)" } - // MARK: - UI + func setGroupName(_ name: String) { + let title = name.isEmpty ? "切换圈子" : " \(name) " + switchGroupBtn.setTitle(title, for: .normal) + } + + func configureLocked(_ locked: Bool) { + isLocked = locked + applyLockedState() + } + + private func applyLockedState() { + avatarLockView.isHidden = !isLocked + styleSectionTitle.isHidden = isLocked + styleCard.isHidden = isLocked + lockedEmptyView.isHidden = !isLocked + appCollectionView.reloadData() + tipLab.text = isLocked + ? "已被锁定情况下,先解锁才能锁定哟~" + : "锁定功能只有在圈主的情况下才能使用哟~" + lockBtn.setTitle(isLocked ? "请求解锁" : "锁定", for: .normal) + } private func setupUI() { - addSubview(navBgView) - addSubview(navView) - navView.addRightButton(switchGroupBtn) addSubview(scrollView) scrollView.addSubview(contentView) - addSubview(lockBtn) + addSubview(navView) + navView.addRightButton(switchGroupBtn) + addSubview(footerBar) + footerBar.addSubview(tipRow) + footerBar.addSubview(lockBtn) - contentView.addSubview(titleLab) - contentView.addSubview(subtitleLab) - contentView.addSubview(heroPlaceholder) + contentView.addSubview(navBgView) contentView.addSubview(statsRow) statsRow.addArrangedSubview(memberCard) statsRow.addArrangedSubview(todayCard) contentView.addSubview(appsSectionTitle) contentView.addSubview(appsCard) appsCard.addSubview(appsHeaderRow) - appsCard.addSubview(unknownAppIcon) - contentView.addSubview(styleSectionTitle) - contentView.addSubview(styleCard) - addSubview(tipRow) - - navBgView.layoutChain - .edges(excludingEdge: .bottom) - .heightToWidth(160/375) + appsCard.addSubview(appCollectionView) + contentView.addSubview(bottomStack) + bottomStack.addArrangedSubview(styleSectionTitle) + bottomStack.addArrangedSubview(styleCard) + bottomStack.addArrangedSubview(lockedEmptyView) navView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) + footerBar.layoutChain + .edgesHorzontal() + .bottom() + lockBtn.layoutChain - .bottom(kSafeBottomMargin + 0) + .bottom(kSafeBottomMargin) .edgesHorzontal(30) .height(56) + tipRow.layoutChain + .centerX() + .bottomToTopOfView(lockBtn, offset: -8) + .top(10) + scrollView.layoutChain - .topToBottomOfView(navView) + .top() .edgesHorzontal() - .bottomToTopOfView(tipRow, offset: -12) + .bottomToTopOfView(footerBar) contentView.layoutChain .edges() .widthToView(scrollView) - titleLab.layoutChain - .top(8) - .left(20) - - subtitleLab.layoutChain - .topToBottomOfView(titleLab, offset: 6) - .left(20) - .right(140) - - heroPlaceholder.layoutChain - .top(0) - .right(16) - .width(120) - .height(100) + navBgView.layoutChain + .top() + .edgesHorzontal() + .heightToWidth(200.0 / 375.0) statsRow.layoutChain - .topToBottomOfView(subtitleLab, offset: 20) + .topToBottomOfView(navBgView, offset: -48) .edgesHorzontal(16) .height(120) @@ -119,26 +135,23 @@ final class LockDistractView: UIView { appsHeaderRow.layoutChain .top(16) .edgesHorzontal(14) + .height(28) - unknownAppIcon.layoutChain - .topToBottomOfView(appsHeaderRow, offset: 14) - .left(14) - .width(56) - .height(56) + appCollectionView.layoutChain + .topToBottomOfView(appsHeaderRow, offset: 12) + .edgesHorzontal(8) + .height(78) .bottom(16) - styleSectionTitle.layoutChain + bottomStack.layoutChain .topToBottomOfView(appsCard, offset: 22) - .left(20) - - styleCard.layoutChain - .topToBottomOfView(styleSectionTitle, offset: 12) .edgesHorzontal(16) - .bottom(20) - - tipRow.layoutChain + .bottom(24) + + styleSectionTitle.layoutChain.left(4) + lockedEmptyView.layoutChain .centerX() - .bottomToTopOfView(lockBtn, offset: -6) + .height(180) } private func setupCopyLimit() { @@ -154,13 +167,13 @@ final class LockDistractView: UIView { .disposed(by: disposeBag) } - // MARK: - Views lazy var navBgView: UIImageView = { let iv = UIImageView() - iv.image = UIImage(named: "Common/navBar_bg_2") + iv.image = UIImage(named: "LockDistract/header_hero") iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true return iv }() @@ -168,10 +181,16 @@ final class LockDistractView: UIView { BaseNavigationView(title: "") }() + lazy var footerBar: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#FAFAFA") + return view + }() + lazy var switchGroupBtn: UIButton = { let btn = UIButton(type: .custom) - btn.backgroundColor = .white.withAlphaComponent(0.9) - btn.setTitle(" 切换圈子 ", for: .normal) + btn.backgroundColor = .white.withAlphaComponent(0.92) + btn.setImage(UIImage(named: "Home/group_name_icon"), for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) btn.setTitleColor(UIColor(hexStr: "#293445"), for: .normal) btn.cornerRadius = 8 @@ -181,6 +200,7 @@ final class LockDistractView: UIView { lazy var scrollView: UIScrollView = { let sv = UIScrollView() + sv.contentInsetAdjustmentBehavior = .never sv.showsVerticalScrollIndicator = false sv.alwaysBounceVertical = true sv.keyboardDismissMode = .onDrag @@ -189,29 +209,6 @@ final class LockDistractView: UIView { lazy var contentView = UIView() - lazy var titleLab: UILabel = { - let lab = UILabel() - lab.text = "锁住分心" - lab.font = FontManager.boboBold(30) - lab.textColor = UIColor(hexStr: "#293445") - return lab - }() - - lazy var subtitleLab: UILabel = { - let lab = UILabel() - lab.text = "减少干扰,只专注重要的事" - lab.font = .systemFont(ofSize: 13, weight: .medium) - lab.textColor = UIColor(hexStr: "#8A94A6") - lab.numberOfLines = 2 - return lab - }() - - lazy var heroPlaceholder: UIView = { - let view = UIView() - view.backgroundColor = .clear - return view - }() - lazy var statsRow: UIStackView = { let stack = UIStackView() stack.axis = .horizontal @@ -227,6 +224,7 @@ final class LockDistractView: UIView { view.addSubview(memberPrevBtn) view.addSubview(memberAvatar) + view.addSubview(avatarLockView) view.addSubview(memberNextBtn) view.addSubview(memberNameChip) memberNameChip.addSubview(memberNameLab) @@ -237,17 +235,23 @@ final class LockDistractView: UIView { .width(56) .height(56) + avatarLockView.layoutChain + .topToView(memberAvatar) + .leftToView(memberAvatar) + .rightToView(memberAvatar) + .bottomToView(memberAvatar) + memberPrevBtn.layoutChain .centerY(memberAvatar) - .left(8) - .width(28) - .height(28) + .rightToLeftOfView(memberAvatar, offset: -18) + .width(16) + .height(16) memberNextBtn.layoutChain .centerY(memberAvatar) - .right(8) - .width(28) - .height(28) + .leftToRightOfView(memberAvatar, offset: 18) + .width(16) + .height(16) memberNameChip.layoutChain .topToBottomOfView(memberAvatar, offset: 10) @@ -269,7 +273,16 @@ final class LockDistractView: UIView { let iv = UIImageView(image: UIImage(named: "Common/default_avatar")) iv.contentMode = .scaleAspectFill iv.clipsToBounds = true - iv.cornerRadius = 16 + iv.cornerRadius = 12 + return iv + }() + + lazy var avatarLockView: UIImageView = { + let iv = UIImageView(image: UIImage(named: "LockDistract/avatar_lock")) + iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true + iv.cornerRadius = 12 + iv.isHidden = true return iv }() @@ -291,16 +304,16 @@ final class LockDistractView: UIView { }() lazy var memberPrevBtn: UIButton = { - let btn = UIButton(type: .system) - btn.setImage(UIImage(systemName: "chevron.left"), for: .normal) - btn.tintColor = UIColor(hexStr: "#9CA3AF") + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "LockDistract/member_prev"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 50, left: 30, bottom: 50, right: 18) return btn }() lazy var memberNextBtn: UIButton = { - let btn = UIButton(type: .system) - btn.setImage(UIImage(systemName: "chevron.right"), for: .normal) - btn.tintColor = UIColor(hexStr: "#9CA3AF") + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "LockDistract/member_next"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 50, left: 18, bottom: 50, right: 30) return btn }() @@ -371,54 +384,75 @@ final class LockDistractView: UIView { return row }() - lazy var notFetchedView: UIView = { - let view = UIView() - + lazy var notFetchedView: UIButton = { + let btn = UIButton(type: .custom) + btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 4) + let warn = UIView() + warn.isUserInteractionEnabled = false warn.backgroundColor = UIColor(hexStr: "#FA5151") warn.cornerRadius = 5 let warnText = UILabel() warnText.text = "!" warnText.textColor = .white warnText.font = .systemFont(ofSize: 8) - - view.addSubview(warn) + warnText.isUserInteractionEnabled = false + btn.addSubview(warn) warn.addSubview(warnText) warnText.layoutChain.centerX().centerY() - + let notFetchedText = UILabel() notFetchedText.text = "未获取" notFetchedText.textColor = UIColor(hexStr: "#293445") notFetchedText.font = .systemFont(ofSize: 12, weight: .medium) - view.addSubview(notFetchedText) - + notFetchedText.isUserInteractionEnabled = false + btn.addSubview(notFetchedText) + let arrow = UIImageView(image: UIImage(named: "LockDistract/arrow_right")) - - view.addSubview(arrow) - view.addSubview(notFetchedText) - + arrow.isUserInteractionEnabled = false + btn.addSubview(arrow) + + warn.layoutChain + .left() + .centerY() + .width(10).height(10) + + notFetchedText.layoutChain + .leftToRightOfView(warn, offset: 4) + .centerY() + arrow.layoutChain - .right(14) + .leftToRightOfView(notFetchedText, offset: 2) + .right() .width(10) .heightToWidth(1) .centerY() - - notFetchedText.layoutChain - .rightToLeftOfView(arrow) - .centerY() - - warn.layoutChain - .rightToLeftOfView(notFetchedText, offset: -4) - .centerY() - .width(10).height(10) - - return view + + return btn }() - lazy var unknownAppIcon: UIImageView = { - let iv = UIImageView(image: UIImage(named: "LockDistract/app_unknown")) - iv.contentMode = .scaleAspectFit - return iv + lazy var appCollectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: 56, height: 78) + layout.minimumLineSpacing = 12 + layout.sectionInset = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: 6) + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = .clear + cv.showsHorizontalScrollIndicator = false + cv.register(LockDistractAppCell.self) + cv.dataSource = self + cv.delegate = self + cv.tag = 1 + return cv + }() + + lazy var bottomStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .fill + stack.spacing = 12 + return stack }() lazy var styleSectionTitle: UIView = { @@ -431,14 +465,14 @@ final class LockDistractView: UIView { card.cornerRadius = 26 let wallLab = UILabel() - wallLab.text = "锁定壁纸图片" + wallLab.text = "锁定图标" wallLab.font = .systemFont(ofSize: 13, weight: .medium) wallLab.textColor = UIColor(hexStr: "#8A94A6") card.addSubview(wallLab) wallLab.layoutChain.top(16).left(14) - card.addSubview(wallpaperCV) - wallpaperCV.layoutChain + card.addSubview(lockIconCV) + lockIconCV.layoutChain .topToBottomOfView(wallLab, offset: 10) .edgesHorzontal(14) .height(74) @@ -448,7 +482,7 @@ final class LockDistractView: UIView { copyLab.font = .systemFont(ofSize: 13, weight: .medium) copyLab.textColor = UIColor(hexStr: "#8A94A6") card.addSubview(copyLab) - copyLab.layoutChain.topToBottomOfView(wallpaperCV, offset: 16).left(14) + copyLab.layoutChain.topToBottomOfView(lockIconCV, offset: 16).left(14) let input = UIView() input.backgroundColor = UIColor(hexStr: "#F5F6F8") @@ -468,18 +502,18 @@ final class LockDistractView: UIView { return card }() - lazy var wallpaperCV: UICollectionView = { + lazy var lockIconCV: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal - layout.itemSize = CGSize(width: 50, height: 74) - layout.minimumLineSpacing = 12 - layout.minimumInteritemSpacing = 0 + layout.itemSize = CGSize(width: 56, height: 74) + layout.minimumLineSpacing = 10 let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) cv.backgroundColor = .clear cv.showsHorizontalScrollIndicator = false cv.register(LockDistractWallpaperCell.self) cv.dataSource = self cv.delegate = self + cv.tag = 2 return cv }() @@ -501,19 +535,30 @@ final class LockDistractView: UIView { return lab }() - lazy var tipRow: UIView = { - let row = UIView() - let icon = UIImageView(image: UIImage(systemName: "info.circle.fill")) - icon.tintColor = UIColor(hexStr: "#16B3FF") + lazy var lockedEmptyView: UIImageView = { + let iv = UIImageView(image: UIImage(named: "LockDistract/locked_empty")) + iv.contentMode = .scaleAspectFit + iv.isHidden = true + return iv + }() + + lazy var tipLab: UILabel = { let lab = UILabel() lab.text = "锁定功能只有在圈主的情况下才能使用哟~" lab.font = .systemFont(ofSize: 12, weight: .regular) lab.textColor = UIColor(hexStr: "#00ADFE") lab.numberOfLines = 0 + return lab + }() + + lazy var tipRow: UIView = { + let row = UIView() + let icon = UIImageView(image: UIImage(named: "LockDistract/info_circle")) + icon.contentMode = .scaleAspectFit row.addSubview(icon) - row.addSubview(lab) - icon.layoutChain.left().top(1).width(14).height(14) - lab.layoutChain.leftToRightOfView(icon, offset: 6).right().top().bottom() + row.addSubview(tipLab) + icon.layoutChain.left().centerY().width(14).height(14) + tipLab.layoutChain.leftToRightOfView(icon, offset: 6).right().top().bottom() return row }() @@ -530,11 +575,7 @@ final class LockDistractView: UIView { private func makeSectionTitle(_ text: String) -> UIView { let row = UIView() let star = UIImageView(image: UIImage(named: "LockDistract/section_star")) - // 切图目前带黑底,先用 SF Symbol 保证白底可读;资源补透明图后可改回 LockDistract/section_star -// let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .bold) -// star.image = UIImage(systemName: "sparkle", withConfiguration: config) -// star.tintColor = UIColor(hexStr: "#16B3FF") -// star.contentMode = .scaleAspectFit + star.contentMode = .scaleAspectFit let lab = UILabel() lab.text = text lab.font = .systemFont(ofSize: 16, weight: .bold) @@ -550,22 +591,77 @@ final class LockDistractView: UIView { extension LockDistractView: UICollectionViewDataSource, UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - wallpaperNames.count + collectionView.tag == 1 ? 1 : lockIconNames.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + if collectionView.tag == 1 { + let cell = collectionView.dequeueReusableCell(for: indexPath) as LockDistractAppCell + cell.configure( + image: UIImage(named: "LockDistract/app_unknown"), + selected: selectedAppIndexes.contains(indexPath.item), + showsCheck: false + ) + return cell + } let cell = collectionView.dequeueReusableCell(for: indexPath) as LockDistractWallpaperCell - cell.configure(imageName: wallpaperNames[indexPath.item], - isSelected: indexPath.item == selectedWallpaperIndex) + cell.configure( + imageName: lockIconNames[indexPath.item], + isSelected: indexPath.item == selectedLockIconIndex + ) return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - selectedWallpaperIndex = indexPath.item + if collectionView.tag == 1 { + guard !isLocked, indexPath.item > 0 else { return } + if selectedAppIndexes.contains(indexPath.item) { + selectedAppIndexes.remove(indexPath.item) + } else { + selectedAppIndexes.insert(indexPath.item) + } + collectionView.reloadData() + return + } + selectedLockIconIndex = indexPath.item collectionView.reloadData() } } +final class LockDistractAppCell: UICollectionViewCell { + private let iconView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFit + iv.clipsToBounds = true + iv.cornerRadius = 12 + return iv + }() + + private let checkBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Common/checkbox"), for: .normal) + btn.setImage(UIImage(named: "Common/checkbox_on"), for: .selected) + btn.isUserInteractionEnabled = false + return btn + }() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(iconView) + contentView.addSubview(checkBtn) + iconView.layoutChain.top().centerX().width(48).height(48) + checkBtn.layoutChain.topToBottomOfView(iconView, offset: 6).centerX().width(16).height(16) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(image: UIImage?, selected: Bool, showsCheck: Bool) { + iconView.image = image + checkBtn.isSelected = selected + checkBtn.isHidden = !showsCheck + } +} + final class LockDistractWallpaperCell: UICollectionViewCell { private let frameView: UIView = { let view = UIView() @@ -576,9 +672,8 @@ final class LockDistractWallpaperCell: UICollectionViewCell { private let imageView: UIImageView = { let iv = UIImageView() - iv.contentMode = .scaleAspectFill + iv.contentMode = .scaleAspectFit iv.clipsToBounds = true - iv.cornerRadius = 10 return iv }() @@ -590,8 +685,8 @@ final class LockDistractWallpaperCell: UICollectionViewCell { imageView.layoutChain .centerY() .centerX() - .width(40) - .height(40) + .width(48) + .height(48) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -599,7 +694,7 @@ final class LockDistractWallpaperCell: UICollectionViewCell { func configure(imageName: String, isSelected: Bool) { imageView.image = UIImage(named: imageName) frameView.backgroundColor = UIColor(hexStr: isSelected ? "#EAF8FF" : "#F6F6F6") - frameView.borderWidth = isSelected ? 1 : 0 + frameView.borderWidth = isSelected ? 1.5 : 0 frameView.borderColor = UIColor(hexStr: isSelected ? "#00ADFE" : "") } } diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryVC.swift index 843dd944..377e1d88 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryVC.swift @@ -4,21 +4,24 @@ // import UIKit +import AVFoundation +import RxSwift final class PigeonMessageHistoryVC: BaseViewController { - private let members: [GroupMemberModel] + private let groupKey: String private var items: [PigeonHistoryItem] = [] private var rootView: PigeonMessageHistoryView! private var playingItemId: String? private var isVoicePlaying = false private var voiceElapsed: TimeInterval = 0 - private var voiceDuration: TimeInterval = 10 + private var voiceDuration: TimeInterval = 0 private var voiceTimer: Timer? + private var audioPlayer: AVAudioPlayer? - init(members: [GroupMemberModel]) { - self.members = members + init(groupKey: String) { + self.groupKey = groupKey super.init(nibName: nil, bundle: nil) } @@ -33,15 +36,10 @@ final class PigeonMessageHistoryVC: BaseViewController { override func viewDidLoad() { super.viewDidLoad() - items = Self.makeMockItems(members: members) rootView.tableView.dataSource = self rootView.tableView.delegate = self rootView.tableView.register(PigeonHistoryCell.self, forCellReuseIdentifier: PigeonHistoryCell.reuseId) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - + loadHistory() } override func viewWillDisappear(_ animated: Bool) { @@ -53,8 +51,26 @@ final class PigeonMessageHistoryVC: BaseViewController { navigationController?.popViewController(animated: true) } + private func loadHistory() { + DLToast.showLoading() + PigeonService.sent(groupKey: groupKey) + .subscribe(onNext: { [weak self] response in + DLToast.dismiss() + guard let self else { return } + self.items = response.list.map { $0.toHistoryItem() } + self.rootView.tableView.reloadData() + }, onError: { _ in + DLToast.dismiss() + }) + .disposed(by: disposeBag) + } + private func togglePlayback(for item: PigeonHistoryItem) { guard item.kind == .voice else { return } + guard item.mediaURL != nil else { + DLToast.show(text: "无法播放") + return + } if playingItemId == item.id { if isVoicePlaying { pauseVoicePlayback() @@ -70,19 +86,58 @@ final class PigeonMessageHistoryVC: BaseViewController { private func startVoicePlayback(item: PigeonHistoryItem) { playingItemId = item.id - voiceDuration = max(1, item.duration) + if audioPlayer == nil || playingItemId != item.id { + preparePlayer(for: item) + return + } + audioPlayer?.play() isVoicePlaying = true + startTimer() + refreshVisiblePlayback() + } + + private func preparePlayer(for item: PigeonHistoryItem) { + guard let url = item.mediaURL else { return } + DLToast.showLoading() + URLSession.shared.dataTask(with: url) { [weak self] data, _, error in + DispatchQueue.main.async { + DLToast.dismiss() + guard let self, self.playingItemId == item.id else { return } + guard let data, error == nil else { + DLToast.show(text: "无法播放") + self.playingItemId = nil + return + } + do { + let player = try AVAudioPlayer(data: data) + player.prepareToPlay() + self.audioPlayer = player + self.voiceDuration = max(1, player.duration) + self.voiceElapsed = 0 + player.play() + self.isVoicePlaying = true + self.startTimer() + self.refreshVisiblePlayback() + } catch { + DLToast.show(text: "无法播放") + self.playingItemId = nil + } + } + }.resume() + } + + private func startTimer() { voiceTimer?.invalidate() let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in self?.tickVoicePlayback() } RunLoop.main.add(timer, forMode: .common) voiceTimer = timer - refreshVisiblePlayback() } private func pauseVoicePlayback() { isVoicePlaying = false + audioPlayer?.pause() voiceTimer?.invalidate() voiceTimer = nil refreshVisiblePlayback() @@ -92,6 +147,8 @@ final class PigeonMessageHistoryVC: BaseViewController { isVoicePlaying = false voiceTimer?.invalidate() voiceTimer = nil + audioPlayer?.stop() + audioPlayer = nil if reset { voiceElapsed = 0 playingItemId = nil @@ -100,9 +157,14 @@ final class PigeonMessageHistoryVC: BaseViewController { } private func tickVoicePlayback() { - voiceElapsed = min(voiceDuration, voiceElapsed + 0.05) + if let player = audioPlayer { + voiceElapsed = player.currentTime + voiceDuration = max(player.duration, voiceElapsed) + } else { + voiceElapsed = min(voiceDuration, voiceElapsed + 0.05) + } refreshVisiblePlayback() - guard voiceElapsed >= voiceDuration else { return } + guard voiceElapsed >= voiceDuration, voiceDuration > 0 else { return } stopVoicePlayback(reset: true) } @@ -114,70 +176,14 @@ final class PigeonMessageHistoryVC: BaseViewController { } let item = items[indexPath.row] let isCurrent = item.id == playingItemId + let duration = isCurrent && voiceDuration > 0 ? voiceDuration : item.duration historyCell.applyPlayback( isPlaying: isCurrent && isVoicePlaying, elapsed: isCurrent ? voiceElapsed : 0, - duration: item.duration + duration: duration ) } } - - private static func makeMockItems(members: [GroupMemberModel]) -> [PigeonHistoryItem] { - let fallback = UIImage(named: "UserIcon/1") - let sender = AppContextManager.shared.avaterIcon.size == .zero - ? fallback - : AppContextManager.shared.avaterIcon - var receivers = members - .filter { $0.user_id != AppContextManager.shared.userId } - .map { member -> UIImage in - member.userIcon.size == .zero ? (fallback ?? UIImage()) : member.userIcon - } - if receivers.isEmpty { - receivers = [fallback ?? UIImage()] - } - while receivers.count < 3 { - receivers.append(receivers[receivers.count % max(receivers.count, 1)]) - } - - let dateText = "8.14 周五" - let timeText = "下午 10:12" - let caption = "和红红火火恍恍惚惚或或或或或或或或" - return [ - PigeonHistoryItem( - id: "image-1", - dateText: dateText, - timeText: timeText, - kind: .image, - caption: caption, - image: UIImage(named: "PigeonMessage/history_photo") ?? sender ?? fallback, - senderAvatar: sender, - receiverAvatars: [receivers[0]], - duration: 0 - ), - PigeonHistoryItem( - id: "voice-1", - dateText: dateText, - timeText: timeText, - kind: .voice, - caption: "", - image: nil, - senderAvatar: sender, - receiverAvatars: [receivers[0]], - duration: 10 - ), - PigeonHistoryItem( - id: "voice-2", - dateText: dateText, - timeText: timeText, - kind: .voice, - caption: "", - image: nil, - senderAvatar: sender, - receiverAvatars: Array(receivers.prefix(3)), - duration: 10 - ) - ] - } } extension PigeonMessageHistoryVC: UITableViewDataSource, UITableViewDelegate { diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift index a8d5d525..bc830e3b 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift @@ -4,6 +4,7 @@ // import UIKit +import Kingfisher enum PigeonHistoryKind { case image @@ -17,6 +18,7 @@ struct PigeonHistoryItem { let kind: PigeonHistoryKind let caption: String let image: UIImage? + let mediaURL: URL? let senderAvatar: UIImage? let receiverAvatars: [UIImage] let duration: TimeInterval @@ -208,8 +210,15 @@ final class PigeonHistoryCell: UITableViewCell { let isVoice = item.kind == .voice imageContainer.isHidden = isVoice voiceBar.isHidden = !isVoice - photoView.image = item.image - captionLabel.text = "留言:\(String(item.caption.prefix(20)))" + photoView.kf.cancelDownloadTask() + if let mediaURL = item.mediaURL, !isVoice { + photoView.kf.setImage(with: mediaURL, placeholder: nil) + } else { + photoView.image = item.image + } + let caption = item.caption.trimmingCharacters(in: .whitespacesAndNewlines) + captionBackground.isHidden = caption.isEmpty + captionLabel.text = caption.isEmpty ? "" : "留言:\(String(caption.prefix(20)))" applyPlayback(isPlaying: isPlaying, elapsed: elapsed, duration: item.duration) } @@ -413,7 +422,7 @@ final class PigeonHistoryAvatarStackView: UIView { super.init(frame: frame) senderView.contentMode = .scaleAspectFill senderView.clipsToBounds = true - senderView.layer.cornerRadius = 20 + senderView.layer.cornerRadius = 14 senderView.layer.borderWidth = 2 senderView.layer.borderColor = UIColor.white.cgColor arrowView.contentMode = .scaleAspectFit @@ -435,7 +444,7 @@ final class PigeonHistoryAvatarStackView: UIView { let view = UIImageView(image: image) view.contentMode = .scaleAspectFill view.clipsToBounds = true - view.layer.cornerRadius = 20 + view.layer.cornerRadius = 14 view.layer.borderWidth = 2 view.layer.borderColor = UIColor.white.cgColor addSubview(view) diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift index 58804225..5c34a4ca 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageVC.swift @@ -5,15 +5,15 @@ import UIKit import AVFoundation -import CoreLocation -import SwiftyUserDefaults import RxSwift final class PigeonMessageVC: BaseViewController { private var rootView: PigeonMessageView! + private var groupModel: GroupModel? + private var activeGroupKey: String = "" private var members: [GroupMemberModel] = [] - private var selectedMember: GroupMemberModel? + private var selectedMembers: [GroupMemberModel] = [] private var selectedImage: UIImage? private var audioRecorder: AVAudioRecorder? @@ -43,12 +43,11 @@ final class PigeonMessageVC: BaseViewController { } private func bindActions() { + rootView.switchGroupButton.addTarget(self, action: #selector(switchGroup), for: .touchUpInside) rootView.historyButton.addTarget(self, action: #selector(showHistory), for: .touchUpInside) - rootView.changeRecipientButton.addTarget( - self, - action: #selector(changeRecipient), - for: .touchUpInside - ) + rootView.onSelectionChanged = { [weak self] members in + self?.selectedMembers = members + } rootView.addImageButton.addTarget(self, action: #selector(selectImage), for: .touchUpInside) rootView.deleteImageButton.addTarget(self, action: #selector(deleteImage), for: .touchUpInside) rootView.playPauseButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside) @@ -79,97 +78,130 @@ final class PigeonMessageVC: BaseViewController { DLToast.showError(text: response.message ?? "获取圈子信息失败") return } - guard !model.default_group_key.isEmpty else { - DLToast.dismiss() - self.applyMembers([]) - return - } - GroupService.groupUsers(groupKey: model.default_group_key) - .subscribe(onNext: { [weak self] response in - DLToast.dismiss() - guard response.isValid(for: model.default_group_key) else { - DLToast.showError(text: response.message ?? "获取圈子成员失败") - return - } - self?.applyMembers(response.list) - }, onError: { error in - DLToast.dismiss() - DLToast.showError(text: error.localizedDescription) - }) - .disposed(by: self.disposeBag) + self.groupModel = model + self.activeGroupKey = model.default_group_key + self.loadUsers(for: self.activeGroupKey, showLoading: false) }, onError: { error in DLToast.dismiss() DLToast.showError(text: error.localizedDescription) }).disposed(by: disposeBag) } + private func loadUsers(for groupKey: String, showLoading: Bool) { + guard !groupKey.isEmpty else { + DLToast.dismiss() + applyMembers([]) + return + } + if showLoading { DLToast.showLoading() } + GroupService.groupUsers(groupKey: groupKey) + .subscribe(onNext: { [weak self] response in + DLToast.dismiss() + guard response.isValid(for: groupKey) else { + DLToast.showError(text: response.message ?? "获取圈子成员失败") + return + } + self?.applyMembers(response.list) + }, onError: { error in + DLToast.dismiss() + DLToast.showError(text: error.localizedDescription) + }) + .disposed(by: disposeBag) + } + private func applyMembers(_ members: [GroupMemberModel]) { self.members = members - selectedMember = members.first - refreshRecipient() - } - - private func refreshRecipient() { - rootView.configureRecipient( - selectedMember, - distanceText: distanceText(for: selectedMember) - ) - } - - private func distanceText(for member: GroupMemberModel?) -> String { - guard let member, - let latitude = Defaults[\.currentLatitude], - let longitude = Defaults[\.currentLongitude] else { - return "与您相距 --" - } - let parts = member.last_position.components(separatedBy: ":") - guard parts.count >= 2, - let memberLatitude = Double(parts[0]), - let memberLongitude = Double(parts[1]) else { - return "与您相距 --" - } - let current = CLLocation(latitude: latitude, longitude: longitude) - let target = CLLocation(latitude: memberLatitude, longitude: memberLongitude) - let kilometers = current.distance(from: target) / 1000 - if kilometers < 1 { - return "与您相距 \(Int(kilometers * 1000))m" - } - return String(format: "与您相距 %.1fkm", kilometers) + selectedMembers = [] + rootView.configureMembers(members, selectedIds: []) } @objc private func goBack() { navigationController?.popViewController(animated: true) } + @objc private func switchGroup() { + guard let groupModel else { + loadMembers() + return + } + GroupListPopView.show( + groupModel: groupModel, + currentMembers: members, + selectedKey: activeGroupKey + ) { [weak self] groupKey in + guard let self, let key = groupKey, key != self.activeGroupKey else { return } + self.activeGroupKey = key + self.loadUsers(for: key, showLoading: true) + } + } + @objc private func showHistory() { - let historyVC = PigeonMessageHistoryVC(members: members) + let historyVC = PigeonMessageHistoryVC(groupKey: activeGroupKey) navigationController?.pushViewController(historyVC, animated: true) } - @objc private func changeRecipient() { - guard !members.isEmpty else { - DLToast.show(text: "暂无可选收件人") - return - } - PigeonRecipientPickerView.show( - members: members, - selectedId: selectedMember?.user_id ?? "" - ) { [weak self] member in - self?.selectedMember = member - self?.refreshRecipient() - } + @objc private func selectImage() { + PigeonUploadPopView.show( + onAlbum: { [weak self] in + self?.openAlbumPicker() + }, + onCamera: { [weak self] in + self?.openWatermarkCamera() + } + ) } - @objc private func selectImage() { + private func openAlbumPicker() { let picker = PhotoPickerVC(maxCount: 1) picker.onConfirm = { [weak self] images in guard let image = images.first else { return } - self?.selectedImage = image - self?.rootView.setSelectedImage(image) + self?.applySelectedImage(image) } present(picker, animated: true) } + private func openWatermarkCamera() { + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { + DLToast.showError(text: "当前设备不支持拍照") + return + } + guard let manager = AuthorizeManager.manager(type: .camera) else { return } + switch manager.authorizeStatus() { + case .authorized: + pushWatermarkCamera() + case .notDetermined: + manager.authorize { [weak self] status in + if status == .authorized { + self?.pushWatermarkCamera() + } else if status != .authorized { + self?.showCameraDenied() + } + } + case .restricted, .denied: + showCameraDenied() + } + } + + private func pushWatermarkCamera() { + let cameraVC = PigeonWatermarkCameraVC() + cameraVC.onPhoto = { [weak self] image in + self?.applySelectedImage(image) + } + navigationController?.pushViewController(cameraVC, animated: true) + } + + private func applySelectedImage(_ image: UIImage) { + selectedImage = image + rootView.setSelectedImage(image) + } + + private func showCameraDenied() { + Permission.openAppSetting( + title: "无法使用相机", + message: "请在系统设置中允许访问相机" + ) + } + @objc private func deleteImage() { selectedImage = nil rootView.setSelectedImage(nil) @@ -248,7 +280,7 @@ final class PigeonMessageVC: BaseViewController { let recorder = try AVAudioRecorder(url: url, settings: settings) recorder.isMeteringEnabled = true recorder.prepareToRecord() - recorder.record(forDuration: 60) + recorder.record(forDuration: PigeonMessageView.maxVoiceDuration + 0.25) recordURL = url audioRecorder = recorder @@ -271,7 +303,7 @@ final class PigeonMessageVC: BaseViewController { let level = pow(10, CGFloat(recorder.averagePower(forChannel: 0)) / 38) self.rootView.pushMeterLevel(level) self.rootView.setVoiceDuration(recorder.currentTime, hasRecording: false) - if recorder.currentTime >= 59.95 { + if recorder.currentTime >= PigeonMessageView.maxVoiceDuration { self.isPressingRecord = false self.stopRecording(cancel: false) } @@ -303,7 +335,8 @@ final class PigeonMessageVC: BaseViewController { return } - recordedDuration = min(60, duration) + let maxDuration = PigeonMessageView.maxVoiceDuration + recordedDuration = duration + 0.05 >= maxDuration ? maxDuration : min(maxDuration, duration) rootView.setVoiceDuration(recordedDuration, hasRecording: true) rootView.setPlaying(false) } @@ -363,28 +396,89 @@ final class PigeonMessageVC: BaseViewController { } @objc private func sendMessage() { - guard selectedMember != nil else { + guard !selectedMembers.isEmpty else { DLToast.show(text: "请先选择收件人") return } + guard !activeGroupKey.isEmpty else { + DLToast.show(text: "请先选择圈子") + return + } + let upload: Observable + let msgType: Int switch rootView.mode { case .image: - guard selectedImage != nil else { + guard let image = selectedImage, + let jpeg = compressedPigeonJPEG(from: image) else { DLToast.show(text: "请先选择一张图片") return } - selectedImage = nil - rootView.clearImageDraft() + upload = UploadService.upload(jpeg, kind: .jpeg, scene: "pigeon") + msgType = 1 case .voice: - guard recordURL != nil, recordedDuration >= 1 else { + guard let recordURL, recordedDuration >= 1, + let voiceData = try? Data(contentsOf: recordURL) else { DLToast.show(text: "请先完成录音") return } - cleanupAudio(deleteFile: true) - rootView.clearVoiceDraft() + upload = UploadService.upload(voiceData, kind: .wav, scene: "pigeon") + msgType = 2 } - DLToast.show(text: "发送成功") + + DLToast.showLoading() + upload + .flatMap { [weak self] fileId -> Observable in + guard let self else { return .empty() } + return PigeonService.send( + groupKey: self.activeGroupKey, + toUsers: self.selectedMembers.map(\.user_id), + msgType: msgType, + fileId: fileId, + textMsg: self.rootView.captionText.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + .subscribe(onNext: { [weak self] _ in + DLToast.dismiss() + guard let self else { return } + switch self.rootView.mode { + case .image: + self.selectedImage = nil + self.rootView.clearImageDraft() + case .voice: + self.cleanupAudio(deleteFile: true) + self.rootView.clearVoiceDraft() + } + DLToast.show(text: "发送成功") + }, onError: { error in + DLToast.dismiss() + DLToast.show(text: error.localizedDescription) + }) + .disposed(by: disposeBag) + } + + private func compressedPigeonJPEG(from image: UIImage) -> Data? { + let maxEdge: CGFloat = 1280 + let quality: CGFloat = 0.55 + let longest = max(image.size.width, image.size.height) + let output: UIImage + if longest > maxEdge, longest > 0 { + let scale = maxEdge / longest + let size = CGSize( + width: (image.size.width * scale).rounded(), + height: (image.size.height * scale).rounded() + ) + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = true + output = UIGraphicsImageRenderer(size: size, format: format).image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + } else { + output = image + } + return output.jpegData(compressionQuality: quality) + ?? image.jpegData(compressionQuality: 0.8) } private func cleanupAudio(deleteFile: Bool) { diff --git a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift index 0c6948db..12ca9e51 100644 --- a/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift +++ b/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift @@ -12,9 +12,11 @@ enum PigeonMessageMode { final class PigeonMessageView: UIView, UITextFieldDelegate { + static let maxVoiceDuration: TimeInterval = 10 private static let designWidth: CGFloat = 375 var onModeChanged: ((PigeonMessageMode) -> Void)? + var onSelectionChanged: (([GroupMemberModel]) -> Void)? var mode: PigeonMessageMode = .image { didSet { updateMode() @@ -27,22 +29,35 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { }() let historyButton = UIButton(type: .custom) - let changeRecipientButton = UIButton(type: .custom) + let switchGroupButton = UIButton(type: .custom) let addImageButton = UIButton(type: .custom) let deleteImageButton = UIButton(type: .custom) let recordButton = UIButton(type: .custom) let playPauseButton = UIButton(type: .custom) let sendButton = UIButton(type: .custom) - private let recipientAvatarView = UIImageView() - private let recipientNameLabel = UILabel() - private let recipientIdLabel = UILabel() - private let recipientOnlineDotView = UIView() - private let recipientOnlineLabel = UILabel() - private let recipientStatusDividerView = UIView() - private let recipientLocationIconView = UIImageView() - private let recipientDistancePrefixLabel = UILabel() - private let recipientDistanceLabel = UILabel() + private var members: [GroupMemberModel] = [] + private var selectedMemberIds: Set = [] + + private lazy var memberLayout: UICollectionViewFlowLayout = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 0 + layout.itemSize = CGSize(width: 66, height: 84) + return layout + }() + + private lazy var memberCollectionView: UICollectionView = { + let view = UICollectionView(frame: .zero, collectionViewLayout: memberLayout) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.dataSource = self + view.delegate = self + view.register(PigeonMemberCell.self, forCellWithReuseIdentifier: PigeonMemberCell.reuseId) + return view + }() + private let imageModeButton = UIButton(type: .custom) private let voiceModeButton = UIButton(type: .custom) private let imageEditorView = UIView() @@ -63,6 +78,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { private let waveformView = PigeonWaveformView() private var addTileLeadingWithImage: NSLayoutConstraint? private var addTileLeadingWithoutImage: NSLayoutConstraint? + private var selectedImageHeightConstraint: NSLayoutConstraint? private var isRecording = false var captionText: String { @@ -73,7 +89,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { super.init(frame: frame) backgroundColor = UIColor(hexStr: "#F9F9F9") setupUI() - configureRecipient(nil, distanceText: "与您相距 --") + configureMembers([], selectedIds: []) setSelectedImage(nil) clearVoiceDraft() updateMode() @@ -91,19 +107,10 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { value * designScale } - func configureRecipient(_ member: GroupMemberModel?, distanceText: String) { - recipientAvatarView.image = member?.userIcon ?? UIImage(named: "Common/default_avatar") - recipientNameLabel.text = member?.nick_name ?? "暂无圈子成员" - recipientIdLabel.text = member.map { "ID:\($0.user_id)" } ?? "ID:--" - let isOnline = member?.is_online == true - recipientOnlineLabel.text = isOnline ? "在线" : "离线" - recipientOnlineDotView.backgroundColor = isOnline - ? UIColor(hexStr: "#54D34D") - : UIColor(hexStr: "#C7CBD1") - recipientDistanceLabel.text = distanceText - .replacingOccurrences(of: "与您相距 ", with: "") - changeRecipientButton.isEnabled = member != nil - changeRecipientButton.alpha = member == nil ? 0.55 : 1 + func configureMembers(_ members: [GroupMemberModel], selectedIds: Set) { + self.members = members + self.selectedMemberIds = selectedIds + memberCollectionView.reloadData() } func setSelectedImage(_ image: UIImage?) { @@ -116,6 +123,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { // deleteImageButton.isHidden = image == nil addTile.alpha = 1 updateAddTilePosition(hasImage: image != nil) + setNeedsLayout() } func setRecording(isRecording: Bool) { @@ -132,9 +140,15 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { } func setVoiceDuration(_ seconds: TimeInterval, hasRecording: Bool) { - let value = min(60, max(0, Int(seconds.rounded(.down)))) + let maxDuration = Self.maxVoiceDuration + let value: Int + if seconds + 0.05 >= maxDuration { + value = Int(maxDuration) + } else { + value = min(Int(maxDuration), max(0, Int(seconds.rounded(.down)))) + } let currentTime = String(format: "%02d″", value) - let timeText = "\(currentTime)/60″" + let timeText = "\(currentTime)/\(Int(maxDuration))″" let attributedTime = NSMutableAttributedString( string: timeText, attributes: [.foregroundColor: UIColor(hexStr: "#AAAAAA")] @@ -248,6 +262,19 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { .top(scaled(16)) .leftToRightOfView(sectionSparkle, offset: scaled(8)) + switchGroupButton.backgroundColor = .white + switchGroupButton.setTitle(" 切换圈子 ", for: .normal) + switchGroupButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) + switchGroupButton.setTitleColor(UIColor(hexStr: "#293445"), for: .normal) + switchGroupButton.layer.borderWidth = 1 + switchGroupButton.layer.borderColor = UIColor(hexStr: "#E8E8E8").cgColor + switchGroupButton.cornerRadius = 14 + contentView.addSubview(switchGroupButton) + switchGroupButton.layoutChain + .centerY(sectionTitle) + .right(scaled(15)) + .height(28) + let recipientCard = UIView() recipientCard.backgroundColor = .white recipientCard.layer.cornerRadius = scaled(20) @@ -255,7 +282,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { recipientCard.layoutChain .topToBottomOfView(sectionTitle, offset: scaled(8)) .edgesHorzontal(scaled(15)) - .height(scaled(100)) + .height(100) setupRecipientCard(recipientCard) let modeContainer = UIView() @@ -292,107 +319,11 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { } private func setupRecipientCard(_ card: UIView) { - recipientAvatarView.contentMode = .scaleAspectFill - recipientAvatarView.clipsToBounds = true - recipientAvatarView.layer.cornerRadius = scaled(29) - card.addSubview(recipientAvatarView) - recipientAvatarView.layoutChain - .left(scaled(18)) + card.addSubview(memberCollectionView) + memberCollectionView.layoutChain + .edgesHorzontal(8) .centerY() - .width(scaled(58)) - .height(scaled(58)) - - recipientNameLabel.font = .systemFont(ofSize: scaled(17), weight: .bold) - recipientNameLabel.textColor = UIColor(hexStr: "#293445") - recipientNameLabel.lineBreakMode = .byTruncatingTail - card.addSubview(recipientNameLabel) - recipientNameLabel.layoutChain - .top(scaled(18)) - .leftToRightOfView(recipientAvatarView, offset: scaled(16)) - - recipientIdLabel.font = .systemFont(ofSize: scaled(12)) - recipientIdLabel.textColor = UIColor(hexStr: "#767676") - recipientIdLabel.lineBreakMode = .byTruncatingMiddle - card.addSubview(recipientIdLabel) - recipientIdLabel.layoutChain - .topToBottomOfView(recipientNameLabel, offset: scaled(4)) - .leftToView(recipientNameLabel) - - recipientOnlineDotView.layer.cornerRadius = scaled(3) - card.addSubview(recipientOnlineDotView) - recipientOnlineDotView.layoutChain - .topToBottomOfView(recipientIdLabel, offset: scaled(10)) - .leftToView(recipientNameLabel) - .width(scaled(6)) - .height(scaled(6)) - - recipientOnlineLabel.font = .systemFont(ofSize: scaled(12), weight: .medium) - recipientOnlineLabel.textColor = UIColor(hexStr: "#747A84") - card.addSubview(recipientOnlineLabel) - recipientOnlineLabel.layoutChain - .centerY(recipientOnlineDotView) - .leftToRightOfView(recipientOnlineDotView, offset: scaled(6)) - - recipientStatusDividerView.backgroundColor = UIColor(hexStr: "#D8DBDF") - card.addSubview(recipientStatusDividerView) - recipientStatusDividerView.layoutChain - .centerY(recipientOnlineDotView) - .leftToRightOfView(recipientOnlineLabel, offset: scaled(10)) - .width(scaled(1)) - .height(scaled(14)) - - recipientLocationIconView.image = UIImage( - systemName: "mappin.circle", - withConfiguration: UIImage.SymbolConfiguration( - pointSize: scaled(13), - weight: .regular - ) - ) - recipientLocationIconView.tintColor = UIColor(hexStr: "#858A92") - recipientLocationIconView.contentMode = .scaleAspectFit - card.addSubview(recipientLocationIconView) - recipientLocationIconView.layoutChain - .centerY(recipientOnlineDotView) - .leftToRightOfView(recipientStatusDividerView, offset: scaled(9)) - .width(scaled(14)) - .height(scaled(14)) - - recipientDistancePrefixLabel.text = "与您相距" - recipientDistancePrefixLabel.font = .systemFont(ofSize: scaled(12)) - recipientDistancePrefixLabel.textColor = UIColor(hexStr: "#747A84") - card.addSubview(recipientDistancePrefixLabel) - recipientDistancePrefixLabel.layoutChain - .centerY(recipientOnlineDotView) - .leftToRightOfView(recipientLocationIconView, offset: scaled(4)) - - recipientDistanceLabel.font = .systemFont(ofSize: scaled(12)) - recipientDistanceLabel.textColor = UIColor(hexStr: "#00ADFE") - card.addSubview(recipientDistanceLabel) - recipientDistanceLabel.layoutChain - .centerY(recipientOnlineDotView) - .leftToRightOfView(recipientDistancePrefixLabel, offset: scaled(4)) - - changeRecipientButton.setTitle("更换收件人", for: .normal) - changeRecipientButton.setTitleColor(UIColor(hexStr: "#00ADFE"), for: .normal) - changeRecipientButton.titleLabel?.font = .systemFont(ofSize: scaled(12), weight: .medium) - changeRecipientButton.backgroundColor = UIColor(hexStr: "#F6F6F6") - changeRecipientButton.layer.cornerRadius = scaled(15) - card.addSubview(changeRecipientButton) - changeRecipientButton.layoutChain - .right(scaled(12)) - .centerY() - .width(scaled(70)) - .height(scaled(30)) - recipientNameLabel.layoutChain.rightToLeftOfView( - changeRecipientButton, - offset: -scaled(8), - relation: .lessThanOrEqual - ) - recipientIdLabel.layoutChain.rightToLeftOfView( - changeRecipientButton, - offset: -scaled(8), - relation: .lessThanOrEqual - ) + .height(84) } private func setupModeButtons(_ container: UIView) { @@ -439,10 +370,16 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { selectedImageMaskView.backgroundColor = .black.withAlphaComponent(0.2) selectedImageMaskView.isHidden = true - selectedImageView.contentMode = .scaleAspectFill - selectedImageView.clipsToBounds = true + selectedImageView.contentMode = .scaleAspectFit + selectedImageView.clipsToBounds = false imageEditorView.addSubview(selectedImageView) - selectedImageView.layoutChain.edges() + selectedImageView.layoutChain + .top() + .left() + .right() + let imageHeight = selectedImageView.heightAnchor.constraint(equalTo: imageEditorView.heightAnchor) + imageHeight.isActive = true + selectedImageHeightConstraint = imageHeight imageEditorView.addSubview(selectedImageMaskView) selectedImageMaskView.layoutChain.edges() @@ -559,7 +496,7 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { .right(scaled(66)) .height(scaled(24)) - voiceTimeLabel.text = "00″/60″" + voiceTimeLabel.text = "00″/10″" voiceTimeLabel.font = .systemFont(ofSize: scaled(16)) voiceTimeLabel.textColor = UIColor(hexStr: "#13B6F1") playbackBar.addSubview(voiceTimeLabel) @@ -682,6 +619,31 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { return current.replacingCharacters(in: stringRange, with: string).count <= 20 } + override func layoutSubviews() { + super.layoutSubviews() + updateSelectedImageTopFill() + } + + private func updateSelectedImageTopFill() { + guard let oldConstraint = selectedImageHeightConstraint else { return } + let editorWidth = imageEditorView.bounds.width + let editorHeight = imageEditorView.bounds.height + guard editorWidth > 1, editorHeight > 1 else { return } + let height: CGFloat + if let image = selectedImageView.image, image.size.width > 1 { + height = max(editorHeight, editorWidth * image.size.height / image.size.width) + } else { + height = editorHeight + } + if oldConstraint.secondItem == nil, abs(oldConstraint.constant - height) < 0.5 { + return + } + oldConstraint.isActive = false + let constraint = selectedImageView.heightAnchor.constraint(equalToConstant: height) + constraint.isActive = true + selectedImageHeightConstraint = constraint + } + private func updateAddTilePosition(hasImage: Bool) { addTileLeadingWithImage?.isActive = false addTileLeadingWithoutImage?.isActive = false @@ -713,6 +675,106 @@ final class PigeonMessageView: UIView, UITextFieldDelegate { } } +extension PigeonMessageView: UICollectionViewDataSource, UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + members.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: PigeonMemberCell.reuseId, + for: indexPath + ) as! PigeonMemberCell + let member = members[indexPath.item] + cell.configure(member: member, selected: selectedMemberIds.contains(member.user_id)) + cell.onToggle = { [weak self] in + self?.toggleMember(at: indexPath.item) + } + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + toggleMember(at: indexPath.item) + } +} + +private extension PigeonMessageView { + func toggleMember(at index: Int) { + guard members.indices.contains(index) else { return } + let id = members[index].user_id + if selectedMemberIds.contains(id) { + selectedMemberIds.remove(id) + } else { + selectedMemberIds.insert(id) + } + memberCollectionView.reloadData() + onSelectionChanged?(members.filter { selectedMemberIds.contains($0.user_id) }) + } +} + +private final class PigeonMemberCell: UICollectionViewCell { + static let reuseId = "PigeonMemberCell" + + var onToggle: (() -> Void)? + + private let avatarView = UIImageView() + private let onlineDot = UIView() + private let nameLabel = UILabel() + private let checkBtn = UIButton(type: .custom) + + override init(frame: CGRect) { + super.init(frame: frame) + avatarView.contentMode = .scaleAspectFill + avatarView.clipsToBounds = true + avatarView.layer.cornerRadius = 10 + contentView.addSubview(avatarView) + avatarView.layoutChain.top(5).centerX().width(38).height(38) + + onlineDot.layer.cornerRadius = 4 + onlineDot.layer.borderWidth = 1.5 + onlineDot.layer.borderColor = UIColor.white.cgColor + onlineDot.isUserInteractionEnabled = false + contentView.addSubview(onlineDot) + onlineDot.layoutChain.topToView(avatarView, offset: -1).rightToView(avatarView, offset: 1).width(8).height(8) + + nameLabel.font = .systemFont(ofSize: 8, weight: .medium) + nameLabel.textColor = UIColor(hexStr: "#767676") + nameLabel.textAlignment = .center + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.backgroundColor = UIColor(hexStr: "#F8F8F8") + nameLabel.layer.cornerRadius = 7 + nameLabel.clipsToBounds = true + contentView.addSubview(nameLabel) + nameLabel.layoutChain + .topToBottomOfView(avatarView, offset: 4) + .centerX() + .width(56) + .height(14) + + checkBtn.setImage(UIImage(named: "Common/checkbox"), for: .normal) + checkBtn.setImage(UIImage(named: "Common/checkbox_on"), for: .selected) + checkBtn.imageView?.contentMode = .scaleAspectFit + checkBtn.addTarget(self, action: #selector(tapCheck), for: .touchUpInside) + contentView.addSubview(checkBtn) + checkBtn.layoutChain.topToBottomOfView(nameLabel, offset: 2).centerX().width(20).height(20) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(member: GroupMemberModel, selected: Bool) { + avatarView.image = member.userIcon.size.width > 0 + ? member.userIcon + : UIImage(named: "Common/default_avatar") + nameLabel.text = member.nick_name + onlineDot.backgroundColor = UIColor(hexStr: member.is_online ? "#67E151" : "#AAAAAA") + checkBtn.isSelected = selected + } + + @objc private func tapCheck() { + onToggle?() + } +} + final class PigeonGradientButton: UIButton { private let gradient = CAGradientLayer() @@ -803,149 +865,3 @@ final class PigeonWaveformView: UIView { } } -final class PigeonRecipientPickerView: UIView { - private let members: [GroupMemberModel] - private let selectedId: String - private let onSelect: (GroupMemberModel) -> Void - private let tableView = UITableView(frame: .zero, style: .plain) - private let sheet = UIView() - - static func show( - members: [GroupMemberModel], - selectedId: String, - onSelect: @escaping (GroupMemberModel) -> Void - ) { - guard let window = kKeyWindow, !members.isEmpty else { return } - let picker = PigeonRecipientPickerView( - frame: CGRect(origin: .zero, size: kScreenSize), - members: members, - selectedId: selectedId, - onSelect: onSelect - ) - window.addSubview(picker) - picker.alpha = 0 - UIView.animate(withDuration: 0.2) { - picker.alpha = 1 - } - } - - private init( - frame: CGRect, - members: [GroupMemberModel], - selectedId: String, - onSelect: @escaping (GroupMemberModel) -> Void - ) { - self.members = members - self.selectedId = selectedId - self.onSelect = onSelect - super.init(frame: frame) - setupUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupUI() { - backgroundColor = UIColor.black.withAlphaComponent(0.48) - let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(dismiss)) - backgroundTap.delegate = self - addGestureRecognizer(backgroundTap) - - sheet.backgroundColor = .white - sheet.layer.cornerRadius = 26 - sheet.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - addSubview(sheet) - sheet.layoutChain.bottom().edgesHorzontal().height(min(460, CGFloat(members.count * 72 + 86))) - - let title = UILabel() - title.text = "选择收件人" - title.font = .systemFont(ofSize: 20, weight: .bold) - title.textColor = UIColor(hexStr: "#293445") - sheet.addSubview(title) - title.layoutChain.top(20).left(20) - - tableView.backgroundColor = .clear - tableView.separatorStyle = .none - tableView.rowHeight = 68 - tableView.dataSource = self - tableView.delegate = self - tableView.register(PigeonRecipientCell.self, forCellReuseIdentifier: "PigeonRecipientCell") - sheet.addSubview(tableView) - tableView.layoutChain.top(58).edgesHorzontal().bottom(kSafeBottomMargin) - - } - - @objc private func dismiss() { - UIView.animate(withDuration: 0.18, animations: { - self.alpha = 0 - }) { _ in - self.removeFromSuperview() - } - } -} - -extension PigeonRecipientPickerView: UITableViewDataSource, UITableViewDelegate, UIGestureRecognizerDelegate { - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - !(touch.view?.isDescendant(of: sheet) ?? false) - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - members.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell( - withIdentifier: "PigeonRecipientCell", - for: indexPath - ) as? PigeonRecipientCell else { - return UITableViewCell() - } - let member = members[indexPath.row] - cell.configure(member: member, selected: member.user_id == selectedId) - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - onSelect(members[indexPath.row]) - dismiss() - } -} - -private final class PigeonRecipientCell: UITableViewCell { - private let avatarView = UIImageView() - private let nameLabel = UILabel() - private let statusLabel = UILabel() - private let tickView = UIImageView(image: UIImage(named: "PigeonMessage/tick_circle")) - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none - avatarView.contentMode = .scaleAspectFill - avatarView.clipsToBounds = true - avatarView.layer.cornerRadius = 20 - nameLabel.font = .systemFont(ofSize: 16, weight: .semibold) - nameLabel.textColor = UIColor(hexStr: "#293445") - statusLabel.font = .systemFont(ofSize: 12) - statusLabel.textColor = UIColor(hexStr: "#9EA3AA") - contentView.addSubview(avatarView) - contentView.addSubview(nameLabel) - contentView.addSubview(statusLabel) - contentView.addSubview(tickView) - avatarView.layoutChain.left(20).centerY().width(48).height(48) - nameLabel.layoutChain.top(13).leftToRightOfView(avatarView, offset: 14) - statusLabel.layoutChain.topToBottomOfView(nameLabel, offset: 4).leftToView(nameLabel) - tickView.layoutChain.right(20).centerY().width(16).height(16) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure(member: GroupMemberModel, selected: Bool) { - avatarView.image = member.userIcon - nameLabel.text = member.nick_name - statusLabel.text = member.is_online ? "在线" : "离线" - tickView.isHidden = !selected - } -} diff --git a/QuickLocation/Section/PigeonMessage/PigeonUploadPopView.swift b/QuickLocation/Section/PigeonMessage/PigeonUploadPopView.swift new file mode 100644 index 00000000..099ecde8 --- /dev/null +++ b/QuickLocation/Section/PigeonMessage/PigeonUploadPopView.swift @@ -0,0 +1,185 @@ +// +// PigeonUploadPopView.swift +// QuickLocation +// + +import UIKit + +final class PigeonUploadPopView: UIView, UIGestureRecognizerDelegate { + + private static let shared = PigeonUploadPopView(frame: CGRect(origin: .zero, size: kScreenSize)) + + private var onAlbum: (() -> Void)? + private var onCamera: (() -> Void)? + + static func show(onAlbum: @escaping () -> Void, onCamera: @escaping () -> Void) { + guard let superView = kKeyWindow else { return } + let pop = PigeonUploadPopView.shared + if pop.superview != nil { + pop.removeFromSuperview() + } + pop.onAlbum = onAlbum + pop.onCamera = onCamera + pop.frame = superView.bounds + pop.dimView.alpha = 0 + pop.sheetView.transform = CGAffineTransform(translationX: 0, y: kScreenHeight) + superView.addSubview(pop) + superView.bringSubviewToFront(pop) + pop.layoutIfNeeded() + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { + pop.dimView.alpha = 1 + pop.sheetView.transform = .identity + } + } + + static func dismiss(completion: (() -> Void)? = nil) { + let pop = PigeonUploadPopView.shared + guard pop.superview != nil else { + completion?() + return + } + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn]) { + pop.dimView.alpha = 0 + pop.sheetView.transform = CGAffineTransform(translationX: 0, y: kScreenHeight) + } completion: { _ in + pop.sheetView.transform = .identity + pop.onAlbum = nil + pop.onCamera = nil + pop.removeFromSuperview() + completion?() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapMask() { + PigeonUploadPopView.dismiss() + } + + @objc private func tapAlbum() { + let action = onAlbum + PigeonUploadPopView.dismiss { + action?() + } + } + + @objc private func tapCamera() { + let action = onCamera + PigeonUploadPopView.dismiss { + action?() + } + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + let point = touch.location(in: self) + return !sheetView.frame.contains(point) + } + + private func setupUI() { + addSubview(dimView) + addSubview(sheetView) + sheetView.addSubview(headerView) + sheetView.addSubview(albumButton) + sheetView.addSubview(cameraButton) + sheetView.addSubview(albumLabel) + sheetView.addSubview(cameraLabel) + + dimView.layoutChain.edges() + + sheetView.layoutChain + .bottom() + .edgesHorzontal() + + headerView.layoutChain + .top() + .edgesHorzontal() + .heightToWidth(200.0 / 750.0) + + albumButton.layoutChain + .topToBottomOfView(headerView, offset: 8) + .centerX(sheetView, offset: -80) + .width(80) + .height(80) + + cameraButton.layoutChain + .topToView(albumButton) + .centerX(sheetView, offset: 80) + .width(80) + .height(80) + + albumLabel.layoutChain + .topToBottomOfView(albumButton, offset: 10) + .centerX(albumButton) + .bottom(22 + kSafeBottomMargin) + + cameraLabel.layoutChain + .topToView(albumLabel) + .centerX(cameraButton) + + let tap = UITapGestureRecognizer(target: self, action: #selector(tapMask)) + tap.delegate = self + addGestureRecognizer(tap) + } + + private lazy var dimView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.45) + return view + }() + + private lazy var sheetView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 40 + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.clipsToBounds = true + return view + }() + + private lazy var headerView: UIImageView = { + let view = UIImageView(image: UIImage(named: "PigeonMessage/upload_header")) + view.contentMode = .scaleToFill + return view + }() + + private lazy var albumButton: UIButton = { + makeOptionButton(imageName: "PigeonMessage/gallery", action: #selector(tapAlbum)) + }() + + private lazy var cameraButton: UIButton = { + makeOptionButton(imageName: "PigeonMessage/camera", action: #selector(tapCamera)) + }() + + private lazy var albumLabel: UILabel = { + makeOptionLabel("本地上传") + }() + + private lazy var cameraLabel: UILabel = { + makeOptionLabel("拍照上传") + }() + + private func makeOptionButton(imageName: String, action: Selector) -> UIButton { + let button = UIButton(type: .custom) + button.backgroundColor = UIColor(hexStr: "#F5F5F5") + button.layer.cornerRadius = 20 + button.setImage(UIImage(named: imageName), for: .normal) + button.addTarget(self, action: action, for: .touchUpInside) + return button + } + + private func makeOptionLabel(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: 14, weight: .medium) + label.textColor = UIColor(hexStr: "#293445") + label.textAlignment = .center + return label + } +} diff --git a/QuickLocation/Section/PigeonMessage/PigeonWatermarkCameraVC.swift b/QuickLocation/Section/PigeonMessage/PigeonWatermarkCameraVC.swift new file mode 100644 index 00000000..11342006 --- /dev/null +++ b/QuickLocation/Section/PigeonMessage/PigeonWatermarkCameraVC.swift @@ -0,0 +1,84 @@ +// +// PigeonWatermarkCameraVC.swift +// QuickLocation +// + +import UIKit +import CoreLocation +import SwiftyUserDefaults +#if !targetEnvironment(simulator) +import AMapSearchKit +#endif + +final class PigeonWatermarkCameraVC: BaseViewController { + + var onPhoto: ((UIImage) -> Void)? + + private var rootView: PigeonWatermarkCameraView! + + #if !targetEnvironment(simulator) + private let searchAPI = AMapSearchAPI() + #endif + + override func loadView() { + rootView = PigeonWatermarkCameraView(frame: UIScreen.main.bounds) + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + #if !targetEnvironment(simulator) + searchAPI?.delegate = self + #endif + rootView.onClose = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + rootView.onConfirm = { [weak self] image in + guard let self else { return } + self.onPhoto?(image) + self.navigationController?.popViewController(animated: true) + } + requestAddress() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + rootView.startSession() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + rootView.stopSession() + } + + private func requestAddress() { + guard let lat = Defaults[\.currentLatitude], + let lon = Defaults[\.currentLongitude] else { return } + let coord = CLLocationCoordinate2D(latitude: lat, longitude: lon) + guard CLLocationCoordinate2DIsValid(coord) else { return } + #if !targetEnvironment(simulator) + let regeo = AMapReGeocodeSearchRequest() + regeo.location = AMapGeoPoint.location( + withLatitude: CGFloat(coord.latitude), + longitude: CGFloat(coord.longitude) + ) + regeo.requireExtension = true + searchAPI?.aMapReGoecodeSearch(regeo) + #endif + } +} + +#if !targetEnvironment(simulator) +extension PigeonWatermarkCameraVC: AMapSearchDelegate { + func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) { + let address = response.regeocode?.formattedAddress + ?? response.regeocode?.pois?.first?.name + ?? "" + rootView.updateAddress(address) + } + + func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) { + print("Pigeon watermark regeo error: \(error.localizedDescription)") + } +} +#endif diff --git a/QuickLocation/Section/PigeonMessage/PigeonWatermarkCameraView.swift b/QuickLocation/Section/PigeonMessage/PigeonWatermarkCameraView.swift new file mode 100644 index 00000000..96e4f062 --- /dev/null +++ b/QuickLocation/Section/PigeonMessage/PigeonWatermarkCameraView.swift @@ -0,0 +1,646 @@ +// +// PigeonWatermarkCameraView.swift +// QuickLocation +// + +import UIKit +import AVFoundation + +final class PigeonWatermarkCameraView: UIView { + + var onClose: (() -> Void)? + var onConfirm: ((UIImage) -> Void)? + + private let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "pigeon.watermark.camera") + private var previewLayer: AVCaptureVideoPreviewLayer? + private var videoInput: AVCaptureDeviceInput? + private var photoOutput: AVCapturePhotoOutput? + + private var cameraPosition: AVCaptureDevice.Position = .back + private var isTorchOn = false + private var isReviewing = false + private var isCapturing = false + private var didConfigureSession = false + private var capturedImage: UIImage? + private var captureDate = Date() + private var addressText = "" + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hexStr: "#F9F9F9") + setupUI() + applyReviewState(false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + let session = self.session + sessionQueue.async { + if session.isRunning { + session.stopRunning() + } + } + } + + func startSession() { + requestAccessAndConfigure() + } + + func stopSession() { + let session = self.session + sessionQueue.async { + if session.isRunning { + session.stopRunning() + } + } + } + + func updateAddress(_ address: String) { + addressText = address + if isReviewing { + refreshWatermark(at: captureDate) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = previewHost.bounds + shutterButton.setNeedsDisplay() + } + + private func setupUI() { + addSubview(gradientView) + addSubview(navView) + addSubview(previewContainer) + previewContainer.addSubview(previewHost) + previewContainer.addSubview(capturedImageView) + previewContainer.addSubview(watermarkView) + addSubview(flashButton) + addSubview(shutterButton) + addSubview(switchButton) + addSubview(retakeButton) + addSubview(confirmButton) + + gradientView.layoutChain + .top() + .edgesHorzontal() + .height(135) + + navView.layoutChain.edges(excludingEdge: .bottom).height(kNaviHeight) + navView.onBackTapped = { [weak self] in + self?.onClose?() + } + + previewContainer.layoutChain + .top(kNaviHeight + 73) + .edgesHorzontal(30) + .heightToWidth(424/315) + + previewHost.layoutChain.edges() + capturedImageView.layoutChain.edges() + + watermarkView.layoutChain + .left(12) + .top(12) + watermarkView.rightAnchor.constraint( + lessThanOrEqualTo: previewContainer.rightAnchor, + constant: -12 + ).isActive = true + + shutterButton.layoutChain + .centerX() + .bottom(kSafeBottomMargin + 18) + .width(84) + .height(84) + + flashButton.layoutChain + .centerY(shutterButton) + .rightToLeftOfView(shutterButton, offset: -52) + .width(50) + .height(50) + + switchButton.layoutChain + .centerY(shutterButton) + .leftToRightOfView(shutterButton, offset: 52) + .width(50) + .height(50) + + retakeButton.layoutChain + .left(30) + .bottom(kSafeBottomMargin + 24) + .height(50) + .width(110) + + confirmButton.layoutChain + .leftToRightOfView(retakeButton, offset: 16) + .right(30) + .bottomToView(retakeButton) + .height(50) + } + + func requestAccessAndConfigure() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + if granted { + self?.configureSession() + } else { + DLToast.show(text: "请在设置中开启相机权限") + } + } + } + default: + DLToast.show(text: "请在设置中开启相机权限") + } + } + + private func configureSession() { + sessionQueue.async { [weak self] in + guard let self else { return } + if !self.didConfigureSession { + self.session.beginConfiguration() + self.session.sessionPreset = .photo + _ = self.attachVideoInput(position: self.cameraPosition) + let output = AVCapturePhotoOutput() + if self.session.canAddOutput(output) { + self.session.addOutput(output) + self.photoOutput = output + } + self.session.commitConfiguration() + self.didConfigureSession = true + DispatchQueue.main.async { + let layer = AVCaptureVideoPreviewLayer(session: self.session) + layer.videoGravity = .resizeAspectFill + layer.frame = self.previewHost.bounds + self.previewHost.layer.insertSublayer(layer, at: 0) + self.previewLayer = layer + self.applyVideoOrientation() + } + } + self.applyVideoOrientation() + if !self.session.isRunning { + self.session.startRunning() + } + } + } + + @discardableResult + private func attachVideoInput(position: AVCaptureDevice.Position) -> Bool { + if let videoInput { + session.removeInput(videoInput) + self.videoInput = nil + } + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return false } + session.addInput(input) + videoInput = input + cameraPosition = position + return true + } + + private func applyVideoOrientation() { + if let conn = photoOutput?.connection(with: .video), conn.isVideoOrientationSupported { + conn.videoOrientation = .portrait + if conn.isVideoMirroringSupported { + conn.automaticallyAdjustsVideoMirroring = false + conn.isVideoMirrored = cameraPosition == .front + } + } + DispatchQueue.main.async { [weak self] in + guard let self, let conn = self.previewLayer?.connection, conn.isVideoOrientationSupported else { return } + conn.videoOrientation = .portrait + } + } + + @objc private func tapFlash() { + guard !isReviewing else { return } + guard cameraPosition == .back, let device = videoInput?.device, device.hasTorch else { + DLToast.show(text: "当前镜头不支持闪光灯") + return + } + do { + try device.lockForConfiguration() + isTorchOn.toggle() + device.torchMode = isTorchOn ? .on : .off + device.unlockForConfiguration() + flashButton.alpha = isTorchOn ? 1 : 0.72 + } catch { + DLToast.show(text: "闪光灯开启失败") + } + } + + @objc private func tapSwitch() { + guard !isReviewing else { return } + let next: AVCaptureDevice.Position = cameraPosition == .back ? .front : .back + sessionQueue.async { [weak self] in + guard let self else { return } + self.session.beginConfiguration() + _ = self.attachVideoInput(position: next) + if self.isTorchOn, next == .front { + self.isTorchOn = false + } + self.applyVideoOrientation() + self.session.commitConfiguration() + DispatchQueue.main.async { + self.flashButton.alpha = self.isTorchOn ? 1 : 0.72 + } + } + } + + @objc private func tapShutter() { + guard !isReviewing, !isCapturing, let photoOutput else { return } + isCapturing = true + let settings = AVCapturePhotoSettings() + if let device = videoInput?.device, device.hasFlash, cameraPosition == .back { + settings.flashMode = isTorchOn ? .on : .off + } + photoOutput.capturePhoto(with: settings, delegate: self) + } + + @objc private func tapRetake() { + capturedImage = nil + capturedImageView.image = nil + isReviewing = false + isCapturing = false + applyReviewState(false) + } + + @objc private func tapConfirm() { + guard let image = makeWatermarkedImage() else { return } + onConfirm?(image) + } + + private func applyReviewState(_ reviewing: Bool) { + capturedImageView.isHidden = !reviewing + watermarkView.isHidden = !reviewing + flashButton.isHidden = reviewing + shutterButton.isHidden = reviewing + switchButton.isHidden = reviewing + retakeButton.isHidden = !reviewing + confirmButton.isHidden = !reviewing + } + + private func refreshWatermark(at date: Date) { + let timeFormatter = DateFormatter() + timeFormatter.locale = Locale(identifier: "zh_CN") + timeFormatter.dateFormat = "HH:mm" + timeLabel.text = timeFormatter.string(from: date) + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "zh_CN") + dateFormatter.dateFormat = "yyyy-M-d" + dateLabel.text = dateFormatter.string(from: date) + + let weekday = Calendar(identifier: .gregorian).component(.weekday, from: date) + let names = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"] + weekdayLabel.text = names[max(0, min(6, weekday - 1))] + + let hasAddress = !addressText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + locationRow.isHidden = !hasAddress + addressLabel.text = addressText + } + + private func makeWatermarkedImage() -> UIImage? { + guard let capturedImage else { return nil } + layoutIfNeeded() + let target = previewContainer.bounds.size + let watermarkSize = watermarkView.bounds.size + let watermarkFrame = watermarkView.frame + guard target.width > 1, target.height > 1, watermarkSize.width > 1 else { return capturedImage } + + watermarkBlurView.isHidden = true + watermarkTintView.isHidden = true + let borderWidth = watermarkView.layer.borderWidth + watermarkView.layer.borderWidth = 0 + layoutIfNeeded() + + let scale = UIScreen.main.scale + let clearFormat = UIGraphicsImageRendererFormat.default() + clearFormat.scale = scale + clearFormat.opaque = false + let contentImage = UIGraphicsImageRenderer(size: watermarkSize, format: clearFormat).image { ctx in + ctx.cgContext.clear(CGRect(origin: .zero, size: watermarkSize)) + watermarkView.drawHierarchy( + in: CGRect(origin: .zero, size: watermarkSize), + afterScreenUpdates: true + ) + } + + watermarkBlurView.isHidden = false + watermarkTintView.isHidden = false + watermarkView.layer.borderWidth = borderWidth + + let photoFormat = UIGraphicsImageRendererFormat.default() + photoFormat.scale = scale + photoFormat.opaque = true + return UIGraphicsImageRenderer(size: target, format: photoFormat).image { _ in + Self.drawAspectFill(capturedImage, in: CGRect(origin: .zero, size: target)) + let card = UIBezierPath(roundedRect: watermarkFrame, cornerRadius: 14) + UIColor.white.withAlphaComponent(0.28).setFill() + card.fill() + UIColor.white.setStroke() + card.lineWidth = 1 + card.stroke() + contentImage.draw(in: watermarkFrame) + } + } + + private static func drawAspectFill(_ image: UIImage, in rect: CGRect) { + let imgSize = image.size + guard imgSize.width > 0, imgSize.height > 0 else { return } + let scale = max(rect.width / imgSize.width, rect.height / imgSize.height) + let scaled = CGSize(width: imgSize.width * scale, height: imgSize.height * scale) + let origin = CGPoint( + x: rect.midX - scaled.width / 2, + y: rect.midY - scaled.height / 2 + ) + image.draw(in: CGRect(origin: origin, size: scaled)) + } + + private lazy var gradientView: PigeonCameraGradientView = { + let view = PigeonCameraGradientView() + view.isUserInteractionEnabled = false + return view + }() + + private lazy var navView: BaseNavigationView = { + BaseNavigationView(title: "水印拍照") + }() + + private lazy var previewContainer: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#DDE1E5") + view.layer.cornerRadius = 24 + view.clipsToBounds = true + return view + }() + + private lazy var previewHost: UIView = { + let view = UIView() + view.backgroundColor = .black + return view + }() + + private lazy var capturedImageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.isHidden = true + return view + }() + + private lazy var watermarkBlurView: UIVisualEffectView = { + let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialLight)) + view.isUserInteractionEnabled = false + return view + }() + + private lazy var watermarkTintView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.white.withAlphaComponent(0.08) + view.isOpaque = false + view.isUserInteractionEnabled = false + return view + }() + + private lazy var timeChipView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#00ADFE") + view.layer.cornerRadius = 10 + + let logo = UIView() + logo.backgroundColor = .white + logo.layer.cornerRadius = 6 + + timeLabel.setContentHuggingPriority(.required, for: .horizontal) + timeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + view.addSubview(logo) + view.addSubview(timeLabel) + logo.layoutChain + .left(6) + .centerY() + .width(22) + .height(22) + timeLabel.layoutChain + .leftToRightOfView(logo, offset: 6) + .right(8) + .top(6) + .bottom(6) + return view + }() + + private lazy var watermarkView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.isOpaque = false + view.layer.cornerRadius = 14 + view.layer.borderWidth = 1 + view.layer.borderColor = UIColor.white.cgColor + view.clipsToBounds = true + view.isHidden = true + + view.addSubview(watermarkBlurView) + view.addSubview(watermarkTintView) + watermarkBlurView.layoutChain.edges() + watermarkTintView.layoutChain.edges() + + let dateColumn = UIStackView(arrangedSubviews: [dateLabel, weekdayLabel]) + dateColumn.axis = .vertical + dateColumn.spacing = 1 + + let topRow = UIStackView(arrangedSubviews: [timeChipView, dateColumn]) + topRow.axis = .horizontal + topRow.alignment = .center + topRow.spacing = 8 + + let stack = UIStackView(arrangedSubviews: [topRow, locationRow]) + stack.axis = .vertical + stack.alignment = .fill + stack.spacing = 8 + view.addSubview(stack) + stack.layoutChain + .top(10) + .left(10) + .right(12) + .bottom(10) + return view + }() + + private lazy var timeLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 22, weight: .semibold) + label.textColor = .white + return label + }() + + private lazy var dateLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 11, weight: .medium) + label.textColor = UIColor(hexStr: "#293445") + return label + }() + + private lazy var weekdayLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 11, weight: .medium) + label.textColor = UIColor(hexStr: "#293445") + return label + }() + + private lazy var addressLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 11, weight: .medium) + label.textColor = UIColor(hexStr: "#293445") + label.numberOfLines = 2 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private lazy var locationRow: UIView = { + let row = UIView() + row.isHidden = true + let pin = UIImageView(image: UIImage(named: "PigeonMessage/sparkle")) + pin.contentMode = .scaleAspectFit + row.addSubview(pin) + row.addSubview(addressLabel) + pin.layoutChain + .left() + .top(1) + .width(11) + .height(11) + addressLabel.layoutChain + .leftToRightOfView(pin, offset: 4) + .right() + .top() + .bottom() + return row + }() + + private lazy var flashButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "PigeonMessage/flash"), for: .normal) + button.alpha = 0.72 + button.addTarget(self, action: #selector(tapFlash), for: .touchUpInside) + return button + }() + + private lazy var switchButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "PigeonMessage/camera_switch"), for: .normal) + button.addTarget(self, action: #selector(tapSwitch), for: .touchUpInside) + return button + }() + + private lazy var shutterButton: PigeonShutterButton = { + let button = PigeonShutterButton() + button.addTarget(self, action: #selector(tapShutter), for: .touchUpInside) + return button + }() + + private lazy var retakeButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("重拍", for: .normal) + button.setTitleColor(UIColor(hexStr: "#13B6F1"), for: .normal) + button.titleLabel?.font = FontManager.boboBold(18) + button.backgroundColor = .white + button.layer.cornerRadius = 16 + button.layer.borderWidth = 1.5 + button.layer.borderColor = UIColor(hexStr: "#13B6F1").cgColor + button.addTarget(self, action: #selector(tapRetake), for: .touchUpInside) + return button + }() + + private lazy var confirmButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("确定", for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = FontManager.boboBold(18) + button.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) + button.clipsToBounds = true + button.cornerRadius = 16 + button.addTarget(self, action: #selector(tapConfirm), for: .touchUpInside) + return button + }() +} + +extension PigeonWatermarkCameraView: AVCapturePhotoCaptureDelegate { + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error? + ) { + isCapturing = false + if let error { + DLToast.show(text: "拍照失败:\(error.localizedDescription)") + return + } + guard let data = photo.fileDataRepresentation(), let image = UIImage(data: data) else { + DLToast.show(text: "拍照失败") + return + } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.captureDate = Date() + self.capturedImage = image + self.capturedImageView.image = image + self.refreshWatermark(at: self.captureDate) + self.isReviewing = true + self.applyReviewState(true) + } + } +} + +private final class PigeonCameraGradientView: UIView { + override class var layerClass: AnyClass { CAGradientLayer.self } + + override init(frame: CGRect) { + super.init(frame: frame) + guard let gradient = layer as? CAGradientLayer else { return } + gradient.colors = [ + UIColor(hexStr: "#BAF4FF").cgColor, + UIColor(hexStr: "#BAF4FF", alpha: 0).cgColor + ] + gradient.startPoint = CGPoint(x: 0.5, y: 0) + gradient.endPoint = CGPoint(x: 0.5, y: 1) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class PigeonShutterButton: UIControl { + 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 layoutSubviews() { + super.layoutSubviews() + setNeedsDisplay() + } + + override func draw(_ rect: CGRect) { + let color = UIColor(hexStr: "#13B6F1") + let ring = UIBezierPath(ovalIn: rect.insetBy(dx: 3, dy: 3)) + color.setStroke() + ring.lineWidth = 4 + ring.stroke() + color.setFill() + UIBezierPath(ovalIn: rect.insetBy(dx: 11, dy: 11)).fill() + } +} diff --git a/QuickLocation/Service/PigeonService.swift b/QuickLocation/Service/PigeonService.swift new file mode 100644 index 00000000..d81c0c51 --- /dev/null +++ b/QuickLocation/Service/PigeonService.swift @@ -0,0 +1,49 @@ +// +// PigeonService.swift +// QuickLocation +// + +import RxSwift + +struct PigeonService { + static func send( + groupKey: String, + toUsers: [String], + msgType: Int, + fileId: String, + textMsg: String + ) -> Observable { + let api = PigeonAPI.send( + groupKey: groupKey, + toUsers: toUsers, + msgType: msgType, + msg: fileId, + textMsg: textMsg, + expireTime: 0 + ).multiTarget + return APIProvider.request(token: api, handle: false) + .map(ResponseModel.self) + .asObservable() + } + + static func sent(groupKey: String) -> Observable { + let api = PigeonAPI.sent(groupKey: groupKey).multiTarget + return APIProvider.request(token: api) + .map(PigeonSentResponse.self) + .asObservable() + } + + static func inbox() -> Observable { + let api = PigeonAPI.inbox.multiTarget + return APIProvider.request(token: api, handle: false) + .map(PigeonSentResponse.self) + .asObservable() + } + + static func burn(messageUUID: String) -> Observable { + let api = PigeonAPI.burn(messageUUID: messageUUID).multiTarget + return APIProvider.request(token: api, handle: false) + .map(ResponseModel.self) + .asObservable() + } +} diff --git a/QuickLocation/Service/UploadService.swift b/QuickLocation/Service/UploadService.swift new file mode 100644 index 00000000..65b5bb65 --- /dev/null +++ b/QuickLocation/Service/UploadService.swift @@ -0,0 +1,184 @@ +// +// UploadService.swift +// QuickLocation +// + +import Foundation +import RxSwift +import SwiftyJSON +import CommonCrypto + +enum UploadFileKind { + case jpeg + case mp4 + case wav + + var fileExtension: String { + switch self { + case .jpeg: return "jpg" + case .mp4: return "mp4" + case .wav: return "wav" + } + } + + var mimeType: String { + switch self { + case .jpeg: return "image/jpeg" + case .mp4: return "video/mp4" + case .wav: return "audio/wav" + } + } +} + +struct UploadService { + + private static let boundary = "YLQH" + private static let jiaMiKey = "857d69d374694c1de46486d3bdaaac43" + + static func upload(_ data: Data, kind: UploadFileKind, scene: String? = nil) -> Observable { + Observable.create { observer in + do { + let request = try makeRequest(fileData: data, kind: kind, scene: scene) + let session = URLSession(configuration: .default) + let task = session.uploadTask(with: request.request, from: request.body) { data, response, error in + if let error { + DispatchQueue.main.async { + observer.onError(error) + } + return + } + guard let data else { + DispatchQueue.main.async { + observer.onError(makeError("上传失败")) + } + return + } + let decrypted = ApiManager.decryptIfNeeded(data) ?? data + let json = JSON(decrypted) + let code = json["code"].intValue + guard code == 0 || json["success"].boolValue else { + let message = json["message"].string ?? "上传失败" + DispatchQueue.main.async { + observer.onError(makeError(message)) + } + return + } + let nestedId = json["data"]["id"].stringValue + let nestedFileId = json["data"]["file_id"].stringValue + let dataString = json["data"].stringValue + let fileId: String? = { + if !nestedId.isEmpty { return nestedId } + if !nestedFileId.isEmpty { return nestedFileId } + if !dataString.isEmpty, json["data"].type != .dictionary { return dataString } + return nil + }() + guard let fileId, !fileId.isEmpty else { + DispatchQueue.main.async { + observer.onError(makeError("上传成功但未返回文件 id")) + } + return + } + DispatchQueue.main.async { + observer.onNext(fileId) + observer.onCompleted() + } + } + task.resume() + return Disposables.create { + task.cancel() + } + } catch { + observer.onError(error) + return Disposables.create() + } + } + } + + private static func makeRequest(fileData: Data, kind: UploadFileKind, scene: String?) throws -> (request: URLRequest, body: Data) { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMddHHmmss" + let fileName = "\(formatter.string(from: Date())).\(kind.fileExtension)" + let body = multipartBody(fileData: fileData, fileName: fileName, mimeType: kind.mimeType) + + var query: [String: String] = [ + "timestamp": String(format: "%.0lf", Date().timeIntervalSince1970), + "nonce": randomString(length: 32) + ] + if let scene, !scene.isEmpty { + query["scene"] = scene + } + let queryString = sortedQueryString(query) + let signature = sign(queryString: queryString, body: body) + let signedQuery = queryString + "&signature=" + signature + let encodedQuery = signedQuery.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? signedQuery + + let base = AppNetworkConfig.shared.baseURL + guard let url = URL(string: base + "api/user/upload?" + encodedQuery) else { + throw makeError("上传地址无效") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 180 + var headers = ApiManager.shared.httpHeader() + headers["Content-Type"] = "multipart/form-data; boundary=\(boundary)" + headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } + return (request, body) + } + + private static func multipartBody(fileData: Data, fileName: String, mimeType: String) -> Data { + var data = Data() + func append(_ string: String) { + if let part = string.data(using: .utf8) { + data.append(part) + } + } + append("--\(boundary)\r\n") + append("Content-Disposition:form-data; name=\"file\"; filename=\"\(fileName)\"\r\n") + append("Content-Type:\(mimeType)\r\n") + append("\r\n") + data.append(fileData) + append("\r\n") + append("--\(boundary)--\r\n") + return data + } + + private static func sign(queryString: String, body: Data) -> String { + var combined = Data() + combined.append(Data((queryString + "&").utf8)) + combined.append(body) + combined.append(Data(("&" + md5(jiaMiKey)).utf8)) + return md5(combined) + } + + private static func sortedQueryString(_ dict: [String: String]) -> String { + dict.sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: "&") + } + + private static func randomString(length: Int) -> String { + let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return String((0.. String { + md5(Data(string.utf8)) + } + + private static func md5(_ data: Data) -> String { + var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) + data.withUnsafeBytes { buffer in + _ = CC_MD5(buffer.baseAddress, CC_LONG(data.count), &digest) + } + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func makeError(_ message: String) -> NSError { + NSError( + domain: "UploadService", + code: -1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } +}