|
|
@ -321,6 +321,9 @@
|
|||
55B218A13024A00100784708 /* CancelAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784707 /* CancelAccountView.swift */; };
|
||||
55B218A13024A0010078470A /* MemberInfoVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218A13024A00100784709 /* MemberInfoVC.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 */; };
|
||||
55B218B13024B00100784724 /* FeatureIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B218B13024B00100784723 /* FeatureIntroView.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -1285,6 +1291,7 @@
|
|||
30EFF3CB2FDA667A00EB35D4 /* MyProfile */,
|
||||
30EFF3BC2FD9585200EB35D4 /* Account */,
|
||||
55B218A13024A00100784710 /* About */,
|
||||
55B220063031000100784806 /* Feedback */,
|
||||
55B218A13024A00100784711 /* CancelAccount */,
|
||||
30EFF3D42FDA8EF200EB35D4 /* EmergencyContact */,
|
||||
30EFF3E32FDAA92200EB35D4 /* PrivacyPolicy */,
|
||||
|
|
@ -1973,6 +1980,16 @@
|
|||
path = MemberInfo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
55B220063031000100784806 /* Feedback */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
55B220003031000100784800 /* FeedbackVC.swift */,
|
||||
55B220023031000100784802 /* FeedbackView.swift */,
|
||||
55B220043031000100784804 /* FeedbackSuccessView.swift */,
|
||||
);
|
||||
path = Feedback;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
55B218B13024B00100784720 /* Explore */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -2390,6 +2407,9 @@
|
|||
55B218A13024A00100784708 /* CancelAccountView.swift in Sources */,
|
||||
55B218A13024A0010078470A /* MemberInfoVC.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 */,
|
||||
30DC185A2FD11E7A0041DCD1 /* WebOperations.swift in Sources */,
|
||||
30A87A642FEE75520095E7C6 /* CreateBubbleTipsView.swift in Sources */,
|
||||
|
|
|
|||
22
QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/remove_member_search@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 673 B |
BIN
QuickLocation/Assets.xcassets/Group/remove_member_search.imageset/remove_member_search@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
22
QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/remove_member_selected@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 574 B |
BIN
QuickLocation/Assets.xcassets/Group/remove_member_selected.imageset/remove_member_selected@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
22
QuickLocation/Assets.xcassets/Group/remove_member_unselected.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 651 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 753 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 751 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 663 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -5,12 +5,12 @@
|
|||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Icons_Photo@2x.png",
|
||||
"filename" : "keyboard@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Icons_Photo@3x.png",
|
||||
"filename" : "keyboard@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 737 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1010 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/IM/location_recenter.imageset/location_recenter@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 886 B |
BIN
QuickLocation/Assets.xcassets/IM/location_recenter.imageset/location_recenter@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 963 B After Width: | Height: | Size: 591 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 563 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 498 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 581 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -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 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 212 B |
|
After Width: | Height: | Size: 351 B |
8
QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/Contents.json
vendored
Normal 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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/header@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
QuickLocation/Assets.xcassets/Mine/feedback_success_header.imageset/header@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 106 KiB |
8
QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/Contents.json
vendored
Normal 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 }
|
||||
}
|
||||
BIN
QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
QuickLocation/Assets.xcassets/Mine/feedback_success_icon.imageset/icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -81,6 +81,8 @@ enum Route: String {
|
|||
case phoneReportDetail = "phoneReportDetail"
|
||||
/// 关于我们
|
||||
case about = "about"
|
||||
/// 意见反馈
|
||||
case feedback = "feedback"
|
||||
/// 成员信息
|
||||
case memberInfo = "memberInfo"
|
||||
/// 注销账号
|
||||
|
|
@ -384,6 +386,11 @@ extension AppRouter: AppRouterProtocol {
|
|||
AboutVC()
|
||||
}
|
||||
|
||||
// MARK: - 意见反馈
|
||||
AppRouter.register(Route.feedback) { _, _ in
|
||||
FeedbackVC()
|
||||
}
|
||||
|
||||
// MARK: - 成员信息
|
||||
AppRouter.register(Route.memberInfo) { _, parameters in
|
||||
MemberInfoVC(userInfo: parameters)
|
||||
|
|
@ -430,4 +437,3 @@ extension AppRouter: AppRouterProtocol {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import CoreLocation
|
||||
import RxSwift
|
||||
import RxCocoa
|
||||
import OpenIMSDK
|
||||
|
|
@ -22,6 +23,40 @@ struct QuotePreview {
|
|||
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 {
|
||||
case send(ChatMessage)
|
||||
case received(ChatMessage)
|
||||
|
|
@ -31,6 +66,8 @@ enum ChatSectionItem {
|
|||
case voiceReceived(ChatMessage)
|
||||
case imageSend(ChatMessage)
|
||||
case imageReceived(ChatMessage)
|
||||
case locationSend(ChatMessage)
|
||||
case locationReceived(ChatMessage)
|
||||
case notification(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 let timeGapThreshold: TimeInterval = 300
|
||||
|
||||
var groupModel: GroupInfoModel?
|
||||
var groupModel: GroupInfoModel? {
|
||||
didSet { refreshMessageMetadata() }
|
||||
}
|
||||
var memberList: [GroupMemberModel] = [] {
|
||||
didSet { buildAvatarCache() }
|
||||
}
|
||||
|
|
@ -110,16 +149,23 @@ final class GroupChatViewModel {
|
|||
cache[member.user_id] = member.userIcon
|
||||
}
|
||||
avatarCache = cache
|
||||
refreshMessageAvatars()
|
||||
refreshMessageMetadata()
|
||||
}
|
||||
|
||||
private func refreshMessageAvatars() {
|
||||
private func refreshMessageMetadata() {
|
||||
var items = messagesSubject.value
|
||||
var didChange = false
|
||||
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
|
||||
return ChatSectionItem.with(m)
|
||||
return ChatSectionItem.with(message)
|
||||
}
|
||||
if didChange { messagesSubject.accept(items) }
|
||||
}
|
||||
|
|
@ -144,6 +190,11 @@ final class GroupChatViewModel {
|
|||
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
|
||||
func setPendingQuote(_ msg: OIMMessageInfo?) {
|
||||
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
|
||||
func revokeMessage(clientMsgID: String) {
|
||||
OIMManager.manager.revokeMessage(conversationID,
|
||||
|
|
@ -276,9 +347,10 @@ final class GroupChatViewModel {
|
|||
}
|
||||
let ts = timestampFrom(item: items[idx])
|
||||
let showTime: Bool = {
|
||||
switch items[idx] {
|
||||
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):
|
||||
switch items[idx] {
|
||||
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 .locationSend(m), let .locationReceived(m):
|
||||
return m.showTime
|
||||
case let .notification(_, s, _), let .revoked(_, s, _):
|
||||
return s
|
||||
|
|
@ -602,7 +674,8 @@ final class GroupChatViewModel {
|
|||
private func timestampFrom(item: ChatSectionItem) -> TimeInterval {
|
||||
switch item {
|
||||
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
|
||||
case let .notification(_, _, ts), let .revoked(_, _, ts):
|
||||
return ts
|
||||
|
|
@ -619,6 +692,8 @@ final class GroupChatViewModel {
|
|||
case var .voiceReceived(m): m.showTime = show; return .voiceReceived(m)
|
||||
case var .imageSend(m): m.showTime = show; return .imageSend(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 .revoked(text, _, ts): return .revoked(text, showTime: show, timestamp: ts)
|
||||
}
|
||||
|
|
@ -636,6 +711,7 @@ final class GroupChatViewModel {
|
|||
var atUserIDs: [String] = []
|
||||
var atNicknames: [String] = []
|
||||
var isAtAll = false
|
||||
var location: ChatLocationPayload?
|
||||
|
||||
let maxWH: CGFloat = 200
|
||||
var msgImageW: CGFloat = 0
|
||||
|
|
@ -659,6 +735,11 @@ final class GroupChatViewModel {
|
|||
imageUrl = pic.sourcePicture?.url ?? pic.bigPicture?.url ?? pic.sourcePath ?? ""
|
||||
imageW = msgImageW
|
||||
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 {
|
||||
content = at.text ?? ""
|
||||
atUserIDs = at.atUserList ?? []
|
||||
|
|
@ -684,12 +765,15 @@ final class GroupChatViewModel {
|
|||
}
|
||||
|
||||
let sendID = msg.sendID ?? ""
|
||||
let senderName = msg.senderNickname?.isEmpty == false
|
||||
? msg.senderNickname!
|
||||
: getUserNickName(id: sendID)
|
||||
return ChatMessage(
|
||||
id: msg.clientMsgID ?? UUID().uuidString,
|
||||
isSelf: isSelf,
|
||||
senderId: sendID,
|
||||
avatar: getUserAvatar(id: sendID),
|
||||
senderName: msg.senderNickname ?? "",
|
||||
senderName: senderName,
|
||||
content: content,
|
||||
voiceUrl: voiceUrl,
|
||||
imageUrl: imageUrl,
|
||||
|
|
@ -700,7 +784,9 @@ final class GroupChatViewModel {
|
|||
quotePreview: quotePreview,
|
||||
atUserIDs: atUserIDs,
|
||||
atNicknames: atNicknames,
|
||||
isAtAll: isAtAll
|
||||
isAtAll: isAtAll,
|
||||
isCircleOwner: isCircleOwner(id: sendID),
|
||||
location: location
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -719,6 +805,9 @@ final class GroupChatViewModel {
|
|||
let summary = dur > 0 ? "[语音] \(dur)''" : "[语音]"
|
||||
return QuotePreview(senderName: name, kind: .voice, summary: summary, quotedClientMsgID: id)
|
||||
}
|
||||
if type == 109 {
|
||||
return QuotePreview(senderName: name, kind: .unknown, summary: "[位置]", quotedClientMsgID: id)
|
||||
}
|
||||
var text = ""
|
||||
if type == 106 {
|
||||
text = msg.atTextElem?.text ?? ""
|
||||
|
|
@ -764,6 +853,11 @@ final class GroupChatViewModel {
|
|||
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)
|
||||
if chatMsg.content.isEmpty && chatMsg.quotePreview == nil { return nil }
|
||||
|
|
@ -815,7 +909,8 @@ extension ChatSectionItem {
|
|||
var chatMessage: ChatMessage? {
|
||||
switch self {
|
||||
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
|
||||
case .notification, .revoked:
|
||||
return nil
|
||||
|
|
@ -826,6 +921,9 @@ extension ChatSectionItem {
|
|||
var revokedClientMsgID: String? { nil }
|
||||
|
||||
static func with(_ msg: ChatMessage) -> ChatSectionItem {
|
||||
if msg.location != nil {
|
||||
return msg.isSelf ? .locationSend(msg) : .locationReceived(msg)
|
||||
}
|
||||
if !msg.imageUrl.isEmpty {
|
||||
return msg.isSelf ? .imageSend(msg) : .imageReceived(msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,11 +42,25 @@ class RemoveMemberVC: BaseViewController {
|
|||
})
|
||||
.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)
|
||||
.subscribe(viewModel.cellAction.inputs)
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
// selectedMembers 驱动删除按钮状态和文字
|
||||
// selectedMembers 驱动移除按钮状态
|
||||
let selectedCount = viewModel.selectedMembers
|
||||
.map { $0.count }
|
||||
.share(replay: 1)
|
||||
|
|
@ -56,20 +70,18 @@ class RemoveMemberVC: BaseViewController {
|
|||
.bind(to: rootView.deleteBtn.rx.isEnabled)
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
selectedCount
|
||||
.map { "删除(\($0))" }
|
||||
.bind(to: rootView.deleteBtn.rx.title())
|
||||
rootView.deleteBtn.rx.tap
|
||||
.subscribe(onNext: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.showConfirmPop(showCloseBtn: true,
|
||||
title: "确定要移除成员吗?",
|
||||
message: "成员将从当前圈子中移除",
|
||||
confirmText: "否",
|
||||
cancelText: "是") {
|
||||
self.requestRemoveMember()
|
||||
}
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
rootView.deleteBtn.rx.tap.subscribe(onNext: { _ in
|
||||
self.showConfirmPop(showCloseBtn: true,
|
||||
title: "确定要移除成员吗?",
|
||||
message: "成员将从当前圈子中移除",
|
||||
confirmText: "否",
|
||||
cancelText: "是") {
|
||||
self.requestRemoveMember()
|
||||
}
|
||||
}).disposed(by: disposeBag)
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
|
@ -79,7 +91,8 @@ class RemoveMemberVC: BaseViewController {
|
|||
let cell: RemoveMemberCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.configure(model,
|
||||
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
|
||||
})
|
||||
}()
|
||||
|
|
@ -91,6 +104,7 @@ class RemoveMemberVC: BaseViewController {
|
|||
DLToast.dismiss()
|
||||
guard let model = response.model else { return }
|
||||
self.viewModel.groupModel = model
|
||||
self.rootView.updateMemberCount(response.list.count)
|
||||
self.viewModel.loadData(response.list)
|
||||
}.disposed(by: disposeBag)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,94 +6,158 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import RxSwift
|
||||
import RxCocoa
|
||||
|
||||
class RemoveMemberView: UIView {
|
||||
final class RemoveMemberView: UIView {
|
||||
|
||||
var disposeBag = DisposeBag()
|
||||
/// 单行高度
|
||||
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
|
||||
}
|
||||
static let rowHeight: CGFloat = 52
|
||||
|
||||
private var listHeightConstraint: NSLayoutConstraint?
|
||||
|
||||
lazy var navBgView: UIImageView = {
|
||||
let iv = UIImageView()
|
||||
iv.image = UIImage(named: "Common/navBar_bg_2")
|
||||
iv.contentMode = .scaleAspectFill
|
||||
return iv
|
||||
}()
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor(hexStr: "#F7F7F7")
|
||||
setupUI()
|
||||
}
|
||||
|
||||
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 = {
|
||||
let nav = BaseNavigationView(title: "移除圈子成员")
|
||||
let nav = BaseNavigationView(title: "圈子成员")
|
||||
nav.addRightButton(deleteBtn)
|
||||
return nav
|
||||
}()
|
||||
|
||||
lazy var deleteBtn: UIButton = {
|
||||
let btn = UIButton(type: .custom)
|
||||
btn.setTitle("删除(0)", for: .normal)
|
||||
btn.setTitleColor(UIColor(hexStr: "#FF383C"), for: .normal)
|
||||
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
btn.contentHorizontalAlignment = .right
|
||||
return btn
|
||||
let button = UIButton(type: .custom)
|
||||
button.setTitle("移除", for: .normal)
|
||||
button.setTitleColor(UIColor(hexStr: "#FF4D53"), for: .normal)
|
||||
button.setTitleColor(UIColor(hexStr: "#FF4D53", alpha: 0.35), for: .disabled)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
button.contentHorizontalAlignment = .right
|
||||
button.isEnabled = false
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var titleLab: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = "圈子成员"
|
||||
label.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
label.textColor = ThemeManager.shared.color.titleAuxColor
|
||||
return label
|
||||
lazy var searchField: UISearchTextField = {
|
||||
let textField = UISearchTextField()
|
||||
textField.backgroundColor = .white
|
||||
textField.borderStyle = .none
|
||||
textField.cornerRadius = 11
|
||||
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()
|
||||
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.clipsToBounds = true
|
||||
return view
|
||||
}()
|
||||
|
||||
|
|
@ -101,166 +165,102 @@ class RemoveMemberView: UIView {
|
|||
let tableView = UITableView(frame: .zero, style: .plain)
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.estimatedRowHeight = 85
|
||||
tableView.rowHeight = Self.rowHeight
|
||||
tableView.estimatedRowHeight = Self.rowHeight
|
||||
tableView.isScrollEnabled = false
|
||||
tableView.showsVerticalScrollIndicator = false
|
||||
tableView.keyboardDismissMode = .onDrag
|
||||
tableView.register(RemoveMemberCell.self)
|
||||
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
|
||||
class RemoveMemberCell: UITableViewCell {
|
||||
final class RemoveMemberCell: UITableViewCell {
|
||||
|
||||
var disposeBag = DisposeBag()
|
||||
|
||||
func configure(_ model: GroupMemberModel, isOwn: Bool, isSelected: Bool) {
|
||||
avaterImgView.image = model.userIcon
|
||||
func configure(
|
||||
_ model: GroupMemberModel,
|
||||
isOwn: Bool,
|
||||
isSelected: Bool,
|
||||
showsSeparator: Bool
|
||||
) {
|
||||
avatarImageView.image = model.userIcon
|
||||
nameLab.text = model.nick_name
|
||||
ownView.isHidden = !isOwn
|
||||
selectedBtn.isHidden = isOwn
|
||||
selectedBtn.isSelected = isSelected
|
||||
selectedBtn.isHidden = false
|
||||
selectedBtn.isSelected = isOwn ? false : isSelected
|
||||
selectedBtn.alpha = isOwn ? 0.45 : 1
|
||||
lineView.isHidden = !showsSeparator
|
||||
}
|
||||
|
||||
override init(style: CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
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) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
// Initialization code
|
||||
private func setupSubviews() {
|
||||
contentView.addSubview(avatarImageView)
|
||||
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) {
|
||||
super.setSelected(selected, animated: animated)
|
||||
private lazy var avatarImageView: UIImageView = {
|
||||
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() {
|
||||
super.prepareForReuse()
|
||||
disposeBag = DisposeBag()
|
||||
}
|
||||
private lazy var selectedBtn: UIButton = {
|
||||
let button = UIButton(type: .custom)
|
||||
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
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class RemoveMemberViewModel {
|
|||
private var disposeBag = DisposeBag()
|
||||
private let sectionedItems = PublishSubject<[ReviewMemberListSectionModel]>()
|
||||
private var list: [GroupMemberModel] = []
|
||||
private var searchKeyword = ""
|
||||
|
||||
var selectedMembers = BehaviorRelay<[String]>(value: [])
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ class RemoveMemberViewModel {
|
|||
current.append(memberId)
|
||||
}
|
||||
this.selectedMembers.accept(current)
|
||||
self.sectionedItems.onNext(self.list.mapSection())
|
||||
self.emitItems()
|
||||
return .empty()
|
||||
}
|
||||
}(self)
|
||||
|
|
@ -53,7 +54,27 @@ class RemoveMemberViewModel {
|
|||
|
||||
func loadData(_ list: [GroupMemberModel]) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ final class AboutVC: BaseViewController {
|
|||
|
||||
rootView.feedbackBtn.rx.controlEvent(.touchUpInside)
|
||||
.subscribe(onNext: { _ in
|
||||
DLToast.show(text: "敬请期待")
|
||||
AppRouter.push(Route.feedback)
|
||||
})
|
||||
.disposed(by: disposeBag)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||