IM聊天页

发送位置(选择位置页)
This commit is contained in:
linshujie 2026-08-17 18:31:16 +08:00
parent 544f0c009c
commit ff8eb522de
69 changed files with 2753 additions and 531 deletions

View File

@ -321,6 +321,9 @@
55B218A13024A00100784708 /* CancelAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784707 /* CancelAccountView.swift */; }; 55B218A13024A00100784708 /* CancelAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784707 /* CancelAccountView.swift */; };
55B218A13024A0010078470A /* MemberInfoVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784709 /* MemberInfoVC.swift */; }; 55B218A13024A0010078470A /* MemberInfoVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784709 /* MemberInfoVC.swift */; };
55B218A13024A0010078470C /* MemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A0010078470B /* MemberInfoView.swift */; }; 55B218A13024A0010078470C /* MemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A0010078470B /* MemberInfoView.swift */; };
55B220013031000100784801 /* FeedbackVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B220003031000100784800 /* FeedbackVC.swift */; };
55B220033031000100784803 /* FeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B220023031000100784802 /* FeedbackView.swift */; };
55B220053031000100784805 /* FeedbackSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B220043031000100784804 /* FeedbackSuccessView.swift */; };
55B218B13024B00100784722 /* FeatureIntroVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784721 /* FeatureIntroVC.swift */; }; 55B218B13024B00100784722 /* FeatureIntroVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784721 /* FeatureIntroVC.swift */; };
55B218B13024B00100784724 /* FeatureIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784723 /* FeatureIntroView.swift */; }; 55B218B13024B00100784724 /* FeatureIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784723 /* FeatureIntroView.swift */; };
55B219A2302A000100784751 /* UnlockRequestPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219A1302A000100784750 /* UnlockRequestPopView.swift */; }; 55B219A2302A000100784751 /* UnlockRequestPopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B219A1302A000100784750 /* UnlockRequestPopView.swift */; };
@ -731,6 +734,9 @@
55B218A13024A00100784707 /* CancelAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountView.swift; sourceTree = "<group>"; }; 55B218A13024A00100784707 /* CancelAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelAccountView.swift; sourceTree = "<group>"; };
55B218A13024A00100784709 /* MemberInfoVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoVC.swift; sourceTree = "<group>"; }; 55B218A13024A00100784709 /* MemberInfoVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoVC.swift; sourceTree = "<group>"; };
55B218A13024A0010078470B /* MemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoView.swift; sourceTree = "<group>"; }; 55B218A13024A0010078470B /* MemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoView.swift; sourceTree = "<group>"; };
55B220003031000100784800 /* FeedbackVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackVC.swift; sourceTree = "<group>"; };
55B220023031000100784802 /* FeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackView.swift; sourceTree = "<group>"; };
55B220043031000100784804 /* FeedbackSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackSuccessView.swift; sourceTree = "<group>"; };
55B218B13024B00100784721 /* FeatureIntroVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroVC.swift; sourceTree = "<group>"; }; 55B218B13024B00100784721 /* FeatureIntroVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroVC.swift; sourceTree = "<group>"; };
55B218B13024B00100784723 /* FeatureIntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroView.swift; sourceTree = "<group>"; }; 55B218B13024B00100784723 /* FeatureIntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroView.swift; sourceTree = "<group>"; };
55B219A1302A000100784750 /* UnlockRequestPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockRequestPopView.swift; sourceTree = "<group>"; }; 55B219A1302A000100784750 /* UnlockRequestPopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnlockRequestPopView.swift; sourceTree = "<group>"; };
@ -1285,6 +1291,7 @@
30EFF3CB2FDA667A00EB35D4 /* MyProfile */, 30EFF3CB2FDA667A00EB35D4 /* MyProfile */,
30EFF3BC2FD9585200EB35D4 /* Account */, 30EFF3BC2FD9585200EB35D4 /* Account */,
55B218A13024A00100784710 /* About */, 55B218A13024A00100784710 /* About */,
55B220063031000100784806 /* Feedback */,
55B218A13024A00100784711 /* CancelAccount */, 55B218A13024A00100784711 /* CancelAccount */,
30EFF3D42FDA8EF200EB35D4 /* EmergencyContact */, 30EFF3D42FDA8EF200EB35D4 /* EmergencyContact */,
30EFF3E32FDAA92200EB35D4 /* PrivacyPolicy */, 30EFF3E32FDAA92200EB35D4 /* PrivacyPolicy */,
@ -1973,6 +1980,16 @@
path = MemberInfo; path = MemberInfo;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
55B220063031000100784806 /* Feedback */ = {
isa = PBXGroup;
children = (
55B220003031000100784800 /* FeedbackVC.swift */,
55B220023031000100784802 /* FeedbackView.swift */,
55B220043031000100784804 /* FeedbackSuccessView.swift */,
);
path = Feedback;
sourceTree = "<group>";
};
55B218B13024B00100784720 /* Explore */ = { 55B218B13024B00100784720 /* Explore */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2390,6 +2407,9 @@
55B218A13024A00100784708 /* CancelAccountView.swift in Sources */, 55B218A13024A00100784708 /* CancelAccountView.swift in Sources */,
55B218A13024A0010078470A /* MemberInfoVC.swift in Sources */, 55B218A13024A0010078470A /* MemberInfoVC.swift in Sources */,
55B218A13024A0010078470C /* MemberInfoView.swift in Sources */, 55B218A13024A0010078470C /* MemberInfoView.swift in Sources */,
55B220013031000100784801 /* FeedbackVC.swift in Sources */,
55B220033031000100784803 /* FeedbackView.swift in Sources */,
55B220053031000100784805 /* FeedbackSuccessView.swift in Sources */,
30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */, 30A87A622FEE724D0095E7C6 /* CreateBubbleTiemView.swift in Sources */,
30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */, 30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */,
30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */, 30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */,

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "remove_member_search@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "remove_member_search@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "remove_member_selected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "remove_member_selected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "remove_member_unselected@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "remove_member_unselected@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "at@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "at@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

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: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "emoji@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "emoji@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "gallery@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "gallery@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -5,12 +5,12 @@
"scale" : "1x" "scale" : "1x"
}, },
{ {
"filename" : "Icons_Photo@2x.png", "filename" : "keyboard@2x.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "2x" "scale" : "2x"
}, },
{ {
"filename" : "Icons_Photo@3x.png", "filename" : "keyboard@3x.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "3x" "scale" : "3x"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "location@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "location@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "location_pin@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "location_pin@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "location_recenter@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "location_recenter@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 963 B

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "reply@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "reply@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "send-2@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "send-2@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 981 B

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "voice@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "voice@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.8 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "add@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "add@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "header@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "header@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -0,0 +1,8 @@
{
"images" : [
{ "idiom" : "universal", "scale" : "1x" },
{ "filename" : "icon@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "icon@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -81,6 +81,8 @@ enum Route: String {
case phoneReportDetail = "phoneReportDetail" case phoneReportDetail = "phoneReportDetail"
/// ///
case about = "about" case about = "about"
///
case feedback = "feedback"
/// ///
case memberInfo = "memberInfo" case memberInfo = "memberInfo"
/// ///
@ -384,6 +386,11 @@ extension AppRouter: AppRouterProtocol {
AboutVC() AboutVC()
} }
// MARK: -
AppRouter.register(Route.feedback) { _, _ in
FeedbackVC()
}
// MARK: - // MARK: -
AppRouter.register(Route.memberInfo) { _, parameters in AppRouter.register(Route.memberInfo) { _, parameters in
MemberInfoVC(userInfo: parameters) MemberInfoVC(userInfo: parameters)
@ -430,4 +437,3 @@ extension AppRouter: AppRouterProtocol {
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@
// //
import UIKit import UIKit
import CoreLocation
import RxSwift import RxSwift
import RxCocoa import RxCocoa
import OpenIMSDK import OpenIMSDK
@ -22,6 +23,40 @@ struct QuotePreview {
let quotedClientMsgID: String? let quotedClientMsgID: String?
} }
struct ChatLocationPayload: Codable, Equatable {
let name: String
let address: String
let latitude: Double
let longitude: Double
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
var encodedDescription: String {
guard let data = try? JSONEncoder().encode(self),
let value = String(data: data, encoding: .utf8) else {
return [name, address].filter { !$0.isEmpty }.joined(separator: "\n")
}
return value
}
static func decode(description: String?, latitude: Double, longitude: Double) -> ChatLocationPayload {
if let description,
let data = description.data(using: .utf8),
let payload = try? JSONDecoder().decode(ChatLocationPayload.self, from: data) {
return payload
}
let parts = (description ?? "").components(separatedBy: "\n")
return ChatLocationPayload(
name: parts.first ?? "",
address: parts.dropFirst().joined(separator: "\n"),
latitude: latitude,
longitude: longitude
)
}
}
enum ChatSectionItem { enum ChatSectionItem {
case send(ChatMessage) case send(ChatMessage)
case received(ChatMessage) case received(ChatMessage)
@ -31,6 +66,8 @@ enum ChatSectionItem {
case voiceReceived(ChatMessage) case voiceReceived(ChatMessage)
case imageSend(ChatMessage) case imageSend(ChatMessage)
case imageReceived(ChatMessage) case imageReceived(ChatMessage)
case locationSend(ChatMessage)
case locationReceived(ChatMessage)
case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0) case notification(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
case revoked(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0) case revoked(NSAttributedString, showTime: Bool = false, timestamp: TimeInterval = 0)
} }
@ -57,7 +94,9 @@ final class GroupChatViewModel {
private var lastTimeGap: TimeInterval = 0 private var lastTimeGap: TimeInterval = 0
private let timeGapThreshold: TimeInterval = 300 private let timeGapThreshold: TimeInterval = 300
var groupModel: GroupInfoModel? var groupModel: GroupInfoModel? {
didSet { refreshMessageMetadata() }
}
var memberList: [GroupMemberModel] = [] { var memberList: [GroupMemberModel] = [] {
didSet { buildAvatarCache() } didSet { buildAvatarCache() }
} }
@ -110,16 +149,23 @@ final class GroupChatViewModel {
cache[member.user_id] = member.userIcon cache[member.user_id] = member.userIcon
} }
avatarCache = cache avatarCache = cache
refreshMessageAvatars() refreshMessageMetadata()
} }
private func refreshMessageAvatars() { private func refreshMessageMetadata() {
var items = messagesSubject.value var items = messagesSubject.value
var didChange = false var didChange = false
items = items.map { item in items = items.map { item in
guard var m = item.chatMessage, updateAvatar(&m) else { return item } guard var message = item.chatMessage else { return item }
let avatarUpdated = updateAvatar(&message)
let isOwner = isCircleOwner(id: message.senderId)
let ownerUpdated = message.isCircleOwner != isOwner
if ownerUpdated {
message = message.with(isCircleOwner: isOwner)
}
guard avatarUpdated || ownerUpdated else { return item }
didChange = true didChange = true
return ChatSectionItem.with(m) return ChatSectionItem.with(message)
} }
if didChange { messagesSubject.accept(items) } if didChange { messagesSubject.accept(items) }
} }
@ -144,6 +190,11 @@ final class GroupChatViewModel {
memberList.first { id == $0.user_id }?.nick_name ?? "" memberList.first { id == $0.user_id }?.nick_name ?? ""
} }
func isCircleOwner(id: String) -> Bool {
guard !id.isEmpty, let groupModel else { return false }
return groupModel.group_key.contains(id)
}
// MARK: - Quote / Mention state // MARK: - Quote / Mention state
func setPendingQuote(_ msg: OIMMessageInfo?) { func setPendingQuote(_ msg: OIMMessageInfo?) {
pendingQuote = msg pendingQuote = msg
@ -239,6 +290,26 @@ final class GroupChatViewModel {
}) })
} }
func sendLocation(_ location: ChatLocationPayload) {
let msg = OIMMessageInfo.createLocationMessage(location.encodedDescription,
latitude: location.latitude,
longitude: location.longitude)
OIMManager.manager.sendMessage(msg,
recvID: "",
groupID: groupId,
offlinePushInfo: nil,
onSuccess: { [weak self] _ in
self?.cacheMessage(msg)
self?.appendMessage(msg)
},
onProgress: nil as OIMNumberCallback?,
onFailure: { code, errMsg in
DispatchQueue.main.async {
DLToast.showError(text: errMsg ?? "位置发送失败(\(code))")
}
})
}
// MARK: - Revoke // MARK: - Revoke
func revokeMessage(clientMsgID: String) { func revokeMessage(clientMsgID: String) {
OIMManager.manager.revokeMessage(conversationID, OIMManager.manager.revokeMessage(conversationID,
@ -278,7 +349,8 @@ final class GroupChatViewModel {
let showTime: Bool = { let showTime: Bool = {
switch items[idx] { switch items[idx] {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m): let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m),
let .locationSend(m), let .locationReceived(m):
return m.showTime return m.showTime
case let .notification(_, s, _), let .revoked(_, s, _): case let .notification(_, s, _), let .revoked(_, s, _):
return s return s
@ -602,7 +674,8 @@ final class GroupChatViewModel {
private func timestampFrom(item: ChatSectionItem) -> TimeInterval { private func timestampFrom(item: ChatSectionItem) -> TimeInterval {
switch item { switch item {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m): let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m),
let .locationSend(m), let .locationReceived(m):
return m.timestamp return m.timestamp
case let .notification(_, _, ts), let .revoked(_, _, ts): case let .notification(_, _, ts), let .revoked(_, _, ts):
return ts return ts
@ -619,6 +692,8 @@ final class GroupChatViewModel {
case var .voiceReceived(m): m.showTime = show; return .voiceReceived(m) case var .voiceReceived(m): m.showTime = show; return .voiceReceived(m)
case var .imageSend(m): m.showTime = show; return .imageSend(m) case var .imageSend(m): m.showTime = show; return .imageSend(m)
case var .imageReceived(m): m.showTime = show; return .imageReceived(m) case var .imageReceived(m): m.showTime = show; return .imageReceived(m)
case var .locationSend(m): m.showTime = show; return .locationSend(m)
case var .locationReceived(m): m.showTime = show; return .locationReceived(m)
case let .notification(text, _, ts): return .notification(text, showTime: show, timestamp: ts) case let .notification(text, _, ts): return .notification(text, showTime: show, timestamp: ts)
case let .revoked(text, _, ts): return .revoked(text, showTime: show, timestamp: ts) case let .revoked(text, _, ts): return .revoked(text, showTime: show, timestamp: ts)
} }
@ -636,6 +711,7 @@ final class GroupChatViewModel {
var atUserIDs: [String] = [] var atUserIDs: [String] = []
var atNicknames: [String] = [] var atNicknames: [String] = []
var isAtAll = false var isAtAll = false
var location: ChatLocationPayload?
let maxWH: CGFloat = 200 let maxWH: CGFloat = 200
var msgImageW: CGFloat = 0 var msgImageW: CGFloat = 0
@ -659,6 +735,11 @@ final class GroupChatViewModel {
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? "" imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
imageW = msgImageW imageW = msgImageW
imageH = msgImageH imageH = msgImageH
} else if type == 109, let elem = msg.locationElem {
location = ChatLocationPayload.decode(description: elem.desc,
latitude: elem.latitude,
longitude: elem.longitude)
content = location?.name ?? ""
} else if type == 106, let at = msg.atTextElem { } else if type == 106, let at = msg.atTextElem {
content = at.text ?? "" content = at.text ?? ""
atUserIDs = at.atUserList ?? [] atUserIDs = at.atUserList ?? []
@ -684,12 +765,15 @@ final class GroupChatViewModel {
} }
let sendID = msg.sendID ?? "" let sendID = msg.sendID ?? ""
let senderName = msg.senderNickname?.isEmpty == false
? msg.senderNickname!
: getUserNickName(id: sendID)
return ChatMessage( return ChatMessage(
id: msg.clientMsgID ?? UUID().uuidString, id: msg.clientMsgID ?? UUID().uuidString,
isSelf: isSelf, isSelf: isSelf,
senderId: sendID, senderId: sendID,
avatar: getUserAvatar(id: sendID), avatar: getUserAvatar(id: sendID),
senderName: msg.senderNickname ?? "", senderName: senderName,
content: content, content: content,
voiceUrl: voiceUrl, voiceUrl: voiceUrl,
imageUrl: imageUrl, imageUrl: imageUrl,
@ -700,7 +784,9 @@ final class GroupChatViewModel {
quotePreview: quotePreview, quotePreview: quotePreview,
atUserIDs: atUserIDs, atUserIDs: atUserIDs,
atNicknames: atNicknames, atNicknames: atNicknames,
isAtAll: isAtAll isAtAll: isAtAll,
isCircleOwner: isCircleOwner(id: sendID),
location: location
) )
} }
@ -719,6 +805,9 @@ final class GroupChatViewModel {
let summary = dur > 0 ? "[语音] \(dur)''" : "[语音]" let summary = dur > 0 ? "[语音] \(dur)''" : "[语音]"
return QuotePreview(senderName: name, kind: .voice, summary: summary, quotedClientMsgID: id) return QuotePreview(senderName: name, kind: .voice, summary: summary, quotedClientMsgID: id)
} }
if type == 109 {
return QuotePreview(senderName: name, kind: .unknown, summary: "[位置]", quotedClientMsgID: id)
}
var text = "" var text = ""
if type == 106 { if type == 106 {
text = msg.atTextElem?.text ?? "" text = msg.atTextElem?.text ?? ""
@ -764,6 +853,11 @@ final class GroupChatViewModel {
return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg) return chatMsg.isSelf ? .imageSend(chatMsg) : .imageReceived(chatMsg)
} }
if msg.contentType.rawValue == 109 {
let chatMsg = convert(msg)
return chatMsg.isSelf ? .locationSend(chatMsg) : .locationReceived(chatMsg)
}
// / @ / // / @ /
let chatMsg = convert(msg) let chatMsg = convert(msg)
if chatMsg.content.isEmpty && chatMsg.quotePreview == nil { return nil } if chatMsg.content.isEmpty && chatMsg.quotePreview == nil { return nil }
@ -815,7 +909,8 @@ extension ChatSectionItem {
var chatMessage: ChatMessage? { var chatMessage: ChatMessage? {
switch self { switch self {
case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m), case let .send(m), let .received(m), let .emojiSend(m), let .emojiReceived(m),
let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m): let .voiceSend(m), let .voiceReceived(m), let .imageSend(m), let .imageReceived(m),
let .locationSend(m), let .locationReceived(m):
return m return m
case .notification, .revoked: case .notification, .revoked:
return nil return nil
@ -826,6 +921,9 @@ extension ChatSectionItem {
var revokedClientMsgID: String? { nil } var revokedClientMsgID: String? { nil }
static func with(_ msg: ChatMessage) -> ChatSectionItem { static func with(_ msg: ChatMessage) -> ChatSectionItem {
if msg.location != nil {
return msg.isSelf ? .locationSend(msg) : .locationReceived(msg)
}
if !msg.imageUrl.isEmpty { if !msg.imageUrl.isEmpty {
return msg.isSelf ? .imageSend(msg) : .imageReceived(msg) return msg.isSelf ? .imageSend(msg) : .imageReceived(msg)
} }

View File

@ -42,11 +42,25 @@ class RemoveMemberVC: BaseViewController {
}) })
.disposed(by: disposeBag) .disposed(by: disposeBag)
rootView.searchField.rx.text.orEmpty
.distinctUntilChanged()
.subscribe(onNext: { [weak self] keyword in
self?.rootView.updateSearchPlaceholder(isHidden: !keyword.isEmpty)
self?.viewModel.updateSearchKeyword(keyword)
})
.disposed(by: disposeBag)
rootView.searchField.rx.controlEvent(.editingDidEndOnExit)
.subscribe(onNext: { [weak self] in
self?.rootView.searchField.resignFirstResponder()
})
.disposed(by: disposeBag)
rootView.tableView.rx.modelSelected(GroupMemberModel.self) rootView.tableView.rx.modelSelected(GroupMemberModel.self)
.subscribe(viewModel.cellAction.inputs) .subscribe(viewModel.cellAction.inputs)
.disposed(by: disposeBag) .disposed(by: disposeBag)
// selectedMembers // selectedMembers
let selectedCount = viewModel.selectedMembers let selectedCount = viewModel.selectedMembers
.map { $0.count } .map { $0.count }
.share(replay: 1) .share(replay: 1)
@ -56,12 +70,9 @@ class RemoveMemberVC: BaseViewController {
.bind(to: rootView.deleteBtn.rx.isEnabled) .bind(to: rootView.deleteBtn.rx.isEnabled)
.disposed(by: disposeBag) .disposed(by: disposeBag)
selectedCount rootView.deleteBtn.rx.tap
.map { "删除(\($0))" } .subscribe(onNext: { [weak self] in
.bind(to: rootView.deleteBtn.rx.title()) guard let self = self else { return }
.disposed(by: disposeBag)
rootView.deleteBtn.rx.tap.subscribe(onNext: { _ in
self.showConfirmPop(showCloseBtn: true, self.showConfirmPop(showCloseBtn: true,
title: "确定要移除成员吗?", title: "确定要移除成员吗?",
message: "成员将从当前圈子中移除", message: "成员将从当前圈子中移除",
@ -69,7 +80,8 @@ class RemoveMemberVC: BaseViewController {
cancelText: "") { cancelText: "") {
self.requestRemoveMember() self.requestRemoveMember()
} }
}).disposed(by: disposeBag) })
.disposed(by: disposeBag)
} }
// MARK: - UITableViewDataSource // MARK: - UITableViewDataSource
@ -79,7 +91,8 @@ class RemoveMemberVC: BaseViewController {
let cell: RemoveMemberCell = tableView.dequeueReusableCell(for: indexPath) let cell: RemoveMemberCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(model, cell.configure(model,
isOwn: self.viewModel.groupId.contains(model.user_id), isOwn: self.viewModel.groupId.contains(model.user_id),
isSelected: self.viewModel.isSelected(id: model.user_id)) isSelected: self.viewModel.isSelected(id: model.user_id),
showsSeparator: indexPath.row < tableView.numberOfRows(inSection: indexPath.section) - 1)
return cell return cell
}) })
}() }()
@ -91,6 +104,7 @@ class RemoveMemberVC: BaseViewController {
DLToast.dismiss() DLToast.dismiss()
guard let model = response.model else { return } guard let model = response.model else { return }
self.viewModel.groupModel = model self.viewModel.groupModel = model
self.rootView.updateMemberCount(response.list.count)
self.viewModel.loadData(response.list) self.viewModel.loadData(response.list)
}.disposed(by: disposeBag) }.disposed(by: disposeBag)
} }

View File

@ -6,94 +6,158 @@
// //
import UIKit import UIKit
import RxSwift
import RxCocoa
class RemoveMemberView: UIView { final class RemoveMemberView: UIView {
var disposeBag = DisposeBag() static let rowHeight: CGFloat = 52
///
static let rowHeight: CGFloat = 85
private func setupRx() {
}
private func setupUI() {
addSubview(navBgView)
addSubview(navView)
addSubview(titleLab)
addSubview(cornerBgView)
addSubview(tableView)
navBgView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
navView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
titleLab.layoutChain
.topToBottomOfView(navView, offset: 8)
.left(15)
cornerBgView.layoutChain
.topToBottomOfView(titleLab, offset: 16)
.edgesHorzontal(15)
tableView.layoutChain
.topToBottomOfView(titleLab, offset: 16)
.edgesHorzontal(15)
}
/// bottom = kSafeBottomMargin + 10
func updateHeight(itemCount: Int) {
let contentH = CGFloat(itemCount) * Self.rowHeight
let maxH = dl.height - kNaviHeight - kSafeBottomMargin - 10 - 16
let targetH = min(contentH, maxH)
tableView.layoutChain.height(targetH)
cornerBgView.layoutChain.height(targetH)
tableView.isScrollEnabled = targetH > maxH
}
private var listHeightConstraint: NSLayoutConstraint? private var listHeightConstraint: NSLayoutConstraint?
lazy var navBgView: UIImageView = { override init(frame: CGRect) {
let iv = UIImageView() super.init(frame: frame)
iv.image = UIImage(named: "Common/navBar_bg_2") backgroundColor = UIColor(hexStr: "#F7F7F7")
iv.contentMode = .scaleAspectFill setupUI()
return iv }
}()
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
addSubview(navView)
addSubview(searchField)
addSubview(titleLab)
addSubview(listContainerView)
listContainerView.addSubview(tableView)
navView.layoutChain
.top()
.edgesHorzontal()
.height(kNaviHeight)
searchField.layoutChain
.topToBottomOfView(navView, offset: 20)
.edgesHorzontal(15)
.height(38)
searchField.addSubview(searchPlaceholderView)
searchPlaceholderView.layoutChain
.center()
.height(20)
searchPlaceholderView.layoutChain.width(52)
titleLab.layoutChain
.topToBottomOfView(searchField, offset: 19)
.left(16)
.height(19)
listContainerView.layoutChain
.topToBottomOfView(titleLab, offset: 6)
.edgesHorzontal(15)
tableView.layoutChain.edges()
listHeightConstraint = listContainerView.jh_setDimension(.height, toSize: 0)
}
func updateMemberCount(_ count: Int) {
let prefix = "圈成员"
let countText = "\(count)人)"
let text = prefix + countText
let attributedText = NSMutableAttributedString(string: text)
attributedText.addAttributes([
.font: UIFont.systemFont(ofSize: 14, weight: .medium),
.foregroundColor: UIColor(hexStr: "#6E6E73")
], range: NSRange(location: 0, length: text.count))
attributedText.addAttributes([
.font: UIFont.systemFont(ofSize: 13, weight: .regular),
.foregroundColor: UIColor(hexStr: "#8E8E93")
], range: NSRange(location: prefix.count, length: countText.count))
titleLab.attributedText = attributedText
}
func updateHeight(itemCount: Int) {
let contentHeight = CGFloat(itemCount) * Self.rowHeight
layoutIfNeeded()
let bottomInset = max(kSafeBottomMargin, safeAreaInsets.bottom)
let maxHeight = max(0, bounds.height - listContainerView.frame.minY - bottomInset)
listHeightConstraint?.constant = min(contentHeight, maxHeight)
tableView.isScrollEnabled = contentHeight >= maxHeight && itemCount > 0
listContainerView.isHidden = itemCount == 0
layoutIfNeeded()
}
func updateSearchPlaceholder(isHidden: Bool) {
searchPlaceholderView.isHidden = isHidden
}
lazy var navView: BaseNavigationView = { lazy var navView: BaseNavigationView = {
let nav = BaseNavigationView(title: "移除圈子成员") let nav = BaseNavigationView(title: "圈子成员")
nav.addRightButton(deleteBtn) nav.addRightButton(deleteBtn)
return nav return nav
}() }()
lazy var deleteBtn: UIButton = { lazy var deleteBtn: UIButton = {
let btn = UIButton(type: .custom) let button = UIButton(type: .custom)
btn.setTitle("删除(0)", for: .normal) button.setTitle("移除", for: .normal)
btn.setTitleColor(UIColor(hexStr: "#FF383C"), for: .normal) button.setTitleColor(UIColor(hexStr: "#FF4D53"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) button.setTitleColor(UIColor(hexStr: "#FF4D53", alpha: 0.35), for: .disabled)
btn.contentHorizontalAlignment = .right button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
return btn button.contentHorizontalAlignment = .right
button.isEnabled = false
return button
}() }()
lazy var titleLab: UILabel = { lazy var searchField: UISearchTextField = {
let label = UILabel() let textField = UISearchTextField()
label.text = "圈子成员" textField.backgroundColor = .white
label.font = .systemFont(ofSize: 16, weight: .semibold) textField.borderStyle = .none
label.textColor = ThemeManager.shared.color.titleAuxColor textField.cornerRadius = 11
return label textField.clipsToBounds = true
textField.font = .systemFont(ofSize: 14)
textField.textColor = UIColor(hexStr: "#293445")
textField.tintColor = UIColor(hexStr: "#293445")
textField.returnKeyType = .search
textField.clearButtonMode = .whileEditing
textField.textAlignment = .left
textField.leftView = UIView(frame: .zero)
textField.leftViewMode = .never
return textField
}() }()
lazy var cornerBgView: UIView = { private lazy var searchPlaceholderView: UIView = {
let view = UIView() let view = UIView()
view.backgroundColor = UIColor(hexStr: "#F5FBFF") view.isUserInteractionEnabled = false
let imageView = UIImageView(image: UIImage(named: "Group/remove_member_search"))
imageView.contentMode = .scaleAspectFit
view.addSubview(imageView)
imageView.layoutChain
.left()
.centerY()
.width(16)
.height(16)
let label = UILabel()
label.text = "搜索"
label.textColor = UIColor(hexStr: "#B0B0B5")
label.font = .systemFont(ofSize: 14)
view.addSubview(label)
label.layoutChain
.leftToRightOfView(imageView, offset: 6)
.right()
.centerY()
return view
}()
private lazy var titleLab = UILabel()
private lazy var listContainerView: UIView = {
let view = UIView()
view.backgroundColor = .white
view.cornerRadius = 10 view.cornerRadius = 10
view.clipsToBounds = true
return view return view
}() }()
@ -101,166 +165,102 @@ class RemoveMemberView: UIView {
let tableView = UITableView(frame: .zero, style: .plain) let tableView = UITableView(frame: .zero, style: .plain)
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.estimatedRowHeight = 85 tableView.rowHeight = Self.rowHeight
tableView.estimatedRowHeight = Self.rowHeight
tableView.isScrollEnabled = false tableView.isScrollEnabled = false
tableView.showsVerticalScrollIndicator = false
tableView.keyboardDismissMode = .onDrag
tableView.register(RemoveMemberCell.self) tableView.register(RemoveMemberCell.self)
return tableView return tableView
}() }()
override init(frame: CGRect) {
super.init(frame: .zero)
backgroundColor = .white
setupUI()
setupRx()
} }
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - RemoveMemberCell // MARK: - RemoveMemberCell
class RemoveMemberCell: UITableViewCell { final class RemoveMemberCell: UITableViewCell {
var disposeBag = DisposeBag() func configure(
_ model: GroupMemberModel,
func configure(_ model: GroupMemberModel, isOwn: Bool, isSelected: Bool) { isOwn: Bool,
avaterImgView.image = model.userIcon isSelected: Bool,
showsSeparator: Bool
) {
avatarImageView.image = model.userIcon
nameLab.text = model.nick_name nameLab.text = model.nick_name
ownView.isHidden = !isOwn selectedBtn.isHidden = false
selectedBtn.isHidden = isOwn selectedBtn.isSelected = isOwn ? false : isSelected
selectedBtn.isSelected = isSelected selectedBtn.alpha = isOwn ? 0.45 : 1
lineView.isHidden = !showsSeparator
} }
override init(style: CellStyle, reuseIdentifier: String?) { override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none selectionStyle = .none
backgroundColor = .clear backgroundColor = .clear
contentView.backgroundColor = .clear
setupSubviews() setupSubviews()
} }
private func setupSubviews() {
contentView.addSubview(bgView)
bgView.addSubview(avaterImgView)
bgView.addSubview(nameLab)
bgView.addSubview(stackView)
bgView.addSubview(lineView)
bgView.layoutChain
.edgesHorzontal()
.edgesVertical()
avaterImgView.layoutChain
.edgesVertical(20)
.left(15)
.height(45)
.widthToHeight(1)
nameLab.layoutChain
.leftToRightOfView(avaterImgView, offset: 10)
.centerY(avaterImgView)
stackView.layoutChain
.right(15)
.centerY()
ownView.layoutChain.width(25).height(35)
selectedBtn.layoutChain.width(20).height(20)
lineView.layoutChain
.height(0.5)
.edgesHorzontal(15)
.bottom()
}
lazy var bgView: UIView = {
let view = UIView()
view.backgroundColor = .clear//UIColor(hexStr: "#F5FBFF")
return view
}()
lazy var avaterImgView: UIImageView = {
let view = UIImageView()
view.backgroundColor = .clear
view.cornerRadius = 22.5
return view
}()
lazy var nameLab: UILabel = {
let label = UILabel()
label.textColor = UIColor(hexStr: "#0F2846")
label.font = .systemFont(ofSize: 14, weight: .medium)
return label
}()
lazy var stackView: UIStackView = {
let view = UIStackView(arrangedSubviews: [ownView, selectedBtn])
view.axis = .horizontal
view.alignment = .trailing
view.spacing = 0
view.backgroundColor = .clear
return view
}()
lazy var ownView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.isHidden = true
let icon = UIImageView(image: UIImage(named: "Common/owner"))
view.addSubview(icon)
icon.layoutChain
.top()
.centerX()
.width(18)
.height(18)
let label = UILabel()
label.text = "圈主"
label.textColor = ThemeManager.shared.color.titleAuxColor
label.font = .systemFont(ofSize: 10, weight: .medium)
view.addSubview(label)
label.layoutChain
.topToBottomOfView(icon)
.centerX()
return view
}()
lazy var selectedBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setImage(UIImage(named: "Common/checkbox_20x20"), for: .normal)
btn.setImage(UIImage(named: "Common/delete_check"), for: .selected)
btn.isUserInteractionEnabled = false
btn.isHidden = true
return btn
}()
lazy var lineView: UIView = {
let view = UIView()
view.backgroundColor = ThemeManager.shared.color.lineColor
return view
}()
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func awakeFromNib() { private func setupSubviews() {
super.awakeFromNib() contentView.addSubview(avatarImageView)
// Initialization code contentView.addSubview(nameLab)
contentView.addSubview(selectedBtn)
contentView.addSubview(lineView)
avatarImageView.layoutChain
.left(16)
.centerY()
.width(38)
.height(38)
nameLab.layoutChain
.leftToRightOfView(avatarImageView, offset: 12)
.centerY()
selectedBtn.layoutChain
.right(14)
.centerY()
.width(24)
.height(24)
lineView.layoutChain
.left(20)
.right(13)
.bottom()
.height(0.5)
} }
override func setSelected(_ selected: Bool, animated: Bool) { private lazy var avatarImageView: UIImageView = {
super.setSelected(selected, animated: animated) let imageView = UIImageView()
imageView.backgroundColor = UIColor(hexStr: "#F2F2F2")
imageView.contentMode = .scaleAspectFill
imageView.cornerRadius = 9
imageView.clipsToBounds = true
return imageView
}()
// Configure the view for the selected state private lazy var nameLab: UILabel = {
} let label = UILabel()
label.textColor = UIColor(hexStr: "#111111")
label.font = .systemFont(ofSize: 15, weight: .regular)
label.lineBreakMode = .byTruncatingTail
return label
}()
override func prepareForReuse() { private lazy var selectedBtn: UIButton = {
super.prepareForReuse() let button = UIButton(type: .custom)
disposeBag = DisposeBag() button.setImage(UIImage(named: "Group/remove_member_unselected"), for: .normal)
} button.setImage(UIImage(named: "Group/remove_member_selected"), for: .selected)
button.isUserInteractionEnabled = false
return button
}()
private lazy var lineView: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexStr: "#EEEEF0")
return view
}()
} }

View File

@ -27,6 +27,7 @@ class RemoveMemberViewModel {
private var disposeBag = DisposeBag() private var disposeBag = DisposeBag()
private let sectionedItems = PublishSubject<[ReviewMemberListSectionModel]>() private let sectionedItems = PublishSubject<[ReviewMemberListSectionModel]>()
private var list: [GroupMemberModel] = [] private var list: [GroupMemberModel] = []
private var searchKeyword = ""
var selectedMembers = BehaviorRelay<[String]>(value: []) var selectedMembers = BehaviorRelay<[String]>(value: [])
@ -42,7 +43,7 @@ class RemoveMemberViewModel {
current.append(memberId) current.append(memberId)
} }
this.selectedMembers.accept(current) this.selectedMembers.accept(current)
self.sectionedItems.onNext(self.list.mapSection()) self.emitItems()
return .empty() return .empty()
} }
}(self) }(self)
@ -53,7 +54,27 @@ class RemoveMemberViewModel {
func loadData(_ list: [GroupMemberModel]) { func loadData(_ list: [GroupMemberModel]) {
self.list = list self.list = list
sectionedItems.onNext(list.mapSection()) selectedMembers.accept(selectedMembers.value.filter { selectedId in
list.contains(where: { $0.user_id == selectedId })
})
emitItems()
}
func updateSearchKeyword(_ keyword: String) {
searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
emitItems()
}
private func emitItems() {
let items: [GroupMemberModel]
if searchKeyword.isEmpty {
items = list
} else {
items = list.filter {
$0.nick_name.localizedCaseInsensitiveContains(searchKeyword)
}
}
sectionedItems.onNext(items.mapSection())
} }
init(groupId: String) { init(groupId: String) {

View File

@ -28,7 +28,7 @@ final class AboutVC: BaseViewController {
rootView.feedbackBtn.rx.controlEvent(.touchUpInside) rootView.feedbackBtn.rx.controlEvent(.touchUpInside)
.subscribe(onNext: { _ in .subscribe(onNext: { _ in
DLToast.show(text: "敬请期待") AppRouter.push(Route.feedback)
}) })
.disposed(by: disposeBag) .disposed(by: disposeBag)

View File

@ -0,0 +1,88 @@
//
// FeedbackSuccessView.swift
// QuickLocation
//
import UIKit
final class FeedbackSuccessView: UIView {
private let cardView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
backgroundColor = UIColor.black.withAlphaComponent(0.66)
let pointerView = UIView()
pointerView.backgroundColor = .white
pointerView.transform = CGAffineTransform(rotationAngle: .pi / 4)
addSubview(pointerView)
pointerView.layoutChain.centerX().centerY(self, offset: 94).width(28).height(28)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 36
addSubview(cardView)
cardView.layoutChain.centerX().centerY(self, offset: -8).width(280).height(208)
let headerImageView = UIImageView(image: UIImage(named: "Mine/feedback_success_header"))
headerImageView.contentMode = .scaleToFill
headerImageView.layer.cornerRadius = 36
headerImageView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
headerImageView.clipsToBounds = true
cardView.addSubview(headerImageView)
headerImageView.layoutChain.top().left().right().height(100)
let iconView = UIImageView(image: UIImage(named: "Mine/feedback_success_icon"))
iconView.contentMode = .scaleAspectFit
cardView.addSubview(iconView)
iconView.layoutChain.top(-60).centerX().width(120).height(120)
let titleLabel = UILabel()
titleLabel.text = "反馈提交成功"
titleLabel.font = .systemFont(ofSize: 24, weight: .semibold)
titleLabel.textColor = UIColor(hexStr: "#1D1D1D")
titleLabel.textAlignment = .center
cardView.addSubview(titleLabel)
titleLabel.layoutChain.top(78).left(16).right(16)
let detailLabel = UILabel()
detailLabel.text = "感谢您的反馈,我们会及时查看并妥\n善处理相关问题"
detailLabel.font = .systemFont(ofSize: 15, weight: .medium)
detailLabel.textColor = UIColor(hexStr: "#767676")
detailLabel.textAlignment = .center
detailLabel.numberOfLines = 2
detailLabel.lineBreakMode = .byWordWrapping
cardView.addSubview(detailLabel)
detailLabel.layoutChain.topToBottomOfView(titleLabel, offset: 22).left(24).right(24)
}
func show(in superview: UIView) {
frame = superview.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight]
alpha = 0
cardView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
superview.addSubview(self)
UIView.animate(withDuration: 0.24, delay: 0, options: .curveEaseOut) {
self.alpha = 1
self.cardView.transform = .identity
}
}
func dismiss(completion: @escaping () -> Void) {
UIView.animate(withDuration: 0.2, animations: {
self.alpha = 0
self.cardView.transform = CGAffineTransform(scaleX: 0.96, y: 0.96)
}, completion: { _ in
self.removeFromSuperview()
completion()
})
}
}

View File

@ -0,0 +1,168 @@
//
// FeedbackVC.swift
// QuickLocation
//
import UIKit
import RxSwift
import RxCocoa
import HXPHPicker
final class FeedbackVC: BaseViewController {
private enum FeedbackCategory: String, CaseIterable {
case feature = "功能问题"
case suggestion = "优化建议"
case other = "其他"
}
private struct FeedbackDraft {
let category: FeedbackCategory
let detail: String
let images: [UIImage]
let contact: String
}
private var rootView: FeedbackView!
private var selectedCategory: FeedbackCategory = .feature
private var selectedImages: [UIImage] = []
private var pendingDraft: FeedbackDraft?
private var isSubmitting = false
override func loadView() {
rootView = FeedbackView(frame: UIScreen.main.bounds)
view = rootView
}
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
private func bind() {
rootView.navView.onBackTapped = { [weak self] in
guard self?.isSubmitting == false else { return }
self?.navigationController?.popViewController(animated: true)
}
rootView.categoryButtons.forEach { button in
button.addTarget(self, action: #selector(categoryTapped(_:)), for: .touchUpInside)
}
rootView.detailTextView.rx.text.orEmpty
.subscribe(onNext: { [weak self] text in
self?.handleDetailChange(text)
})
.disposed(by: disposeBag)
rootView.contactTextField.rx.controlEvent(.editingDidEndOnExit)
.subscribe(onNext: { [weak self] in
self?.rootView.contactTextField.resignFirstResponder()
})
.disposed(by: disposeBag)
rootView.onAddImage = { [weak self] in
self?.showAlbum()
}
rootView.onDeleteImage = { [weak self] index in
guard let self, self.selectedImages.indices.contains(index) else { return }
self.selectedImages.remove(at: index)
self.rootView.updateImages(self.selectedImages)
}
rootView.saveBtn.rx.tap
.subscribe(onNext: { [weak self] in
self?.submit()
})
.disposed(by: disposeBag)
}
@objc private func categoryTapped(_ sender: UIButton) {
let categories = FeedbackCategory.allCases
guard categories.indices.contains(sender.tag) else { return }
let category = categories[sender.tag]
selectedCategory = category
rootView.updateCategory(selectedIndex: sender.tag)
}
private func handleDetailChange(_ text: String) {
let limitedText = text.count > 200 ? String(text.prefix(200)) : text
if limitedText != text {
rootView.detailTextView.text = limitedText
rootView.detailTextView.selectedRange = NSRange(location: limitedText.count, length: 0)
}
rootView.updateDetail(text: limitedText)
}
private func showAlbum() {
let remainingCount = 3 - selectedImages.count
guard remainingCount > 0, !isSubmitting else { return }
let config = PhotoTools.getWXPickerConfig()
config.selectOptions = [.photo]
config.selectMode = .multiple
config.maximumSelectedCount = remainingCount
config.allowSyncICloudWhenSelectPhoto = false
config.previewView.bottomView.editButtonHidden = true
config.photoList.allowAddCamera = false
let pickerController = PhotoPickerController(picker: config)
pickerController.pickerDelegate = self
pickerController.modalPresentationStyle = .fullScreen
present(pickerController, animated: true)
}
private func submit() {
guard !isSubmitting else { return }
view.endEditing(true)
let detail = (rootView.detailTextView.text ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !detail.isEmpty else {
DLToast.show(text: "请补充详细问题或意见")
return
}
pendingDraft = FeedbackDraft(
category: selectedCategory,
detail: detail,
images: selectedImages,
contact: rootView.contactTextField.text ?? ""
)
isSubmitting = true
rootView.setSubmitting(true)
let successView = FeedbackSuccessView()
successView.show(in: view)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak successView] in
guard let self, let successView else { return }
successView.dismiss {
AppRouter.shared.popOrDismiss()
}
}
}
}
extension FeedbackVC: PhotoPickerControllerDelegate {
func pickerController(
_ pickerController: PhotoPickerController,
didFinishSelection result: PickerResult
) {
let remainingCount = max(0, 3 - selectedImages.count)
guard remainingCount > 0 else { return }
result.getImage { _, _, _ in
} completionHandler: { [weak self] images in
DispatchQueue.main.async {
guard let self else { return }
self.selectedImages.append(contentsOf: images.prefix(remainingCount))
self.selectedImages = Array(self.selectedImages.prefix(3))
self.rootView.updateImages(self.selectedImages)
}
}
}
func pickerController(didCancel pickerController: PhotoPickerController) {}
}

View File

@ -0,0 +1,398 @@
//
// FeedbackView.swift
// QuickLocation
//
import UIKit
final class FeedbackView: UIView {
let navView = BaseNavigationView(title: "问题反馈")
let detailTextView = UITextView()
let contactTextField = UITextField()
let saveBtn = UIButton(type: .custom)
private(set) var categoryButtons: [UIButton] = []
var onAddImage: (() -> Void)?
var onDeleteImage: ((Int) -> Void)?
private let scrollView = UIScrollView()
private let contentView = UIView()
private let detailPlaceholderLabel = UILabel()
private let countLabel = UILabel()
private let imagesCollectionView: UICollectionView
private var images: [UIImage] = []
override init(frame: CGRect) {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 8
layout.minimumInteritemSpacing = 8
layout.itemSize = CGSize(width: 100, height: 100)
imagesCollectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
super.init(frame: frame)
backgroundColor = UIColor(hexStr: "#F8F8F8")
setupUI()
updateCategory(selectedIndex: 0)
updateDetail(text: "")
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
addSubview(navView)
navView.layoutChain.top().edgesHorzontal().height(kNaviHeight)
saveBtn.setTitle("保存", for: .normal)
saveBtn.setTitleColor(.white, for: .normal)
saveBtn.titleLabel?.font = FontManager.boboBold(18)
saveBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal)
saveBtn.layer.cornerRadius = 24
saveBtn.clipsToBounds = true
addSubview(saveBtn)
saveBtn.layoutChain
.left(28)
.right(28)
.bottom(kSafeBottomMargin + 22)
.height(56)
scrollView.alwaysBounceVertical = false
scrollView.keyboardDismissMode = .onDrag
scrollView.showsVerticalScrollIndicator = false
addSubview(scrollView)
scrollView.layoutChain
.topToBottomOfView(navView)
.edgesHorzontal()
.bottomToTopOfView(saveBtn, offset: -12)
scrollView.addSubview(contentView)
contentView.layoutChain.edges().widthToView(scrollView)
let typeTitle = makeSectionTitle(required: true, title: "您想反馈的功能类型")
contentView.addSubview(typeTitle)
typeTitle.layoutChain.top(14).left(16).right(16)
let titles = ["功能问题", "优化建议", "其他"]
categoryButtons = titles.enumerated().map { index, title in
let button = UIButton(type: .custom)
button.tag = index
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
button.layer.cornerRadius = 12
button.layer.borderWidth = 1
contentView.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}
let firstButton = categoryButtons[0]
let secondButton = categoryButtons[1]
let thirdButton = categoryButtons[2]
NSLayoutConstraint.activate([
firstButton.topAnchor.constraint(equalTo: typeTitle.bottomAnchor, constant: 12),
firstButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
firstButton.heightAnchor.constraint(equalToConstant: 34),
secondButton.topAnchor.constraint(equalTo: firstButton.topAnchor),
secondButton.leadingAnchor.constraint(equalTo: firstButton.trailingAnchor, constant: 16),
secondButton.widthAnchor.constraint(equalTo: firstButton.widthAnchor),
secondButton.heightAnchor.constraint(equalTo: firstButton.heightAnchor),
thirdButton.topAnchor.constraint(equalTo: firstButton.topAnchor),
thirdButton.leadingAnchor.constraint(equalTo: secondButton.trailingAnchor, constant: 16),
thirdButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
thirdButton.widthAnchor.constraint(equalTo: firstButton.widthAnchor),
thirdButton.heightAnchor.constraint(equalTo: firstButton.heightAnchor)
])
let detailTitle = makeSectionTitle(required: true, title: "请补充详细问题或意见")
contentView.addSubview(detailTitle)
detailTitle.layoutChain
.topToBottomOfView(firstButton, offset: 20)
.left(16)
.right(16)
let detailContainer = UIView()
detailContainer.backgroundColor = .white
detailContainer.layer.cornerRadius = 16
contentView.addSubview(detailContainer)
detailContainer.layoutChain
.topToBottomOfView(detailTitle, offset: 12)
.left(16)
.right(16)
.height(150)
detailTextView.backgroundColor = .clear
detailTextView.font = .systemFont(ofSize: 14, weight: .medium)
detailTextView.textColor = UIColor(hexStr: "#293445")
detailTextView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 28, right: 10)
detailTextView.textContainer.lineFragmentPadding = 0
detailTextView.isScrollEnabled = true
detailTextView.returnKeyType = .default
detailContainer.addSubview(detailTextView)
detailTextView.layoutChain.edges()
detailPlaceholderLabel.text = "请在这里输入内容"
detailPlaceholderLabel.font = .systemFont(ofSize: 14, weight: .medium)
detailPlaceholderLabel.textColor = UIColor(hexStr: "#B5B5B5")
detailPlaceholderLabel.isUserInteractionEnabled = false
detailContainer.addSubview(detailPlaceholderLabel)
detailPlaceholderLabel.layoutChain.top(12).left(14).right(14)
countLabel.font = .systemFont(ofSize: 13, weight: .medium)
countLabel.textColor = UIColor(hexStr: "#767676")
countLabel.textAlignment = .right
detailContainer.addSubview(countLabel)
countLabel.layoutChain.right(14).bottom(10)
let imageTitle = makeSectionTitle(
required: false,
title: "请提供相关问题的图片",
detail: "(选填,最多可上传三张)"
)
contentView.addSubview(imageTitle)
imageTitle.layoutChain
.topToBottomOfView(detailContainer, offset: 18)
.left(16)
.right(16)
imagesCollectionView.backgroundColor = .clear
imagesCollectionView.isScrollEnabled = false
imagesCollectionView.showsHorizontalScrollIndicator = false
imagesCollectionView.dataSource = self
imagesCollectionView.delegate = self
imagesCollectionView.register(
FeedbackImageCell.self,
forCellWithReuseIdentifier: FeedbackImageCell.reuseIdentifier
)
imagesCollectionView.register(
FeedbackAddImageCell.self,
forCellWithReuseIdentifier: FeedbackAddImageCell.reuseIdentifier
)
contentView.addSubview(imagesCollectionView)
imagesCollectionView.layoutChain
.topToBottomOfView(imageTitle, offset: 12)
.left(16)
.right(16)
.height(100)
let contactTitle = makeSectionTitle(
required: false,
title: "联系方式",
detail: "(选填,可以留下您的手机号、微信、邮箱)"
)
contentView.addSubview(contactTitle)
contactTitle.layoutChain
.topToBottomOfView(imagesCollectionView, offset: 18)
.left(16)
.right(16)
contactTextField.backgroundColor = .white
contactTextField.layer.cornerRadius = 20
contactTextField.font = .systemFont(ofSize: 14, weight: .medium)
contactTextField.textColor = UIColor(hexStr: "#293445")
contactTextField.returnKeyType = .done
contactTextField.clearButtonMode = .whileEditing
contactTextField.attributedPlaceholder = NSAttributedString(
string: "请输入您的联系方式",
attributes: [
.foregroundColor: UIColor(hexStr: "#B5B5B5"),
.font: UIFont.systemFont(ofSize: 14, weight: .medium)
]
)
contactTextField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1))
contactTextField.leftViewMode = .always
contactTextField.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1))
contactTextField.rightViewMode = .unlessEditing
contentView.addSubview(contactTextField)
contactTextField.layoutChain
.topToBottomOfView(contactTitle, offset: 12)
.left(16)
.right(16)
.height(40)
.bottom(24)
}
private func makeSectionTitle(required: Bool, title: String, detail: String? = nil) -> UILabel {
let label = UILabel()
label.numberOfLines = 0
let text = NSMutableAttributedString()
if required {
text.append(NSAttributedString(
string: "*",
attributes: [
.font: UIFont.systemFont(ofSize: 17, weight: .medium),
.foregroundColor: UIColor(hexStr: "#F64545")
]
))
}
text.append(NSAttributedString(
string: title,
attributes: [
.font: UIFont.systemFont(ofSize: 16, weight: .medium),
.foregroundColor: UIColor(hexStr: "#293445")
]
))
if let detail {
text.append(NSAttributedString(
string: detail,
attributes: [
.font: UIFont.systemFont(ofSize: 12, weight: .medium),
.foregroundColor: UIColor(hexStr: "#B5B5B5")
]
))
}
label.attributedText = text
return label
}
func updateCategory(selectedIndex: Int) {
for (index, button) in categoryButtons.enumerated() {
let isSelected = index == selectedIndex
button.backgroundColor = isSelected ? UIColor(hexStr: "#08B2F5") : .clear
button.layer.borderColor = UIColor(
hexStr: isSelected ? "#08B2F5" : "#D6D6D6"
).cgColor
button.setTitleColor(
isSelected ? .white : UIColor(hexStr: "#293445"),
for: .normal
)
}
}
func updateDetail(text: String) {
detailPlaceholderLabel.isHidden = !text.isEmpty
countLabel.text = "\(text.count)/200"
}
func updateImages(_ images: [UIImage]) {
self.images = Array(images.prefix(3))
imagesCollectionView.reloadData()
}
func setSubmitting(_ submitting: Bool) {
saveBtn.isEnabled = !submitting
saveBtn.alpha = submitting ? 0.65 : 1
}
}
extension FeedbackView: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let width = min(100, floor((collectionView.bounds.width - 16) / 3))
return CGSize(width: width, height: 100)
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
images.count + (images.count < 3 ? 1 : 0)
}
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
if indexPath.item < images.count {
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: FeedbackImageCell.reuseIdentifier,
for: indexPath
) as? FeedbackImageCell else {
return UICollectionViewCell()
}
cell.configure(image: images[indexPath.item])
cell.onDelete = { [weak self, weak cell] in
guard let self, let cell,
let currentIndexPath = collectionView.indexPath(for: cell) else { return }
self.onDeleteImage?(currentIndexPath.item)
}
return cell
}
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: FeedbackAddImageCell.reuseIdentifier,
for: indexPath
) as? FeedbackAddImageCell else {
return UICollectionViewCell()
}
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard indexPath.item == images.count, images.count < 3 else { return }
onAddImage?()
}
}
private final class FeedbackImageCell: UICollectionViewCell {
static let reuseIdentifier = "FeedbackImageCell"
var onDelete: (() -> Void)?
private let imageView = UIImageView()
private let deleteButton = UIButton(type: .custom)
override init(frame: CGRect) {
super.init(frame: frame)
imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = 16
imageView.clipsToBounds = true
contentView.addSubview(imageView)
imageView.layoutChain.edges()
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 10, weight: .bold)
deleteButton.setImage(UIImage(systemName: "xmark", withConfiguration: symbolConfig), for: .normal)
deleteButton.tintColor = UIColor(hexStr: "#293445")
deleteButton.backgroundColor = UIColor.white.withAlphaComponent(0.92)
deleteButton.layer.cornerRadius = 11
deleteButton.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor
deleteButton.layer.shadowOpacity = 1
deleteButton.layer.shadowRadius = 3
deleteButton.layer.shadowOffset = CGSize(width: 0, height: 1)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
contentView.addSubview(deleteButton)
deleteButton.layoutChain.top(6).right(6).width(22).height(22)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
onDelete = nil
}
func configure(image: UIImage) {
imageView.image = image
}
@objc private func deleteTapped() {
onDelete?()
}
}
private final class FeedbackAddImageCell: UICollectionViewCell {
static let reuseIdentifier = "FeedbackAddImageCell"
private let addImageView = UIImageView(image: UIImage(named: "Mine/feedback_add"))
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 16
addImageView.contentMode = .scaleAspectFit
contentView.addSubview(addImageView)
addImageView.layoutChain.centerX().centerY().width(48).height(48)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}