diff --git a/QuickLocation.xcodeproj/project.pbxproj b/QuickLocation.xcodeproj/project.pbxproj index 97d4b20..f8608c4 100644 --- a/QuickLocation.xcodeproj/project.pbxproj +++ b/QuickLocation.xcodeproj/project.pbxproj @@ -191,6 +191,11 @@ 30DC18602FD12A020041DCD1 /* VipWaivePopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DC185F2FD12A020041DCD1 /* VipWaivePopView.swift */; }; 30EFF2992FD65FB000EB35D4 /* VoicePlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF2982FD65FB000EB35D4 /* VoicePlayerManager.swift */; }; 30EFF29B2FD668C900EB35D4 /* VoiceRecordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF29A2FD668C900EB35D4 /* VoiceRecordView.swift */; }; + 30EFF3A42FD7C5A300EB35D4 /* GroupSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3A32FD7C5A300EB35D4 /* GroupSettingView.swift */; }; + 30EFF3A62FD7C5AF00EB35D4 /* GroupSettingVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3A52FD7C5AF00EB35D4 /* GroupSettingVC.swift */; }; + 30EFF3A82FD7C6A400EB35D4 /* GroupSettingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3A72FD7C6A400EB35D4 /* GroupSettingViewModel.swift */; }; + 30EFF3AE2FD7FF1400EB35D4 /* TextInputViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3AC2FD7FF1400EB35D4 /* TextInputViewController.swift */; }; + 30EFF3B02FD8122E00EB35D4 /* GroupTagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EFF3AF2FD8122E00EB35D4 /* GroupTagListView.swift */; }; C49B37352A45A02C28FF41BA /* Pods_QuickLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1C77B42994F352054070537 /* Pods_QuickLocation.framework */; }; /* End PBXBuildFile section */ @@ -387,6 +392,13 @@ 30DC185F2FD12A020041DCD1 /* VipWaivePopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VipWaivePopView.swift; sourceTree = ""; }; 30EFF2982FD65FB000EB35D4 /* VoicePlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoicePlayerManager.swift; sourceTree = ""; }; 30EFF29A2FD668C900EB35D4 /* VoiceRecordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceRecordView.swift; sourceTree = ""; }; + 30EFF3A02FD7A47900EB35D4 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = "zh-Hans"; path = "zh-Hans.lproj/LaunchScreen.storyboard"; sourceTree = ""; }; + 30EFF3A12FD7A47900EB35D4 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = "zh-Hans"; path = "zh-Hans.lproj/Main.storyboard"; sourceTree = ""; }; + 30EFF3A32FD7C5A300EB35D4 /* GroupSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupSettingView.swift; sourceTree = ""; }; + 30EFF3A52FD7C5AF00EB35D4 /* GroupSettingVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupSettingVC.swift; sourceTree = ""; }; + 30EFF3A72FD7C6A400EB35D4 /* GroupSettingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupSettingViewModel.swift; sourceTree = ""; }; + 30EFF3AC2FD7FF1400EB35D4 /* TextInputViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputViewController.swift; sourceTree = ""; }; + 30EFF3AF2FD8122E00EB35D4 /* GroupTagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTagListView.swift; sourceTree = ""; }; 3E4359082FC48D26003470A5 /* QuickLocation.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickLocation.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93647DF3683AA5E71EC2FB1A /* Pods-QuickLocation.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuickLocation.release.xcconfig"; path = "Target Support Files/Pods-QuickLocation/Pods-QuickLocation.release.xcconfig"; sourceTree = ""; }; D1C77B42994F352054070537 /* Pods_QuickLocation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_QuickLocation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -396,8 +408,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 3070777D2FD2A214004C37CC /* lotties */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = lotties; sourceTree = ""; }; @@ -759,6 +769,7 @@ 305A76252FCA8C7000227D26 /* GroupViewModel.swift */, 305A76232FCA8C7000227D26 /* GroupView.swift */, 307073E42FD18A20004C37CC /* GroupChat */, + 30EFF3A22FD7C58400EB35D4 /* GroupSetting */, 3062E8B82FCEAC5600CEF511 /* CreateGroup */, 30BAB8612FCD714700C33B5C /* Join */, 30BAB84B2FCD2FA400C33B5C /* InviteJoin */, @@ -830,6 +841,7 @@ 3062E8C52FCFD01000CEF511 /* VipRecharge */, 3062E8B32FCE6BA400CEF511 /* Scan */, 30DC18592FD11E7A0041DCD1 /* Web */, + 30EFF3AD2FD7FF1400EB35D4 /* TextInput */, ); path = Section; sourceTree = ""; @@ -1079,6 +1091,25 @@ path = Web; sourceTree = ""; }; + 30EFF3A22FD7C58400EB35D4 /* GroupSetting */ = { + isa = PBXGroup; + children = ( + 30EFF3A52FD7C5AF00EB35D4 /* GroupSettingVC.swift */, + 30EFF3A32FD7C5A300EB35D4 /* GroupSettingView.swift */, + 30EFF3A72FD7C6A400EB35D4 /* GroupSettingViewModel.swift */, + 30EFF3AF2FD8122E00EB35D4 /* GroupTagListView.swift */, + ); + path = GroupSetting; + sourceTree = ""; + }; + 30EFF3AD2FD7FF1400EB35D4 /* TextInput */ = { + isa = PBXGroup; + children = ( + 30EFF3AC2FD7FF1400EB35D4 /* TextInputViewController.swift */, + ); + path = TextInput; + sourceTree = ""; + }; 3E4358FF2FC48D26003470A5 = { isa = PBXGroup; children = ( @@ -1148,11 +1179,11 @@ }; }; buildConfigurationList = 3E4359032FC48D26003470A5 /* Build configuration list for PBXProject "QuickLocation" */; - developmentRegion = en; + developmentRegion = "zh-Hans"; hasScannedForEncodings = 0; knownRegions = ( - en, Base, + "zh-Hans", ); mainGroup = 3E4358FF2FC48D26003470A5; minimizedProjectReferenceProxies = 1; @@ -1216,10 +1247,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-frameworks.sh\"\n"; @@ -1233,10 +1268,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-QuickLocation/Pods-QuickLocation-resources.sh\"\n"; @@ -1253,6 +1292,7 @@ 305A76892FCA8C7000227D26 /* Observable+Response.swift in Sources */, 305A768A2FCA8C7000227D26 /* Single+Response.swift in Sources */, 305A768B2FCA8C7000227D26 /* API.swift in Sources */, + 30EFF3AE2FD7FF1400EB35D4 /* TextInputViewController.swift in Sources */, 305A768C2FCA8C7000227D26 /* APIProvider.swift in Sources */, 3062E8C92FCFD03B00CEF511 /* VipRechargeVC.swift in Sources */, 305A768D2FCA8C7000227D26 /* AppNetworkConfig.swift in Sources */, @@ -1279,6 +1319,7 @@ 305A769F2FCA8C7000227D26 /* TextContentArrowCell.swift in Sources */, 305A76A02FCA8C7000227D26 /* TextTableViewCell.swift in Sources */, 305A76A12FCA8C7000227D26 /* UIButton+RTL.m in Sources */, + 30EFF3A62FD7C5AF00EB35D4 /* GroupSettingVC.swift in Sources */, 305A76A22FCA8C7000227D26 /* Array+Extension.swift in Sources */, 305A76A32FCA8C7000227D26 /* ControlEvents+Block.swift in Sources */, 3062E8B72FCE6BFE00CEF511 /* ScanView.swift in Sources */, @@ -1303,6 +1344,7 @@ 305A76B42FCA8C7000227D26 /* UINavigationController+FDFullscreenPopGesture.m in Sources */, 305A76B52FCA8C7000227D26 /* UITableView+Extension.swift in Sources */, 305A76B62FCA8C7000227D26 /* UITextField+Extensions.swift in Sources */, + 30EFF3A82FD7C6A400EB35D4 /* GroupSettingViewModel.swift in Sources */, 305A76B72FCA8C7000227D26 /* UIView+Extension.swift in Sources */, 305A76B82FCA8C7000227D26 /* UIViewController+Extension.swift in Sources */, 305A76B92FCA8C7000227D26 /* URL+Extension.swift in Sources */, @@ -1343,6 +1385,7 @@ 305A76D62FCA8C7000227D26 /* ImagePlugin.swift in Sources */, 305A76D72FCA8C7000227D26 /* NotEmpty.swift in Sources */, 305A76D82FCA8C7000227D26 /* Action.swift in Sources */, + 30EFF3B02FD8122E00EB35D4 /* GroupTagListView.swift in Sources */, 305A76D92FCA8C7000227D26 /* Action+Internal.swift in Sources */, 305A76DA2FCA8C7000227D26 /* Button+Action.swift in Sources */, 305A76DB2FCA8C7000227D26 /* Control+Action.swift in Sources */, @@ -1384,6 +1427,7 @@ 305A76F32FCA8C7000227D26 /* AutoLayout+NSLayoutConstraint.swift in Sources */, 305A76F42FCA8C7000227D26 /* AutoLayout+UIView.swift in Sources */, 305A76F52FCA8C7000227D26 /* AutoLayoutSwift.swift in Sources */, + 30EFF3A42FD7C5A300EB35D4 /* GroupSettingView.swift in Sources */, 305A76F62FCA8C7000227D26 /* AppRouter.swift in Sources */, 305A76F72FCA8C7000227D26 /* RouterTarget.swift in Sources */, 307073E52FD18A20004C37CC /* GroupChatView.swift in Sources */, @@ -1434,6 +1478,7 @@ isa = PBXVariantGroup; children = ( 305A76812FCA8C7000227D26 /* Base */, + 30EFF3A02FD7A47900EB35D4 /* zh-Hans */, ); name = LaunchScreen.storyboard; sourceTree = ""; @@ -1442,6 +1487,7 @@ isa = PBXVariantGroup; children = ( 305A76832FCA8C7000227D26 /* Base */, + 30EFF3A12FD7A47900EB35D4 /* zh-Hans */, ); name = Main.storyboard; sourceTree = ""; @@ -1463,6 +1509,12 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = QuickLocation/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "极速定位"; + INFOPLIST_KEY_NSCameraUsageDescription = "您的相机将被用于扫描二维码、拍摄照片和视频。"; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "需要获取您的位置信息以在地图上显示您的位置"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息以在地图上显示您的位置"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风进行音频录制。"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = Main; @@ -1505,6 +1557,12 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = QuickLocation/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "极速定位"; + INFOPLIST_KEY_NSCameraUsageDescription = "您的相机将被用于扫描二维码、拍摄照片和视频。"; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "需要获取您的位置信息以在地图上显示您的位置"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息以在地图上显示您的位置"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "我们需要使用您的麦克风,以便您使用麦克风进行音频录制。"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "为了保存图片到您的相册,请允许添加照片,谢谢。"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "我们需要访问您的相册,以便您可以发送照片。"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = Main; @@ -1538,6 +1596,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -1601,6 +1660,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; diff --git a/QuickLocation.xcworkspace/xcuserdata/yanghong.xcuserdatad/UserInterfaceState.xcuserstate b/QuickLocation.xcworkspace/xcuserdata/yanghong.xcuserdatad/UserInterfaceState.xcuserstate index 4e1bb89..3562d90 100644 Binary files a/QuickLocation.xcworkspace/xcuserdata/yanghong.xcuserdatad/UserInterfaceState.xcuserstate and b/QuickLocation.xcworkspace/xcuserdata/yanghong.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/QuickLocation/Info.plist b/QuickLocation/Info.plist index b20bc81..8171989 100644 --- a/QuickLocation/Info.plist +++ b/QuickLocation/Info.plist @@ -7,14 +7,6 @@ NSAllowsArbitraryLoads - NSCameraUsageDescription - 您的相机将被用于扫描二维码、拍摄照片和视频。 - NSLocationAlwaysAndWhenInUseUsageDescription - 需要获取您的位置信息以在地图上显示您的位置 - NSLocationWhenInUseUsageDescription - 需要获取您的位置信息以在地图上显示您的位置 - NSMicrophoneUsageDescription - 我们需要使用您的麦克风,以便您使用麦克风进行音频录制。 UIAppFonts douyu.otf diff --git a/QuickLocation/Manager/App/RouterManager.swift b/QuickLocation/Manager/App/RouterManager.swift index 17fc85c..4522131 100644 --- a/QuickLocation/Manager/App/RouterManager.swift +++ b/QuickLocation/Manager/App/RouterManager.swift @@ -29,6 +29,8 @@ enum Route: String { case vipRights = "vipRights" /// 群聊 case groupChat = "groupChat" + /// 圈子设置 + case groupSetting = "groupSetting" } extension Route: RouterTarget { @@ -149,10 +151,17 @@ extension AppRouter: AppRouterProtocol { VipRightsVC() } + // MARK: - 群聊 AppRouter.register(Route.groupChat) { url, parameters in let groupId = parameters["groupId"].safeString return GroupChatVC(groupId: groupId) } + + // MARK: - 圈子设置 + AppRouter.register(Route.groupSetting) { url, parameters in + let groupId = parameters["groupId"].safeString + return GroupSettingVC(groupId: groupId) + } } } diff --git a/QuickLocation/Manager/Theme/ThemeManager.swift b/QuickLocation/Manager/Theme/ThemeManager.swift index 9059632..0eaa145 100644 --- a/QuickLocation/Manager/Theme/ThemeManager.swift +++ b/QuickLocation/Manager/Theme/ThemeManager.swift @@ -60,7 +60,7 @@ public struct ThemeColor: Mappable { /// 用于次要信息、辅助功能,如提示说明文字 搜索栏默认文字 #999999 public var contentColor = UIColor(hexStr: "#999999") /// 线条 - public var lineColor = UIColor(hexStr: "#F2F2F2") + public var lineColor = UIColor(hexStr: "#EEEEEE") /// 背景色 public var backgroundColor = UIColor(hexStr: "#F2F2F2") /// 商品卡片背景色 diff --git a/QuickLocation/Model/GroupModel.swift b/QuickLocation/Model/GroupModel.swift index 0302838..39173ce 100644 --- a/QuickLocation/Model/GroupModel.swift +++ b/QuickLocation/Model/GroupModel.swift @@ -105,9 +105,13 @@ struct GroupInfoModel: Mappable, Equatable { var groupIcon: UIImage { UIImage(named: "GroupIcon/\(icon_index)") ?? UIImage() } + /// 标签 + var labels: [String] = [] + /// 审核开关 + var review: Bool = false /// 人数 var people_no: Int = 0 - /// + /// 描述 var description: String = "" /// 会员等级 var level: String = "" @@ -121,7 +125,10 @@ struct GroupInfoModel: Mappable, Equatable { is_owner <- map["is_owner"] name <- map["name"] icon_index <- map["icon_index"] + labels <- map["labels"] level <- map["level"] + review <- map["review"] + description <- map["description"] people_no <- (map["people_no"], kStrTransformInt) } } diff --git a/QuickLocation/Section/Common/TextInput/TextInputViewController.swift b/QuickLocation/Section/Common/TextInput/TextInputViewController.swift new file mode 100644 index 0000000..4c58f58 --- /dev/null +++ b/QuickLocation/Section/Common/TextInput/TextInputViewController.swift @@ -0,0 +1,311 @@ +// +// TextInputViewController.swift +// QuickLocation +// +// Created by 八条 on 2026/6/9. +// + +import UIKit +import RxSwift +import RxCocoa + +/// 通用文本输入页面 +/// 用法: +/// let vc = TextInputViewController(title: "编辑昵称", maxLength: 20) { text in +/// print("用户输入: \(text)") +/// } +/// present(vc, animated: true) +final class TextInputViewController: UIViewController { + + private let titleText: String + private let maxLength: Int + private let confirmAction: ((String) -> Void)? + + private let disposeBag = DisposeBag() + private let textRelay = BehaviorRelay(value: "") + + // MARK: - Init + + /// - Parameters: + /// - title: 页面标题 + /// - maxLength: 文字输入上限(0 表示不限制) + /// - initialText: 初始文本,默认空 + /// - confirmAction: 确定回调 + init(title: String, + maxLength: Int = 0, + initialText: String = "", + confirmAction: ((String) -> Void)? = nil) { + self.titleText = title + self.maxLength = maxLength + self.confirmAction = confirmAction + self.textRelay.accept(initialText) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + modalTransitionStyle = .coverVertical + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(hexStr: "#F5FBFB") + setupUI() + setupBinding() + setupKeyboard() + textView.becomeFirstResponder() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if textViewHeightConstraint == nil { + updateTextViewHeight() + } + } + + // MARK: - UI + + private func setupUI() { + view.addSubview(topBar) + topBar.addSubview(closeBtn) + topBar.addSubview(titleLabel) + + view.addSubview(contentView) + contentView.addSubview(textView) + textView.addSubview(countLabel) + contentView.addSubview(confirmBtn) + + // 顶栏 + topBar.layoutChain + .top() + .edgesHorzontal() + .height(kNaviHeight) + + closeBtn.layoutChain + .bottom(12) + .left(7) + .width(24).height(24) + + titleLabel.layoutChain + .centerY(closeBtn) + .centerX() + + // 内容容器(textView + 按钮),键盘升起时整体上移 + contentView.layoutChain + .topToBottomOfView(topBar, offset: 16) + .edgesHorzontal(15) + .bottom() + + // 输入框 + textView.layoutChain + .top() + .edgesHorzontal() + .height(textViewMinHeight) + + // 字数统计(textView 右下角) + countLabel.layoutChain + .right(-10) + .bottom(-8) + + // 确定按钮 + confirmBtn.layoutChain + .topToBottomOfView(textView, offset: 50) + .edgesHorzontal() + .height(44) + .bottom() + } + + // MARK: - Binding + + private func setupBinding() { + // 输入流 + textView.rx.text + .compactMap { $0 } + .subscribe(onNext: { [weak self] text in + guard let self = self else { return } + let realText = self.maxLength > 0 && text.count > self.maxLength + ? String(text.prefix(self.maxLength)) + : text + if realText != text { + self.textView.text = realText + } + self.textRelay.accept(realText) + }) + .disposed(by: disposeBag) + + // 字数统计 + textRelay + .map { [weak self] text in + guard let self = self, self.maxLength > 0 else { return "" } + return "\(text.count)/\(self.maxLength)" + } + .bind(to: countLabel.rx.text) + .disposed(by: disposeBag) + + // 确定按钮状态 + 背景色 + let confirmEnabled = textRelay + .map { [weak self] text in + guard let self = self else { return false } + if self.maxLength > 0 { return !text.isEmpty && text.count <= self.maxLength } + return !text.isEmpty + } + .share(replay: 1) + + confirmEnabled + .bind(to: confirmBtn.rx.isEnabled) + .disposed(by: disposeBag) + + confirmEnabled + .subscribe(onNext: { [weak self] enabled in + self?.confirmBtn.backgroundColor = enabled + ? UIColor(hexStr: "#16B3FF") + : UIColor(hexStr: "#CCCCCC") + }) + .disposed(by: disposeBag) + + // 动态高度 + textView.rx.text + .observe(on: MainScheduler.asyncInstance) + .subscribe(onNext: { [weak self] _ in + self?.updateTextViewHeight() + }) + .disposed(by: disposeBag) + + // 确定 + confirmBtn.rx.tap + .withLatestFrom(textRelay) + .subscribe(onNext: { [weak self] text in + guard let self = self else { return } + self.confirmAction?(text) + self.dismiss(animated: true) + }) + .disposed(by: disposeBag) + + // 关闭 + closeBtn.rx.tap + .subscribe(onNext: { [weak self] _ in + self?.dismiss(animated: true) + }) + .disposed(by: disposeBag) + } + + // MARK: - Keyboard + + private var originContentY: CGFloat = 0 + + private func setupKeyboard() { + NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification) + .subscribe(onNext: { [weak self] noti in + guard let self = self, + let userInfo = noti.userInfo, + let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { return } + let keyboardHeight = frame.height + let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 + // 计算 contentView 需要上移的距离 = 键盘遮住底部的高度 - 安全区域 + let offset = keyboardHeight - kSafeBottomMargin + UIView.animate(withDuration: duration) { + self.contentView.transform = CGAffineTransform(translationX: 0, y: -offset) + self.view.layoutIfNeeded() + } + }) + .disposed(by: disposeBag) + + NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification) + .subscribe(onNext: { [weak self] noti in + guard let self = self else { return } + let duration = (noti.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 + UIView.animate(withDuration: duration) { + self.contentView.transform = .identity + self.view.layoutIfNeeded() + } + }) + .disposed(by: disposeBag) + } + + // MARK: - TextView Height + + private let textViewMinHeight: CGFloat = 150 + private let textViewMaxHeight: CGFloat = 300 + private var textViewHeightConstraint: NSLayoutConstraint? + + private func updateTextViewHeight() { + let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: CGFloat.greatestFiniteMagnitude)) + let height = min(max(size.height, textViewMinHeight), textViewMaxHeight) + if textViewHeightConstraint == nil { + textViewHeightConstraint = textView.layoutChain.height(height) + } else { + textViewHeightConstraint?.constant = height + } + UIView.setAnimationsEnabled(false) + textView.layoutIfNeeded() + UIView.setAnimationsEnabled(true) + } + + // MARK: - Views + + private lazy var contentView: UIView = { + let v = UIView() + v.backgroundColor = .clear + return v + }() + + private lazy var topBar: UIView = { + let v = UIView() + v.backgroundColor = .clear + return v + }() + + private lazy var closeBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Common/back"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 30) + return btn + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .medium) + label.textColor = UIColor(hexStr: "#333333") + label.text = titleText + label.textAlignment = .center + return label + }() + + lazy var textView: UITextView = { + let tv = UITextView() + tv.font = .systemFont(ofSize: 15) + tv.textColor = UIColor(hexStr: "#333333") + tv.backgroundColor = .white + tv.cornerRadius = 4 + tv.layer.borderWidth = 1 + tv.layer.borderColor = ThemeManager.shared.color.lineColor.cgColor + tv.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + tv.showsVerticalScrollIndicator = true + tv.bounces = false + tv.tintColor = UIColor(hexStr: "#16B3FF") + return tv + }() + + private lazy var countLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(hexStr: "#BBBBBB") + label.text = maxLength > 0 ? "0/\(maxLength)" : "" + return label + }() + + private lazy var confirmBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("确定", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.setTitleColor(.white, for: .disabled) + btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + btn.cornerRadius = 22 + btn.isEnabled = false + return btn + }() +} diff --git a/QuickLocation/Section/Group/CreateGroup/GroupIconListVC.swift b/QuickLocation/Section/Group/CreateGroup/GroupIconListVC.swift index cee25ae..f6e037c 100644 --- a/QuickLocation/Section/Group/CreateGroup/GroupIconListVC.swift +++ b/QuickLocation/Section/Group/CreateGroup/GroupIconListVC.swift @@ -48,6 +48,5 @@ class GroupIconListVC: BaseViewController { extension GroupIconListVC: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { rootView.selectedIndex = indexPath.row + 1 - onSelectIcon?(rootView.selectedIndex) } } diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift index add59ba..bc6f58e 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatVC.swift @@ -12,6 +12,8 @@ import RxDataSources import OpenIMSDK import AVFoundation import AudioToolbox +import HXPHPicker +import IQKeyboardManagerSwift final class GroupChatVC: BaseViewController { @@ -45,6 +47,49 @@ final class GroupChatVC: BaseViewController { setupMessageListener() setupVoiceRecording() setupPanelDismiss() + setupKeyboard() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + IQKeyboardManager.shared.isEnabled = false + IQKeyboardManager.shared.resignOnTouchOutside = false + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) +// if !hasScrolledToBottom { +// hasScrolledToBottom = true +// scrollToBottom() +// } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + VoicePlayerManager.shared.stop() + IQKeyboardManager.shared.isEnabled = true + IQKeyboardManager.shared.resignOnTouchOutside = true + } + + // MARK: - Keyboard + private func setupKeyboard() { + // 键盘升起 + NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification) + .subscribe(onNext: { [weak self] noti in + guard let self = self, + let userInfo = noti.userInfo, + let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { return } + let height = frame.height + let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25 + // 收起表情/语音面板 + self.rootView.dismissAllPanels(excludeTextField: true) + UIView.animate(withDuration: duration) { + self.rootView.bottomBar.layoutChain.bottom(height + kSafeBottomMargin + 20) + } + self.scrollToBottom() + }) + .disposed(by: disposeBag) } private func setupPanelDismiss() { @@ -89,19 +134,6 @@ final class GroupChatVC: BaseViewController { private var hasScrolledToBottom = false - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - if !hasScrolledToBottom { - hasScrolledToBottom = true - scrollToBottom() - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - VoicePlayerManager.shared.stop() - } - private func scrollToBottom() { let count = dataSource.sectionModels.first?.items.count ?? 0 guard count > 0 else { return } @@ -112,50 +144,6 @@ final class GroupChatVC: BaseViewController { } } - // MARK: - dataSource - private lazy var dataSource: RxTableViewSectionedReloadDataSource = { - RxTableViewSectionedReloadDataSource { _, tableView, indexPath, item in - switch item { - case let .send(msg): - let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(msg) - return cell - case let .received(msg): - let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(msg) - return cell - case let .emojiSend(msg): - let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(msg) - return cell - case let .emojiReceived(msg): - let cell: EmojiReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(msg) - return cell - case let .voiceSend(msg): - let cell: VoiceSendMsgCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(msg) - return cell - case let .voiceReceived(msg): - let cell: VoiceReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(msg) - return cell - case let .imageSend(msg): - let cell: ImageSendMsgCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(msg) - return cell - case let .imageReceived(msg): - let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(msg) - return cell - case let .notification(text): - let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath) - cell.configure(text) - return cell - } - } - }() - private lazy var emojiDataSource: RxCollectionViewSectionedReloadDataSource> = { RxCollectionViewSectionedReloadDataSource> { _, collectionView, indexPath, name in let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath) @@ -183,25 +171,58 @@ final class GroupChatVC: BaseViewController { }) .disposed(by: disposeBag) + // 语音按钮 + rootView.voiceBtn.rx.tap.subscribe(onNext: { [weak self] _ in + guard let self = self else { return } + let status = AVCaptureDevice.authorizationStatus(for: .audio) + switch status { + case .authorized: + break + case .notDetermined: + AVAudioSession.sharedInstance().requestRecordPermission { granted in + guard granted else { return } + DispatchQueue.main.async { + self.rootView.dismissAllPanels() + self.showSpeakPanel() + } + } + return + default: + Permission.openAppSetting(title: "请开启麦克风权限", + message: "请在iPhone的“设置-隐私-麦克风”选项中允许\(kAppName)访问你的麦克风。") + return + } + self.rootView.dismissAllPanels() + self.showSpeakPanel() + }) + .disposed(by: disposeBag) + + // 键盘按钮 + rootView.voiceRecordView.keyboardBtn.rx.tap + .subscribe(onNext: { [weak self] _ in + guard let self = self else { return } + self.rootView.dismissAllPanels() + self.rootView.textField.becomeFirstResponder() + }) + .disposed(by: disposeBag) + + // 表情按钮 rootView.emojiBtn.rx.tap .subscribe(onNext: { [weak self] _ in guard let self = self else { return } - let show = self.rootView.emojiPanelView.isHidden - self.rootView.emojiPanelView.isHidden = !show - let offset: CGFloat = show ? 220 : 0 - UIView.animate(withDuration: 0.25) { - self.rootView.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20 + offset) - } completion: { _ in - let offset: CGFloat = self.rootView.tableView.contentSize.height - self.rootView.tableView.setContentOffset(CGPointMake(0, offset), animated: false) - } - if show { - self.rootView.textField.resignFirstResponder() - EmojiPanelCell.preloadAnimations() - } + self.showEmojiPanel() }) .disposed(by: disposeBag) + rootView.voiceRecordView.emojiBtn.rx.tap + .subscribe(onNext: { [weak self] _ in + guard let self = self else { return } + self.rootView.dismissAllPanels() + self.showEmojiPanel() + }) + .disposed(by: disposeBag) + + // 表情面板点击 rootView.emojiCollectionView.rx.modelSelected(String.self) .subscribe(onNext: { [weak self] name in guard let self = self, let idx = UITableViewCell.emojiFileNames.firstIndex(of: name) else { return } @@ -209,8 +230,18 @@ final class GroupChatVC: BaseViewController { }) .disposed(by: disposeBag) + // ➕按钮 + Observable.merge( + rootView.addBtn.rx.tap.asObservable(), + rootView.voiceRecordView.addBtn.rx.tap.asObservable() + ) + .subscribe(onNext: { [weak self] in + guard let self = self else { return } + self.rootView.dismissAllPanels() + self.showAlbum() + }) + .disposed(by: disposeBag) - let sendText = Observable.merge( rootView.sendBtn.rx.tap.map { [weak self] _ in self?.rootView.textField.text ?? "" }, rootView.textField.rx.controlEvent(.editingDidEndOnExit) @@ -222,6 +253,65 @@ final class GroupChatVC: BaseViewController { sendText .bind(to: viewModel.input.sendMessage) .disposed(by: disposeBag) + + // 设置 + rootView.settingBtn.rx.tap + .subscribe(onNext: { [weak self] _ in + guard let self = self else { return } + AppRouter.push(Route.groupSetting, userInfo: ["groupId": self.viewModel.groupId]) + }) + .disposed(by: disposeBag) + } + + // MARK: - 显示语音面板 + private func showSpeakPanel() { + let show = rootView.voiceRecordView.isHidden + rootView.voiceRecordView.isHidden = !show + let offset: CGFloat = show ? 252 : 0 + UIView.animate(withDuration: 0.25) { + self.rootView.bottomBar.layoutChain.bottom(show ? offset - self.rootView.bottomBar.dl.height : kSafeBottomMargin + 20) + self.rootView.voiceRecordView.layoutChain.bottom(offset - 252 + kSafeBottomMargin) + } + scrollToBottom() + } + + // MARK: - 显示表情面板 + private func showEmojiPanel() { + let show = self.rootView.emojiPanelView.isHidden + self.rootView.emojiPanelView.isHidden = !show + let offset: CGFloat = show ? 220 : 0 + UIView.animate(withDuration: 0.25) { + self.rootView.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20 + offset) + } +// completion: { _ in +// let offset: CGFloat = self.rootView.tableView.contentSize.height +// self.rootView.tableView.setContentOffset(CGPointMake(0, offset), animated: false) +// } + scrollToBottom() + + if show { + self.rootView.textField.resignFirstResponder() + EmojiPanelCell.preloadAnimations() + } + } + + // MARK: - 显示相册 + private func showAlbum() { + // 设置与微信主题一致的配置 + let config = PhotoTools.getWXPickerConfig() + // 最多可以选择的资源数,如果为0则不限制 + config.selectOptions = [.photo] + config.selectMode = .multiple + config.maximumSelectedCount = 9 +// config.maximumSelectedPhotoFileSize = 5242880 + config.allowSyncICloudWhenSelectPhoto = false + config.previewView.bottomView.editButtonHidden = true + config.photoList.allowAddCamera = true + config.photoList.camera.allowsEditing = false + let pickerController = PhotoPickerController(picker: config) + pickerController.pickerDelegate = self + pickerController.modalPresentationStyle = .fullScreen + self.present(pickerController, animated: true, completion: nil) } // MARK: - Voice Recording @@ -331,6 +421,56 @@ final class GroupChatVC: BaseViewController { self.viewModel.loadMessages() }.disposed(by: disposeBag) } + + // MARK: - dataSource + private lazy var dataSource: RxTableViewSectionedReloadDataSource = { + RxTableViewSectionedReloadDataSource { _, tableView, indexPath, item in + switch item { + case let .send(msg): + let cell: TextSendMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + return cell + case let .received(msg): + let cell: TextReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + return cell + case let .emojiSend(msg): + let cell: EmojiSendMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + return cell + case let .emojiReceived(msg): + let cell: EmojiReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + return cell + case let .voiceSend(msg): + let cell: VoiceSendMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + return cell + case let .voiceReceived(msg): + let cell: VoiceReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + return cell + case let .imageSend(msg): + let cell: ImageSendMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + cell.onImageTap = { [weak self] in + self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) + } + return cell + case let .imageReceived(msg): + let cell: ImageReceivedMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(msg) + cell.onImageTap = { [weak self] in + self?.showBigImage(imgUrlList: [msg.imageUrl], currentPage: 0, projectiveView: cell.photoView) + } + return cell + case let .notification(text): + let cell: NotificationMsgCell = tableView.dequeueReusableCell(for: indexPath) + cell.configure(text) + return cell + } + } + }() } // MARK: - MessageListenerProxy @@ -345,3 +485,97 @@ private class MessageListenerProxy: NSObject, OIMAdvancedMsgListener { handler(msg) } } + +// MARK: - PhotoPickerControllerDelegate +extension GroupChatVC: PhotoPickerControllerDelegate { + /// 选择完成之后调用 + /// - Parameters: + /// - pickerController: 对应的 PhotoPickerController + /// - result: 选择的结果 + /// result.photoAssets 选择的资源数组 + /// result.isOriginal 是否选中原图 + func pickerController(_ pickerController: PhotoPickerController, + didFinishSelection result: PickerResult) { + result.getImage { (image, photoAsset, index) in + } completionHandler: { [weak self] (images) in + guard let self = self else { return } + for img in images { + self.sendImageMessage(img) + } + } + } + + private func sendImageMessage(_ image: UIImage) { + guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let dir = NSTemporaryDirectory() + let filename = "img_\(Int(Date().timeIntervalSince1970)).jpg" + let fileURL = URL(fileURLWithPath: dir + filename) + try? data.write(to: fileURL) + + let displaySize = Self.imageDisplaySize(w: image.size.width, h: image.size.height) + + let msg = OIMMessageInfo.createImageMessage(fromFullPath: fileURL.path) + // 使用 SDK 的 clientMsgID 作为本地消息 ID,方便后续与监听器去重 + let localId = msg.clientMsgID ?? UUID().uuidString + + // 立即显示本地图片(带 loading) + let localMsg = ChatMessage( + id: localId, + isSelf: true, + avatar: viewModel.getUserAvatar(id: AppContextManager.shared.userId), + senderName: AppContextManager.shared.name, + content: "", + voiceUrl: "", + imageUrl: fileURL.path, + imageWidth: displaySize.width, + imageHeight: displaySize.height, + timestamp: Date().timeIntervalSince1970, + showTime: false, + isUploading: true + ) + viewModel.appendLocalMessage(.imageSend(localMsg)) + OIMManager.manager.sendMessage(msg, + recvID: "", + groupID: viewModel.groupId, + offlinePushInfo: nil, + onSuccess: { [weak self] returnedMsg in + // 服务端返回后,更新本地消息为服务端图片URL,去掉loading + // 注意:returnedMsg 是 SDK 回填服务端数据后的新 OIMMessageInfo,包含完整 URL + let networkUrl = returnedMsg?.pictureElem?.sourcePicture?.url + ?? returnedMsg?.pictureElem?.bigPicture?.url + ?? returnedMsg?.pictureElem?.sourcePath + ?? "" + self?.viewModel.updateLocalMessage(id: localId) { chatMsg in + // 仅在服务端有URL时才替换,否则保留本地路径让图片仍可见 + if !networkUrl.isEmpty { + chatMsg.imageUrl = networkUrl + } + chatMsg.isUploading = false + } + try? FileManager.default.removeItem(at: fileURL) + }, + onProgress: nil as OIMNumberCallback?, + onFailure: { [weak self] code, errMsg in + print("Image send failed: \(code) \(errMsg ?? "")") + // 发送失败,隐藏 loading + self?.viewModel.updateLocalMessage(id: localId) { chatMsg in + chatMsg.isUploading = false + } + }) + } + + private static func imageDisplaySize(w: CGFloat, h: CGFloat) -> CGSize { + guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) } + let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80 + var dw = maxW, dh = dw * (h / w) + if dh > maxH { dh = maxH; dw = dh * (w / h) } + if dw < minW { dw = minW; dh = dw * (h / w) } + return CGSize(width: dw, height: dh) + } + + /// 点击取消时调用 + /// - Parameter pickerController: 对应的 PhotoPickerController + func pickerController(didCancel pickerController: PhotoPickerController) { + + } +} diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift index c10013b..13125da 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatView.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatView.swift @@ -25,11 +25,12 @@ struct ChatMessage { let senderName: String let content: String let voiceUrl: String - let imageUrl: String + var imageUrl: String let imageWidth: CGFloat let imageHeight: CGFloat let timestamp: TimeInterval var showTime: Bool = false + var isUploading: Bool = false } class GroupChatView: UIView { @@ -44,54 +45,22 @@ class GroupChatView: UIView { var onVoiceRecordState: ((VoiceRecordState) -> Void)? private func setupRx() { - voiceBtn.rx.tap.subscribe(onNext: { [weak self] _ in - guard let self = self else { return } - let status = AVCaptureDevice.authorizationStatus(for: .audio) - switch status { - case .authorized: - break - case .notDetermined: - AVAudioSession.sharedInstance().requestRecordPermission { granted in - guard granted else { return } - DispatchQueue.main.async { - self.toggleVoicePanel() - } - } - return - default: - Permission.openAppSetting(title: "请开启麦克风权限", - message: "请在iPhone的“设置-隐私-麦克风”选项中允许\(kAppName)访问你的麦克风。") - return - } - self.toggleVoicePanel() - }) - .disposed(by: disposeBag) - } - - private func toggleVoicePanel() { - let show = voiceRecordView.isHidden - voiceRecordView.isHidden = !show - let offset: CGFloat = show ? 252 : 0 - UIView.animate(withDuration: 0.25) { - self.bottomBar.layoutChain.bottom(show ? offset - self.bottomBar.dl.height : kSafeBottomMargin + 20) - self.voiceRecordView.layoutChain.bottom(offset - 252 + kSafeBottomMargin) - } completion: { _ in - let offset: CGFloat = self.tableView.contentSize.height - self.tableView.setContentOffset(CGPointMake(0, offset), animated: false) - } + } /// 收起所有浮层面板 - func dismissAllPanels() { - let needsReset = !emojiPanelView.isHidden || !voiceRecordView.isHidden || !voiceRecordView.isHidden + func dismissAllPanels(excludeTextField: Bool = false) { + let needsReset = !emojiPanelView.isHidden + || !voiceRecordView.isHidden + || textField.isFirstResponder + guard needsReset else { return } emojiPanelView.isHidden = true voiceRecordView.isHidden = true - textField.resignFirstResponder() + if !excludeTextField { textField.resignFirstResponder() } UIView.animate(withDuration: 0.25) { self.bottomBar.layoutChain.bottom(kSafeBottomMargin + 20) self.voiceRecordView.layoutChain.bottom(-252) - self.layoutIfNeeded() } } @@ -1226,16 +1195,34 @@ final class VoiceReceivedMsgCell: UITableViewCell, VoicePlaybackView { // MARK: - 发送的图片消息 final class ImageSendMsgCell: UITableViewCell { + var onImageTap: (() -> Void)? + func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil avatarView.image = msg.avatar if !msg.imageUrl.isEmpty { - photoView.dl.setImage(with: msg.imageUrl) + // 优先加载本地文件路径,否则走网络加载 + if let localImage = UIImage(contentsOfFile: msg.imageUrl) { + photoView.image = localImage + } else { + photoView.dl.setImage(with: msg.imageUrl) + } } photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight) + loadingView.isHidden = !msg.isUploading + if msg.isUploading { loadingView.startAnimating() } } + @objc private func onTap() { onImageTap?() } + + private let loadingView: UIActivityIndicatorView = { + let v = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.large) + v.hidesWhenStopped = true + v.color = UIColor(hexStr: "#16B3FF") + return v + }() + private static func displaySize(w: CGFloat, h: CGFloat) -> CGSize { guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) } let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80 @@ -1276,12 +1263,13 @@ final class ImageSendMsgCell: UITableViewCell { return iv }() - private let photoView: UIImageView = { + lazy var photoView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 8 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#F0F0F0") + iv.isUserInteractionEnabled = true return iv }() @@ -1290,8 +1278,13 @@ final class ImageSendMsgCell: UITableViewCell { selectionStyle = .none backgroundColor = .clear contentView.addSubview(timeLabel) - contentView.addSubview(avatarView) contentView.addSubview(photoView) + contentView.addSubview(avatarView) + + let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) + photoView.addGestureRecognizer(tap) + photoView.addSubview(loadingView) + loadingView.layoutChain.centerX().centerY() timeLabel.layoutChain.top().centerX() avatarView.layoutChain @@ -1312,6 +1305,8 @@ final class ImageSendMsgCell: UITableViewCell { // MARK: - 收到的图片消息 final class ImageReceivedMsgCell: UITableViewCell { + var onImageTap: (() -> Void)? + func configure(_ msg: ChatMessage) { timeLabel.isHidden = !msg.showTime timeLabel.text = msg.showTime ? formatTime(msg.timestamp) : nil @@ -1323,6 +1318,8 @@ final class ImageReceivedMsgCell: UITableViewCell { photoView.layoutChain.width(msg.imageWidth).height(msg.imageHeight) } + @objc private func onTap() { onImageTap?() } + private static func displaySize(w: CGFloat, h: CGFloat) -> CGSize { guard w > 0, h > 0 else { return CGSize(width: 160, height: 160) } let maxW: CGFloat = 200, maxH: CGFloat = 250, minW: CGFloat = 80 @@ -1370,12 +1367,13 @@ final class ImageReceivedMsgCell: UITableViewCell { return label }() - private let photoView: UIImageView = { + lazy var photoView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.cornerRadius = 8 iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#F0F0F0") + iv.isUserInteractionEnabled = true return iv }() @@ -1388,8 +1386,11 @@ final class ImageReceivedMsgCell: UITableViewCell { contentView.addSubview(photoView) contentView.addSubview(avatarView) + let tap = UITapGestureRecognizer(target: self, action: #selector(onTap)) + photoView.addGestureRecognizer(tap) + timeLabel.layoutChain.top().centerX() - + photoView.layoutChain .topToBottomOfView(timeLabel, offset: 14) .width(160).height(160) diff --git a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift index d1b46dd..b2cee24 100644 --- a/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift +++ b/QuickLocation/Section/Group/GroupChat/GroupChatViewModel.swift @@ -134,11 +134,44 @@ final class GroupChatViewModel { }) } + /// 本地消息(发送中) + func appendLocalMessage(_ item: ChatSectionItem) { + var items = messagesSubject.value + items.append(item) + messagesSubject.accept(items) + } + + /// 根据 id 更新本地消息(图片上传成功/失败后替换本地占位消息) + func updateLocalMessage(id: String, update: (inout ChatMessage) -> Void) { + var items = messagesSubject.value + guard let idx = items.firstIndex(where: { item in + switch item { + case let .imageSend(m): return m.id == id + case let .voiceSend(m): return m.id == id + case let .send(m): return m.id == id + case let .emojiSend(m): return m.id == id + default: return false + } + }), + var chatMsg = items[idx].chatMessage + else { return } + update(&chatMsg) + items[idx] = ChatSectionItem.with(chatMsg) + messagesSubject.accept(items) + } + // MARK: - Receive func onReceiveMessage(_ msg: OIMMessageInfo) { guard let item = toSectionItem(msg) else { return } let ts = timestampFrom(item: item) var items = messagesSubject.value + + // 去重:如果 clientMsgID 已存在(本地占位消息),跳过监听器追加 + if let clientMsgID = msg.clientMsgID, !clientMsgID.isEmpty, + items.contains(where: { $0.chatMessage?.id == clientMsgID }) { + return + } + let showTime = items.isEmpty || ts - lastTimeGap >= timeGapThreshold lastTimeGap = ts items.append(showTime ? setShowTime(item, true) : item) @@ -284,3 +317,30 @@ final class GroupChatViewModel { return displayStr } } + +// MARK: - ChatSectionItem Helpers +extension ChatSectionItem { + /// 提取 ChatMessage(仅用于有消息的 case) + 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): + return m + case .notification: return nil + } + } + + /// 用给定 ChatMessage 重建 case + static func with(_ msg: ChatMessage) -> ChatSectionItem { + if !msg.imageUrl.isEmpty { + return msg.isSelf ? .imageSend(msg) : .imageReceived(msg) + } + if msg.content.hasPrefix("js_emoji:") { + return msg.isSelf ? .emojiSend(msg) : .emojiReceived(msg) + } + if !msg.voiceUrl.isEmpty { + return msg.isSelf ? .voiceSend(msg) : .voiceReceived(msg) + } + return msg.isSelf ? .send(msg) : .received(msg) + } +} diff --git a/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift b/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift new file mode 100644 index 0000000..537d7c7 --- /dev/null +++ b/QuickLocation/Section/Group/GroupSetting/GroupSettingVC.swift @@ -0,0 +1,149 @@ +// +// GroupSettingVC.swift +// QuickLocation +// +// Created by 八条 on 2026/6/9. +// + +import UIKit +import RxSwift +import RxCocoa +import RxDataSources +import ObjectMapper + +class GroupSettingVC: BaseViewController { + + fileprivate var rootView: GroupSettingView! + + override func loadView() { + rootView = GroupSettingView(frame: UIScreen.main.bounds) + view = rootView + } + + private var viewModel: GroupSettingViewModel + + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + reactiveAction() + requestGroupInfo() + } + + // MARK: - Actions + private func reactiveAction() { + // 更换名称 + rootView.groupNameView.rx.tapGesture.subscribe { _ in + guard let model = self.viewModel.groupModel, model.is_owner else { return } + let vc = TextInputViewController(title: "圈子名称", maxLength: 10, initialText: model.name) { text in + self.requestEditName(text) + } + self.present(vc, animated: true, completion: nil) + }.disposed(by: disposeBag) + + // 修改描述 + rootView.groupDescView.rx.tapGesture.subscribe { _ in + guard let model = self.viewModel.groupModel, model.is_owner else { return } + let vc = TextInputViewController(title: "圈子描述", maxLength: 40, initialText: model.description) { text in + self.requestEditDesc(text) + } + self.present(vc, animated: true, completion: nil) + }.disposed(by: disposeBag) + + // 更换图标 + rootView.groupIconView.rx.tapGesture.subscribe { _ in + guard let model = self.viewModel.groupModel, model.is_owner else { return } + let vc = GroupIconListVC(iconIndex: "\(model.icon_index)") + vc.onSelectIcon = { index in + self.requestChangeIcon(index) + } + self.navigationController?.pushViewController(vc, animated: true) + }.disposed(by: disposeBag) + + // 审核开关 + rootView.switchBtn.rx.isOn + .subscribe(onNext: { isOn in + guard let model = self.viewModel.groupModel, model.is_owner else { return } + self.requestChangeReview(isOn) + }) + .disposed(by: disposeBag) + + // 标签 + rootView.tagView.rx.tapGesture.subscribe { _ in + guard let model = self.viewModel.groupModel, model.is_owner else { return } + GroupTagListView.show(selectedTagList: model.labels) { tagList in + guard let model = self.viewModel.groupModel, + model.is_owner, + let list = tagList else { return } + self.requestEditLabels(labels: list) + } + }.disposed(by: disposeBag) + + // 邀请成员 + rootView.inviteView.rx.tapGesture.subscribe(onNext: { _ in + guard let model = self.viewModel.groupModel else { return } + AppRouter.push(Route.inviteJoin, userInfo: ["groupInfo": model.toJSON()]) + }).disposed(by: disposeBag) + } + + // MARK: - API + private func requestGroupInfo() { + DLToast.showLoading() + GroupService.groupInfoByKey(viewModel.groupId).subscribe { response in + DLToast.dismiss() + guard let model = response.model else { return } + self.viewModel.groupModel = model + self.rootView.setupData(model) + }.disposed(by: disposeBag) + } + + private func requestChangeIcon(_ iconIndex: Int) { + DLToast.showLoading() + GroupService.changeIcon(requestData: ["group_key": viewModel.groupId, "icon_index": iconIndex]).subscribe { response in + DLToast.dismiss() + self.requestGroupInfo() + }.disposed(by: disposeBag) + } + + private func requestChangeReview(_ review: Bool) { + DLToast.showLoading() + GroupService.changeReview(requestData: ["group_key": viewModel.groupId, "review": review]).subscribe { response in + DLToast.dismiss() + self.requestGroupInfo() + }.disposed(by: disposeBag) + } + + private func requestEditName(_ name: String) { + DLToast.showLoading() + GroupService.editName(requestData: ["group_key": viewModel.groupId, "group_name": name]).subscribe { response in + DLToast.dismiss() + self.requestGroupInfo() + }.disposed(by: disposeBag) + } + + private func requestEditDesc(_ desc: String) { + DLToast.showLoading() + GroupService.editDesc(requestData: ["group_key": viewModel.groupId, "description": desc]).subscribe { response in + DLToast.dismiss() + self.requestGroupInfo() + }.disposed(by: disposeBag) + } + + private func requestEditLabels(labels: [String]) { + DLToast.showLoading() + GroupService.editLabels(requestData: ["group_key": viewModel.groupId, "labels": labels]).subscribe { response in + DLToast.dismiss() + self.requestGroupInfo() + }.disposed(by: disposeBag) + } + + // MARK: - Init + init(groupId: String) { + self.viewModel = GroupSettingViewModel(groupId: groupId) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift b/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift new file mode 100644 index 0000000..eaefd82 --- /dev/null +++ b/QuickLocation/Section/Group/GroupSetting/GroupSettingView.swift @@ -0,0 +1,651 @@ +// +// GroupSettingView.swift +// QuickLocation +// +// Created by 八条 on 2026/6/9. +// + +import UIKit +import RxSwift +import RxCocoa +import TagListView + +class GroupSettingView: UIView { + + var disposeBag = DisposeBag() + + func setupData(_ model: GroupInfoModel) { + navTitleLabel.text = model.name + groupNameLab.text = model.name + groupIcon.image = model.groupIcon + groupDescLab.text = model.description.isEmpty ? "暂无描述" : model.description + switchBtn.isOn = model.review + tagListView.removeAllTags() + tagListView.addTags(model.labels) + tagListView.tagViews.forEach { + $0.layer.cornerRadius = 4 + } + tagListView.invalidateIntrinsicContentSize() // 通知系统重新算高 + + auditSwitchView.isHidden = !model.is_owner + groupNameEditIcon.isHidden = !model.is_owner + groupIconArrowIcon.isHidden = !model.is_owner + groupDescEditIcon.isHidden = !model.is_owner + groupTagArrowIcon.isHidden = !model.is_owner + + auditMemberView.isHidden = !model.is_owner + removeMemberView.isHidden = !model.is_owner + dismissGroupView.isHidden = !model.is_owner + leaveGroupView.isHidden = model.is_owner + } + + private func setupRx() { + backBtn.rx.tap.subscribe(onNext: { _ in + AppRouter.shared.popOrDismiss() + }).disposed(by: disposeBag) + } + + private func setupUI() { + addSubview(navBgView) + addSubview(navBarView) + navBarView.addSubview(navTitleLabel) + navBarView.addSubview(backBtn) + + addSubview(scrollView) + scrollView.addSubview(scrollContentView) + scrollContentView.addSubview(infoView) + infoView.addSubview(infoStackView) + + scrollContentView.addSubview(groupManagerTitleLab) + scrollContentView.addSubview(groupManagerView) + groupManagerView.addSubview(groupManagerStackView) + + navBgView.layoutChain + .edges(excludingEdge: .bottom) + .heightToWidth(160/375) + + navBarView.layoutChain + .edges(excludingEdge: .bottom) + .height(kNaviHeight) + + navTitleLabel.layoutChain + .top(kStatusBarHeight + 12) + .centerY(backBtn) + .centerX() + + backBtn.layoutChain + .centerY(navTitleLabel) + .left(15) + .width(24) + .height(24) + + scrollView.layoutChain + .topToBottomOfView(navBarView) + .edges(excludingEdge: .top) + + scrollContentView.layoutChain + .edges() + .widthToView(scrollView) + + infoView.layoutChain + .top(10) + .edgesHorzontal(15) + + infoStackView.layoutChain + .edges() + + groupNameView.layoutChain + .height(60) + + groupIconView.layoutChain + .height(60) + + auditSwitchView.layoutChain + .height(57) + + groupManagerTitleLab.layoutChain + .topToBottomOfView(infoView, offset: 20) + .left(15) + + groupManagerView.layoutChain + .topToBottomOfView(groupManagerTitleLab, offset: 15) + .edgesHorzontal(15) + .bottom(30) + + groupManagerStackView.layoutChain + .edges() + + inviteView.layoutChain.height(57) + auditMemberView.layoutChain.height(57) + removeMemberView.layoutChain.height(57) + dismissGroupView.layoutChain.height(57) + leaveGroupView.layoutChain.height(57) + } + + lazy var navBgView: UIImageView = { + let iv = UIImageView() + iv.image = UIImage(named: "Common/navBar_bg_2") + iv.contentMode = .scaleAspectFill + return iv + }() + + lazy var navBarView: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + lazy var navTitleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 18, weight: .medium) + label.textColor = ThemeManager.shared.color.titleAuxColor + label.textAlignment = .center + return label + }() + + lazy var backBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Common/back"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 54, left: 15, bottom: 100, right: 100) + return btn + }() + + lazy var scrollView: UIScrollView = { + let view = UIScrollView() + view.backgroundColor = .clear +// view.showsVerticalScrollIndicator = false +// view.bounces = false + return view + }() + + lazy var scrollContentView: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + // MARK: - 圈子信息 + lazy var infoView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#F5FBFF") + view.cornerRadius = 10 + return view + }() + + lazy var infoStackView: UIStackView = { + let view = UIStackView(arrangedSubviews: [groupNameView, groupIconView, groupDescView, auditSwitchView, tagView]) + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = 0 + view.backgroundColor = .clear + return view + }() + + // 圈子名字 + lazy var groupNameView: UIView = { + let view = UIView() + view.backgroundColor = .clear + + let titleLab = UILabel() + titleLab.text = "圈子名称" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .width(50) + .centerY() + + let line = UIView() + line.backgroundColor = ThemeManager.shared.color.lineColor + view.addSubview(line) + line.layoutChain + .bottom() + .height(0.5) + .edgesHorzontal(15) + + view.addSubview(groupNameEditIcon) + groupNameEditIcon.layoutChain + .right(15) + .width(20) + .height(20) + .centerY() + + view.addSubview(groupNameLab) + groupNameLab.layoutChain + .leftToRightOfView(titleLab, offset: 20) + .rightToLeftOfView(groupNameEditIcon, offset: -17, relation: .greaterThanOrEqual) + .centerY() + + return view + }() + + lazy var groupNameLab: UILabel = { + let label = UILabel() + label.textColor = ThemeManager.shared.color.titleAuxColor + label.font = .systemFont(ofSize: 14, weight: .medium) + return label + }() + + lazy var groupNameEditIcon: UIImageView = { + let view = UIImageView(image: UIImage(named: "Group/edit")) + view.isHidden = true + return view + }() + + // 圈子图标 + lazy var groupIconView: UIView = { + let view = UIView() + view.backgroundColor = .clear + + let titleLab = UILabel() + titleLab.text = "圈子图标" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .centerY() + + let line = UIView() + line.backgroundColor = ThemeManager.shared.color.lineColor + view.addSubview(line) + line.layoutChain + .bottom() + .height(0.5) + .edgesHorzontal(15) + + view.addSubview(groupIconArrowIcon) + groupIconArrowIcon.layoutChain + .right(15) + .width(14) + .height(14) + .centerY() + + view.addSubview(groupIcon) + groupIcon.layoutChain + .leftToRightOfView(titleLab, offset: 20) + .centerY() + .width(40) + .heightToWidth(1) + + return view + }() + + lazy var groupIcon: UIImageView = { + let view = UIImageView() + view.backgroundColor = .clear + view.contentMode = .scaleAspectFill + view.cornerRadius = 10 + return view + }() + + lazy var groupIconArrowIcon: UIImageView = { + let view = UIImageView(image: UIImage(named: "Group/arrow")) + view.isHidden = true + return view + }() + + // 圈子描述 + lazy var groupDescView: UIView = { + let view = UIView() + view.backgroundColor = .clear + + let titleLab = UILabel() + titleLab.text = "圈子描述" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .width(50) + .top(22) + + let line = UIView() + line.backgroundColor = ThemeManager.shared.color.lineColor + view.addSubview(line) + line.layoutChain + .bottom() + .height(0.5) + .edgesHorzontal(15) + + view.addSubview(groupDescEditIcon) + groupDescEditIcon.layoutChain + .right(15) + .width(20) + .height(20) + .centerY(titleLab) + + view.addSubview(groupDescLab) + groupDescLab.layoutChain + .top(20) + .leftToRightOfView(titleLab, offset: 20) + .rightToLeftOfView(groupDescEditIcon, offset: -17) + .bottom(20) + + return view + }() + + lazy var groupDescLab: UILabel = { + let label = UILabel() + label.textColor = ThemeManager.shared.color.titleAuxColor + label.font = .systemFont(ofSize: 14, weight: .medium) + label.numberOfLines = 0 + return label + }() + + lazy var groupDescEditIcon: UIImageView = { + let view = UIImageView(image: UIImage(named: "Group/edit")) + view.isHidden = true + return view + }() + + // 审核开关 + lazy var auditSwitchView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.clipsToBounds = true + + let titleLab = UILabel() + titleLab.text = "开启审核" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .centerY() + + let line = UIView() + line.backgroundColor = ThemeManager.shared.color.lineColor + view.addSubview(line) + line.layoutChain + .bottom() + .height(0.5) + .edgesHorzontal(15) + + view.addSubview(switchBtn) + switchBtn.layoutChain + .right(15) + .centerY() + .width(51) + .height(30) + + view.isHidden = true + + return view + }() + + lazy var switchBtn: UISwitch = { + let view = UISwitch() + view.isOn = false + return view + }() + + // 标签 + lazy var tagView: UIView = { + let view = UIView() + view.backgroundColor = .clear + + let titleLab = UILabel() + titleLab.text = "圈子标签" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .width(50) + .top(20) + + view.addSubview(groupTagArrowIcon) + groupTagArrowIcon.layoutChain + .right(15) + .width(14) + .height(14) + .centerY(titleLab) + + view.addSubview(tagListView) + tagListView.layoutChain + .top(15) + .leftToRightOfView(titleLab, offset: 23) + .height(27, relation: .greaterThanOrEqual) + .rightToLeftOfView(groupTagArrowIcon, offset: -17) + + let tipsLab = UILabel() + tipsLab.text = "如选择为私密圈子,将不能被分享到探索和被搜索。" + tipsLab.textColor = ThemeManager.shared.color.contentColor + tipsLab.font = .systemFont(ofSize: 10, weight: .regular) + view.addSubview(tipsLab) + tipsLab.layoutChain + .topToBottomOfView(tagListView, offset: 10) + .leftToView(titleLab) + .bottom(20) + + return view + }() + + lazy var tagListView: TagListView = { + let view = TagListView() + view.textFont = UIFont.systemFont(ofSize: 12, weight: .medium) + view.textColor = UIColor(hexStr: "#16B3FF") + view.tagBackgroundColor = UIColor(hexStr: "#E3F6FF") + view.paddingX = 20 // 水平内边距 + view.paddingY = 6 // 垂直内边距 + view.alignment = .left // 对齐 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var groupTagArrowIcon: UIImageView = { + let view = UIImageView(image: UIImage(named: "Group/arrow")) + view.isHidden = true + return view + }() + + // MARK: - 圈子管理 + lazy var groupManagerTitleLab: UILabel = { + let label = UILabel() + label.text = "圈子管理" + label.textColor = ThemeManager.shared.color.titleAuxColor + label.font = .systemFont(ofSize: 16, weight: .semibold) + return label + }() + + lazy var groupManagerView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexStr: "#F5FBFF") + view.cornerRadius = 10 + return view + }() + + lazy var groupManagerStackView: UIStackView = { + let view = UIStackView(arrangedSubviews: [inviteView, auditMemberView, removeMemberView, dismissGroupView, leaveGroupView]) + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = 0 + view.backgroundColor = .clear + return view + }() + + // 邀请成员 + lazy var inviteView: UIView = { + let view = UIView() + view.backgroundColor = .clear + + let titleLab = UILabel() + titleLab.text = "邀请成员" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .width(50) + .top(20) + + let icon = UIImageView(image: UIImage(named: "Group/arrow")) + view.addSubview(icon) + icon.layoutChain + .right(15) + .width(14) + .height(14) + .centerY() + + let line = UIView() + line.backgroundColor = ThemeManager.shared.color.lineColor + view.addSubview(line) + line.layoutChain + .bottom() + .height(0.5) + .edgesHorzontal(15) + + return view + }() + + // 审核成员 + lazy var auditMemberView = { + let view = UIView() + view.backgroundColor = .clear + view.isHidden = true + let titleLab = UILabel() + titleLab.text = "审核成员" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .width(50) + .top(20) + + let icon = UIImageView(image: UIImage(named: "Group/arrow")) + view.addSubview(icon) + icon.layoutChain + .right(15) + .width(14) + .height(14) + .centerY() + + let line = UIView() + line.backgroundColor = ThemeManager.shared.color.lineColor + view.addSubview(line) + line.layoutChain + .bottom() + .height(0.5) + .edgesHorzontal(15) + + return view + }() + + // 移除成员 + lazy var removeMemberView = { + let view = UIView() + view.backgroundColor = .clear + view.isHidden = true + let titleLab = UILabel() + titleLab.text = "移除成员" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .width(50) + .top(20) + + let icon = UIImageView(image: UIImage(named: "Group/arrow")) + view.addSubview(icon) + icon.layoutChain + .right(15) + .width(14) + .height(14) + .centerY() + + let line = UIView() + line.backgroundColor = ThemeManager.shared.color.lineColor + view.addSubview(line) + line.layoutChain + .bottom() + .height(0.5) + .edgesHorzontal(15) + + return view + }() + + // 解散圈子 + lazy var dismissGroupView = { + let view = UIView() + view.backgroundColor = .clear + view.isHidden = true + let titleLab = UILabel() + titleLab.text = "解散圈子" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .width(50) + .top(20) + + let icon = UIImageView(image: UIImage(named: "Group/arrow")) + view.addSubview(icon) + icon.layoutChain + .right(15) + .width(14) + .height(14) + .centerY() + + let line = UIView() + line.backgroundColor = ThemeManager.shared.color.lineColor + view.addSubview(line) + line.layoutChain + .bottom() + .height(0.5) + .edgesHorzontal(15) + + return view + }() + + // 退出圈子 + lazy var leaveGroupView = { + let view = UIView() + view.backgroundColor = .clear + view.isHidden = true + let titleLab = UILabel() + titleLab.text = "退出圈子" + titleLab.textColor = ThemeManager.shared.color.titleAuxColor + titleLab.font = .systemFont(ofSize: 12, weight: .medium) + view.addSubview(titleLab) + titleLab.layoutChain + .left(15) + .width(50) + .top(20) + + let icon = UIImageView(image: UIImage(named: "Group/arrow")) + view.addSubview(icon) + icon.layoutChain + .right(15) + .width(14) + .height(14) + .centerY() + + let line = UIView() + line.backgroundColor = ThemeManager.shared.color.lineColor + view.addSubview(line) + line.layoutChain + .bottom() + .height(0.5) + .edgesHorzontal(15) + + return view + }() + + override init(frame: CGRect) { + super.init(frame: .zero) + backgroundColor = .white + setupUI() + setupRx() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/QuickLocation/Section/Group/GroupSetting/GroupSettingViewModel.swift b/QuickLocation/Section/Group/GroupSetting/GroupSettingViewModel.swift new file mode 100644 index 0000000..689b66c --- /dev/null +++ b/QuickLocation/Section/Group/GroupSetting/GroupSettingViewModel.swift @@ -0,0 +1,19 @@ +// +// GroupSettingViewModel.swift +// QuickLocation +// +// Created by 八条 on 2026/6/9. +// + +import Foundation + +struct GroupSettingViewModel { + + let groupId: String + + var groupModel: GroupInfoModel? + + init(groupId: String) { + self.groupId = groupId + } +} diff --git a/QuickLocation/Section/Group/GroupSetting/GroupTagListView.swift b/QuickLocation/Section/Group/GroupSetting/GroupTagListView.swift new file mode 100644 index 0000000..11b2bd2 --- /dev/null +++ b/QuickLocation/Section/Group/GroupSetting/GroupTagListView.swift @@ -0,0 +1,303 @@ +// +// GroupTagListView.swift +// QuickLocation +// +// Created by 八条 on 2026/6/9. +// + +import UIKit +import RxSwift +import RxCocoa + +class GroupTagListView: UIView { + + private static let shared = GroupTagListView(frame: CGRect(origin: .zero, size: kScreenSize)) + + var disposeBag = DisposeBag() + + private let tagList = ["私密", "聚会", "运动", "美食", + "旅行", "学习", "自驾", "游戏"] + + private var selectedTagList: [String] = [] { + didSet { + tableView.reloadData() + } + } + + /// 完成选中进行回调 + private var completion: (([String]?) -> Void)? + + @objc func tap() { + completion?(nil) + } + + private lazy var bgView: UIView = { + let view = UIView() + view.backgroundColor = .black.withAlphaComponent(0.5) + view.clipsToBounds = true + return view + }() + + lazy var infoView: UIView = { + let view = UIView() + view.backgroundColor = .white + + let label = UILabel() + label.text = "圈子标签" + label.textColor = .black + label.font = .systemFont(ofSize: 16, weight: .medium) + view.addSubview(label) + label.layoutChain + .top(15) + .left(20) + + view.addSubview(tableView) + view.addSubview(cancelBtn) + view.addSubview(confirmBtn) + + cancelBtn.layoutChain + .left(43) + .bottom(kSafeBottomMargin + 24) + .height(40) + + confirmBtn.layoutChain + .leftToRightOfView(cancelBtn, offset: 13) + .right(43) + .bottomToView(cancelBtn) + .heightToView(cancelBtn) + .widthToView(cancelBtn) + + tableView.layoutChain + .topToBottomOfView(label, offset: 5) + .edgesHorzontal() + .bottomToTopOfView(cancelBtn, offset: -15) + + return view + }() + + lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.backgroundColor = .white + tableView.separatorStyle = .none + tableView.estimatedRowHeight = 38 + tableView.showsVerticalScrollIndicator = false + tableView.bounces = false + tableView.isScrollEnabled = false + tableView.register(TagTextCell.self) + tableView.dataSource = self + tableView.delegate = self + return tableView + }() + + lazy var confirmBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("确定", for: .normal) + btn.setTitleColor(.black, for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + btn.setBackgroundImage(UIImage(named: "Common/gradient_bg"), for: .normal) + btn.cornerRadius = 20 + + btn.rx.tap.subscribe(onNext: { _ in + if let completion = self.completion { + completion(self.selectedTagList) + } + }).disposed(by: disposeBag) + + return btn + }() + + lazy var cancelBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("取消", for: .normal) + btn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + btn.backgroundColor = .white + btn.borderWidth = 1 + btn.borderColor = UIColor(hexStr: "#16B3FF") + btn.cornerRadius = 20 + + btn.rx.tap.subscribe(onNext: { _ in + GroupTagListView.dismiss() + }).disposed(by: disposeBag) + + return btn + }() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + addSubview(bgView) + bgView.addSubview(infoView) + infoView.addSubview(tableView) + infoView.addSubview(confirmBtn) + infoView.addSubview(cancelBtn) + + bgView.layoutChain.edges() + + let tap = UITapGestureRecognizer(target: self, action: #selector(tap)) + tap.delegate = self + addGestureRecognizer(tap) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + infoView.setNeedsLayout() + infoView.layoutIfNeeded() + infoView.setCornerRadius(corners: [.topLeft, .topRight], withCornerRadii: CGSize(width: 20, height: 20)) + } + +} + +// MARK: - Public +extension GroupTagListView { + + /// 显示选择弹窗 + /// - Parameters: + /// - start: 显示起始点 + static func show(selectedTagList: [String], + completion: @escaping (([String]?) -> Void)) { + guard let superView = kKeyWindow else { + return + } + + if GroupTagListView.shared.superview != nil { + GroupTagListView.shared.removeFromSuperview() + GroupTagListView.shared.bgView.frame = .zero + } + GroupTagListView.shared.selectedTagList = selectedTagList + GroupTagListView.shared.bgView.alpha = 1 + + superView.addSubview(GroupTagListView.shared) + superView.bringSubviewToFront(GroupTagListView.shared) + + let viewHeight = kSafeBottomMargin + 24 + 40 + 40 * 8 + 40 + GroupTagListView.shared.infoView.frame = CGRect(x: 0, y: kScreenHeight, width: kScreenWidth, height: viewHeight) + GroupTagListView.shared.infoView.alpha = 0 + GroupTagListView.shared.completion = { tagList in + completion(tagList) + GroupTagListView.dismiss() + } + UIView.animate(withDuration: 0.25) { + GroupTagListView.shared.infoView.alpha = 1 + GroupTagListView.shared.infoView.frame = CGRect(x: 0, y: kScreenHeight - viewHeight, width: kScreenWidth, height: viewHeight) + } + } + + /// 关闭 + static func dismiss() { + guard GroupTagListView.shared.superview != nil else { return } + let viewHeight = kSafeBottomMargin + 24 + 40 + 40 * 8 + 40 + UIView.animate(withDuration: 0.15) { + GroupTagListView.shared.infoView.alpha = 0 + GroupTagListView.shared.infoView.frame = CGRect(x: 0, y: kScreenHeight, width: kScreenWidth, height: viewHeight) + } + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn]) { + GroupTagListView.shared.bgView.alpha = 0 + } completion: { _ in + GroupTagListView.shared.removeFromSuperview() + GroupTagListView.shared.infoView.frame = .zero + } + } +} + +// MARK: - UIGestureRecognizerDelegate +extension GroupTagListView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if let view = touch.view, !(view == self || view == bgView) { + return false + } + return true + } +} + +// MARK: - UITableViewDataSource & UITableViewDelegate +extension GroupTagListView: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + tagList.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: TagTextCell = tableView.dequeueReusableCell(for: indexPath) + let tagText = tagList[indexPath.row] + cell.configure(text: tagText, isSelected: selectedTagList.contains(tagText)) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let tagText = tagList[indexPath.row] + if let idx = selectedTagList.firstIndex(of: tagText) { + selectedTagList.remove(at: idx) + } else { + selectedTagList.append(tagText) + } + } +} + +// MARK: - TagTextCell +class TagTextCell: UITableViewCell { + + func configure(text: String, isSelected: Bool) { + titleLab.text = text + titleLab.textColor = isSelected ? UIColor(hexStr: "#16B3FF") : .black + checkBoxBtn.isSelected = isSelected + } + + override init(style: CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + setupSubviews() + } + + private func setupSubviews() { + contentView.addSubview(titleLab) + contentView.addSubview(checkBoxBtn) + + checkBoxBtn.layoutChain + .centerY() + .right(25) + .width(24) + .heightToWidth(1.0) + + titleLab.layoutChain + .edgesVertical(10) + .left(20) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func awakeFromNib() { + super.awakeFromNib() + // Initialization code + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + // Configure the view for the selected state + } + + lazy var titleLab: UILabel = { + let label = UILabel() + label.textColor = .black + label.font = .systemFont(ofSize: 15, weight: .medium) + return label + }() + + lazy var checkBoxBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setBackgroundImage(UIImage(named: "Login/checkbox"), for: .normal) + btn.setBackgroundImage(UIImage(named: "Login/selected"), for: .selected) + btn.isUserInteractionEnabled = false + return btn + }() +} diff --git a/QuickLocation/Section/TextInput/TextInputViewController.swift b/QuickLocation/Section/TextInput/TextInputViewController.swift new file mode 100644 index 0000000..bcc1f40 --- /dev/null +++ b/QuickLocation/Section/TextInput/TextInputViewController.swift @@ -0,0 +1,241 @@ +// +// TextInputViewController.swift +// QuickLocation +// +// Created by 八条 on 2026/6/9. +// + +import UIKit +import RxSwift +import RxCocoa + +/// 通用文本输入页面 +/// 用法: +/// let vc = TextInputViewController(title: "编辑昵称", maxLength: 20) { text in +/// print("用户输入: \(text)") +/// } +/// present(vc, animated: true) +final class TextInputViewController: UIViewController { + + private let titleText: String + private let maxLength: Int + private let confirmAction: ((String) -> Void)? + + private let disposeBag = DisposeBag() + private let textRelay = BehaviorRelay(value: "") + + // MARK: - Init + + /// - Parameters: + /// - title: 页面标题 + /// - maxLength: 文字输入上限(0 表示不限制) + /// - initialText: 初始文本,默认空 + /// - confirmAction: 确定回调 + init(title: String, + maxLength: Int = 0, + initialText: String = "", + confirmAction: ((String) -> Void)? = nil) { + self.titleText = title + self.maxLength = maxLength + self.confirmAction = confirmAction + self.textRelay.accept(initialText) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + modalTransitionStyle = .coverVertical + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + setupUI() + setupBinding() + + navTitleLabel.text = titleText + textView.becomeFirstResponder() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + } + + // MARK: - UI + + private func setupUI() { + view.addSubview(navBgView) + view.addSubview(navBarView) + navBarView.addSubview(navTitleLabel) + navBarView.addSubview(backBtn) + + view.addSubview(inputTextView) + inputTextView.addSubview(textView) + view.addSubview(countLabel) + view.addSubview(confirmBtn) + + navBgView.layoutChain + .edges(excludingEdge: .bottom) + .heightToWidth(160/375) + + navBarView.layoutChain + .edges(excludingEdge: .bottom) + .height(kNaviHeight) + + navTitleLabel.layoutChain + .top(kStatusBarHeight + 12) + .centerY(backBtn) + .centerX() + + backBtn.layoutChain + .centerY(navTitleLabel) + .left(15) + .width(24) + .height(24) + + inputTextView.layoutChain + .topToBottomOfView(navBarView, offset: 15) + .edgesHorzontal(15) + + // 输入框 + textView.layoutChain + .edgesVertical(5) + .edgesHorzontal(10) + + countLabel.layoutChain + .topToBottomOfView(inputTextView, offset: 5) + .rightToView(textView) + + confirmBtn.layoutChain + .topToBottomOfView(inputTextView, offset: 50) + .edgesHorzontal(15).height(50) + } + + // MARK: - Binding + private func setupBinding() { + // 输入流 + Observable.merge( + textView.rx.didChange.asObservable(), + textView.rx.text.map { _ in () }, + textView.rx.methodInvoked(#selector(UITextView.paste(_:))).map { _ in () } + ) + .throttle(.milliseconds(100), scheduler: MainScheduler.instance) + .subscribe(onNext: { [weak self] in + guard let self = self else { return } + + if self.textView.text.last == "\n" { + self.textView.text = String(self.textView.text.dropLast()) + self.textView.resignFirstResponder() + return + } + + let count = self.textView.text.count + + if count > self.maxLength { + self.textView.text = String(self.textView.text.prefix(self.maxLength)) + self.textView.selectedRange = NSRange(location: self.maxLength, length: 0) + return + } + self.countLabel.text = "\(count)/\(self.maxLength)" + }) + .disposed(by: disposeBag) + + textRelay.asObservable() + .bind(to: textView.rx.text) + .disposed(by: disposeBag) + + // 确定 + textView.rx.text.orEmpty.map { text in + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.isEmpty + } + .bind(to: confirmBtn.rx.isEnabled) + .disposed(by: disposeBag) + + confirmBtn.rx.tap.subscribe(onNext: { [weak self] in + guard let self = self, let text = self.textView.text else { return } + self.confirmAction?(text) + self.dismiss(animated: true) + }) + .disposed(by: disposeBag) + + // 关闭 + backBtn.rx.tap + .subscribe(onNext: { [weak self] _ in + self?.dismiss(animated: true) + }) + .disposed(by: disposeBag) + } + + // MARK: - Views + lazy var navBgView: UIImageView = { + let iv = UIImageView() + iv.image = UIImage(named: "Common/navBar_bg_2") + iv.contentMode = .scaleAspectFill + return iv + }() + + lazy var navBarView: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + lazy var navTitleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 18, weight: .medium) + label.textColor = ThemeManager.shared.color.titleAuxColor + label.textAlignment = .center + return label + }() + + lazy var backBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setImage(UIImage(named: "Common/back"), for: .normal) + btn.extendEdgeInsets = UIEdgeInsets(top: 54, left: 15, bottom: 100, right: 100) + return btn + }() + + lazy var inputTextView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.cornerRadius = 4 + view.borderWidth = 0.5 + view.borderColor = ThemeManager.shared.color.lineColor + return view + }() + + lazy var textView: UITextView = { + let tv = UITextView() + tv.font = .systemFont(ofSize: 15) + tv.textColor = ThemeManager.shared.color.titleAuxColor + tv.backgroundColor = .clear + tv.showsVerticalScrollIndicator = false + tv.isScrollEnabled = false + tv.bounces = false + tv.returnKeyType = .done + return tv + }() + + private lazy var countLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13) + label.textColor = UIColor(hexStr: "#999999") + label.text = maxLength > 0 ? "0/\(maxLength)" : "" + return label + }() + + private lazy var confirmBtn: UIButton = { + let btn = UIButton(type: .custom) + btn.setTitle("确定", for: .normal) + btn.setTitleColor(UIColor(hexStr: "#0F2846"), for: .normal) + btn.setBackgroundImage(UIImage(named: "Common/gradient_bg"), for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + btn.cornerRadius = 25 + btn.isEnabled = false + return btn + }() +} diff --git a/QuickLocation/Service/GroupService.swift b/QuickLocation/Service/GroupService.swift index ba74098..01a1019 100644 --- a/QuickLocation/Service/GroupService.swift +++ b/QuickLocation/Service/GroupService.swift @@ -45,4 +45,54 @@ struct GroupService { .map(GroupInfoResponse.self) .asObservable() } + + /// 修改名字 + /// - Parameters: + /// - requestData:group_key group_name + static func editName(requestData: [String: Any]) -> Observable { + let api = GroupAPI.operate(opType: "changename", requestData: requestData).multiTarget + return APIProvider.request(token: api) + .map(GroupInfoResponse.self) + .asObservable() + } + + /// 更换图标 + /// - Parameters: + /// - requestData:group_key icon_index + static func changeIcon(requestData: [String: Any]) -> Observable { + let api = GroupAPI.operate(opType: "changeicon", requestData: requestData).multiTarget + return APIProvider.request(token: api) + .map(GroupInfoResponse.self) + .asObservable() + } + + /// 修改描述 + /// - Parameters: + /// - requestData:group_key description + static func editDesc(requestData: [String: Any]) -> Observable { + let api = GroupAPI.operate(opType: "changedescription", requestData: requestData).multiTarget + return APIProvider.request(token: api) + .map(GroupInfoResponse.self) + .asObservable() + } + + /// 审核开关 + /// - Parameters: + /// - requestData:group_key review + static func changeReview(requestData: [String: Any]) -> Observable { + let api = GroupAPI.operate(opType: "changereview", requestData: requestData).multiTarget + return APIProvider.request(token: api) + .map(GroupInfoResponse.self) + .asObservable() + } + + /// 更改标签 + /// - Parameters: + /// - requestData:group_key labels + static func editLabels(requestData: [String: Any]) -> Observable { + let api = GroupAPI.operate(opType: "changelabels", requestData: requestData).multiTarget + return APIProvider.request(token: api) + .map(GroupInfoResponse.self) + .asObservable() + } } diff --git a/QuickLocation/zh-Hans.lproj/LaunchScreen.storyboard b/QuickLocation/zh-Hans.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..65dc86f --- /dev/null +++ b/QuickLocation/zh-Hans.lproj/LaunchScreen.storyboard @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/QuickLocation/zh-Hans.lproj/Main.storyboard b/QuickLocation/zh-Hans.lproj/Main.storyboard new file mode 100644 index 0000000..25a7638 --- /dev/null +++ b/QuickLocation/zh-Hans.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + +