- 接口联调

This commit is contained in:
linshujie 2026-08-26 18:26:58 +08:00
parent 59e09f82e7
commit c7c520effa
82 changed files with 2702 additions and 719 deletions

View File

@ -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 = "<group>"; };
305A74CE2FCA8C7000227D26 /* SystemAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAPI.swift; sourceTree = "<group>"; };
305A74CF2FCA8C7000227D26 /* UserAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAPI.swift; sourceTree = "<group>"; };
55B230023032000100785003 /* PigeonAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonAPI.swift; sourceTree = "<group>"; };
55B230003032000100785001 /* UploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadService.swift; sourceTree = "<group>"; };
55B230043032000100785005 /* PigeonService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonService.swift; sourceTree = "<group>"; };
305A74D12FCA8C7000227D26 /* adhoc.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = adhoc.xcconfig; sourceTree = "<group>"; };
305A74D22FCA8C7000227D26 /* Constant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constant.swift; sourceTree = "<group>"; };
305A74D32FCA8C7000227D26 /* LogUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogUtils.swift; sourceTree = "<group>"; };
@ -507,6 +518,7 @@
305A75292FCA8C7000227D26 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
305A752B2FCA8C7000227D26 /* URLManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLManager.swift; sourceTree = "<group>"; };
305A752E2FCA8C7000227D26 /* GroupModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupModel.swift; sourceTree = "<group>"; };
55B230063032000100785007 /* PigeonModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonModel.swift; sourceTree = "<group>"; };
305A752F2FCA8C7000227D26 /* SystemResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemResponse.swift; sourceTree = "<group>"; };
305A75312FCA8C7000227D26 /* ImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePlugin.swift; sourceTree = "<group>"; };
305A75322FCA8C7000227D26 /* NotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotEmpty.swift; sourceTree = "<group>"; };
@ -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 = "<group>"; };
AC46E80B848E4012D192E4CE /* LockDistractView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LockDistractView.swift; sourceTree = "<group>"; };
55B230083032000100785009 /* LockDistractPermissionPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockDistractPermissionPopView.swift; sourceTree = "<group>"; };
55B232003034000100785030 /* PigeonUploadPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonUploadPopView.swift; sourceTree = "<group>"; };
55B232023034000100785032 /* PigeonWatermarkCameraVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonWatermarkCameraVC.swift; sourceTree = "<group>"; };
55B232043034000100785034 /* PigeonWatermarkCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonWatermarkCameraView.swift; sourceTree = "<group>"; };
BE3D39F325FC7624C1D914D6 /* ITunesSearchService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ITunesSearchService.swift; path = AppRestrict/ITunesSearchService.swift; sourceTree = "<group>"; };
BFFBDE92B94E7004F6B642AD /* AppRestrictShieldSettingsVC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppRestrictShieldSettingsVC.swift; path = AppRestrict/AppRestrictShieldSettingsVC.swift; sourceTree = "<group>"; };
D0112B7865829D5B6A4A0BD0 /* AppRestrictVC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppRestrictVC.swift; path = AppRestrict/AppRestrictVC.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -2105,6 +2128,7 @@
children = (
F9CFFB14BA2ECC259151BCDA /* LockDistractVC.swift */,
AC46E80B848E4012D192E4CE /* LockDistractView.swift */,
55B230083032000100785009 /* LockDistractPermissionPopView.swift */,
);
path = LockDistract;
sourceTree = "<group>";
@ -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 */,

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 551 B

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = [
"想对你说的话都藏在这里,请查收呀",
"今天也要开开心心,注意安全哦"
]
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)
}
guard !candidates.isEmpty else {
return [
private func makeReceiveDisplayItem(_ message: PigeonSentMessage) -> ReceiveMessageDisplayItem {
ReceiveMessageDisplayItem(
senderName: "圈子成员",
avatar: fallbackImage,
image: fallbackImage,
message: mockMessages[0]
),
ReceiveMessageDisplayItem(
senderName: "圈子成员",
avatar: fallbackImage,
image: fallbackImage,
message: mockMessages[1],
kind: .voice,
duration: 10
)
]
}
if let selectedIndex = candidates.firstIndex(where: { $0.user_id == selectedMemberId }) {
let selectedMember = candidates.remove(at: selectedIndex)
candidates.insert(selectedMember, at: 0)
}
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
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
)
}
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)
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,13 +63,20 @@ 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([])
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: model.default_group_key)
GroupService.groupUsers(groupKey: groupKey)
.subscribe(onNext: { [weak self] response in
guard response.isValid(for: model.default_group_key) else {
guard response.isValid(for: groupKey) else {
DLToast.showError(text: response.message ?? "获取圈子成员失败")
return
}
@ -76,8 +84,7 @@ final class LockDistractVC: BaseViewController {
}, onError: { error in
DLToast.showError(text: error.localizedDescription)
})
.disposed(by: self.disposeBag)
}).disposed(by: disposeBag)
.disposed(by: disposeBag)
}
private func applyMembers(_ members: [GroupMemberModel]) {
@ -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)
}
}
}

View File

@ -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<Int> = []
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)
.bottom(24)
tipRow.layoutChain
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,18 +384,20 @@ 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()
@ -390,35 +405,54 @@ final class LockDistractView: UIView {
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"))
arrow.isUserInteractionEnabled = false
btn.addSubview(arrow)
view.addSubview(arrow)
view.addSubview(notFetchedText)
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,19 +591,74 @@ 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
}
}
@ -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" : "")
}
}

View File

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

View File

@ -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.kf.cancelDownloadTask()
if let mediaURL = item.mediaURL, !isVoice {
photoView.kf.setImage(with: mediaURL, placeholder: nil)
} else {
photoView.image = item.image
captionLabel.text = "留言:\(String(item.caption.prefix(20)))"
}
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)

View File

@ -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,15 +78,26 @@ final class PigeonMessageVC: BaseViewController {
DLToast.showError(text: response.message ?? "获取圈子信息失败")
return
}
guard !model.default_group_key.isEmpty else {
self.groupModel = model
self.activeGroupKey = model.default_group_key
self.loadUsers(for: self.activeGroupKey, showLoading: false)
}, onError: { error in
DLToast.dismiss()
self.applyMembers([])
DLToast.showError(text: error.localizedDescription)
}).disposed(by: disposeBag)
}
private func loadUsers(for groupKey: String, showLoading: Bool) {
guard !groupKey.isEmpty else {
DLToast.dismiss()
applyMembers([])
return
}
GroupService.groupUsers(groupKey: model.default_group_key)
if showLoading { DLToast.showLoading() }
GroupService.groupUsers(groupKey: groupKey)
.subscribe(onNext: { [weak self] response in
DLToast.dismiss()
guard response.isValid(for: model.default_group_key) else {
guard response.isValid(for: groupKey) else {
DLToast.showError(text: response.message ?? "获取圈子成员失败")
return
}
@ -96,80 +106,102 @@ final class PigeonMessageVC: BaseViewController {
DLToast.dismiss()
DLToast.showError(text: error.localizedDescription)
})
.disposed(by: self.disposeBag)
}, onError: { error in
DLToast.dismiss()
DLToast.showError(text: error.localizedDescription)
}).disposed(by: disposeBag)
.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<String>
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.showLoading()
upload
.flatMap { [weak self] fileId -> Observable<ResponseModel> 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) {

View File

@ -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<String> = []
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<String>) {
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
}
}

View File

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

View File

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

View File

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

View File

@ -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<ResponseModel> {
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<PigeonSentResponse> {
let api = PigeonAPI.sent(groupKey: groupKey).multiTarget
return APIProvider.request(token: api)
.map(PigeonSentResponse.self)
.asObservable()
}
static func inbox() -> Observable<PigeonSentResponse> {
let api = PigeonAPI.inbox.multiTarget
return APIProvider.request(token: api, handle: false)
.map(PigeonSentResponse.self)
.asObservable()
}
static func burn(messageUUID: String) -> Observable<ResponseModel> {
let api = PigeonAPI.burn(messageUUID: messageUUID).multiTarget
return APIProvider.request(token: api, handle: false)
.map(ResponseModel.self)
.asObservable()
}
}

View File

@ -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<String> {
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..<length).map { _ in chars.randomElement()! })
}
private static func md5(_ string: String) -> 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]
)
}
}