// // BreedPickerViewController.swift // VenusKitto // // Created by Neoa on 2025/8/22. // import Foundation import UIKit // MARK: - BreedPickerViewController (选择品种) final class BreedPickerViewController: UIViewController, UITextFieldDelegate { struct Breed { let id: String; let categoryId: String; let name: String; let isHot: String } private let categoryId: Int private let token: String? var onSelect: ((String, String) -> Void)? // UI private let scrollView = UIScrollView() private let content = UIStackView() private let searchContainer = UIView() private let searchIcon = UIImageView(image: UIImage(systemName: "magnifyingglass")) private let searchField = UITextField() private let searchBtn = UIButton(type: .system) init(categoryId: Int, token: String?) { self.categoryId = categoryId self.token = token super.init(nibName: nil, bundle: nil) title = "选择品种" } required init?(coder: NSCoder) { fatalError() } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white buildUI() loadBreeds(keyword: "") } private func buildUI() { // 搜索条 searchContainer.backgroundColor = UIColor(hex: "#F7F7F7") searchContainer.layer.cornerRadius = 22 searchContainer.layer.borderWidth = 1 searchContainer.layer.borderColor = UIColor(hex: "#E6E6E6").cgColor searchIcon.tintColor = UIColor(hex: "#B0B0B0") searchIcon.contentMode = .scaleAspectFit searchField.placeholder = "搜索" searchField.font = .systemFont(ofSize: 14) searchField.delegate = self searchField.returnKeyType = .search searchBtn.setTitle("搜索", for: .normal) searchBtn.setTitleColor(.black, for: .normal) searchBtn.backgroundColor = UIColor(hex: "#FFE059") searchBtn.layer.cornerRadius = 18 searchBtn.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) searchBtn.addTarget(self, action: #selector(tapSearch), for: .touchUpInside) view.addSubview(searchContainer) [searchIcon, searchField, searchBtn].forEach { searchContainer.addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false } searchContainer.translatesAutoresizingMaskIntoConstraints = false // 列表 scrollView.showsVerticalScrollIndicator = false view.addSubview(scrollView) scrollView.translatesAutoresizingMaskIntoConstraints = false content.axis = .vertical content.spacing = 16 content.alignment = .fill scrollView.addSubview(content) content.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ searchContainer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), searchContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), searchContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), searchContainer.heightAnchor.constraint(equalToConstant: 44), searchIcon.leadingAnchor.constraint(equalTo: searchContainer.leadingAnchor, constant: 12), searchIcon.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor), searchIcon.widthAnchor.constraint(equalToConstant: 18), searchIcon.heightAnchor.constraint(equalToConstant: 18), searchBtn.trailingAnchor.constraint(equalTo: searchContainer.trailingAnchor, constant: -8), searchBtn.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor), searchBtn.widthAnchor.constraint(equalToConstant: 66), searchBtn.heightAnchor.constraint(equalToConstant: 36), searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8), searchField.trailingAnchor.constraint(equalTo: searchBtn.leadingAnchor, constant: -8), searchField.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor), scrollView.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 12), scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), content.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 8), content.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16), content.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16), content.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24), content.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32) ]) } @objc private func tapSearch() { loadBreeds(keyword: searchField.text ?? "") } func textFieldShouldReturn(_ textField: UITextField) -> Bool { tapSearch(); return true } // MARK: - Networking private func loadBreeds(keyword: String) { // 清空旧内容 content.arrangedSubviews.forEach { $0.removeFromSuperview() } var comps = URLComponents(string: "\(baseURL)/petRecordBreed/queryBreed")! comps.queryItems = [ URLQueryItem(name: "categoryId", value: String(categoryId)), URLQueryItem(name: "keyword", value: keyword) ] guard let url = comps.url else { return } var req = URLRequest(url: url) req.httpMethod = "GET" // Optional: tell server we expect JSON req.setValue("application/json", forHTTPHeaderField: "Accept") if let token = token, !token.isEmpty { req.setValue(token, forHTTPHeaderField: "Authorization") } let loading = makeLoading() content.addArrangedSubview(loading) URLSession.shared.dataTask(with: req) { [weak self] data, resp, error in DispatchQueue.main.async { loading.removeFromSuperview() } guard let self = self else { return } if let error = error { DispatchQueue.main.async { self.showError(error.localizedDescription) } return } guard let data = data, let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { DispatchQueue.main.async { self.showError("无效的响应") } return } let codeAny = obj["code"] let isOK: Bool if let c = codeAny as? Int { isOK = (c == 200) } else if let s = codeAny as? String { isOK = (s == "200") } else { isOK = false } guard isOK, let arr = obj["data"] as? [[String: Any]] else { DispatchQueue.main.async { self.showError(obj["msg"] as? String ?? "请求失败") } return } let breeds = arr.compactMap { d -> Breed? in guard let id = d["id"] as? String, let cid = d["categoryId"] as? String, let name = d["name"] as? String, let hot = d["isHot"] as? String else { return nil } return Breed(id: id, categoryId: cid, name: name, isHot: hot) } self.render(breeds: breeds) }.resume() } private func render(breeds: [Breed]) { DispatchQueue.main.async { self.content.arrangedSubviews.forEach { $0.removeFromSuperview() } // 热门 let hot = breeds.filter { $0.isHot == "1" } if !hot.isEmpty { self.content.addArrangedSubview(self.makeSectionTitle("热门品种")) self.content.addArrangedSubview(self.makeChipsGrid(hot)) } // 其他分组(按拼音首字母) let others = breeds.filter { $0.isHot != "1" } let grouped = Dictionary(grouping: others) { self.initial(of: $0.name) } let keys = grouped.keys.sorted() for k in keys { self.content.addArrangedSubview(self.makeLetterTitle(k)) self.content.addArrangedSubview(self.makeChipsGrid(grouped[k] ?? [])) } } } private func initial(of name: String) -> String { var s = name as NSString let mutable = NSMutableString(string: s) CFStringTransform(mutable, nil, kCFStringTransformToLatin, false) CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false) let up = (mutable as String).uppercased() let ch = up.first ?? "#" if ch >= "A" && ch <= "Z" { return String(ch) } else { return "#" } } private func makeSectionTitle(_ text: String) -> UIView { let l = UILabel() l.text = text l.font = .systemFont(ofSize: 14, weight: .semibold) l.textColor = UIColor(hex: "#2B2B2B") return l } private func makeLetterTitle(_ letter: String) -> UIView { let l = UILabel() l.text = letter l.font = .systemFont(ofSize: 14, weight: .semibold) l.textColor = UIColor(hex: "#2B2B2B") l.translatesAutoresizingMaskIntoConstraints = false return l } private func makeChipsGrid(_ items: [Breed]) -> UIView { let wrap = UIStackView() wrap.axis = .vertical wrap.spacing = 12 func makeRow() -> UIStackView { let r = UIStackView() r.axis = .horizontal r.alignment = .leading r.distribution = .fill r.spacing = 12 return r } var row = makeRow() wrap.addArrangedSubview(row) // content width available (16 left & right padding) let availableWidth = view.bounds.width - 32 let maxInRow = 4 let spacing: CGFloat = 12 var usedWidth: CGFloat = 0 var countInRow = 0 for b in items { let chip = BreedChip(title: b.name) let chipWidth = chip.estimatedWidth() // If the row is full or it doesn't fit, start a new row let needWrap = (countInRow >= maxInRow) || (countInRow > 0 && usedWidth + spacing + chipWidth > availableWidth) if needWrap { row = makeRow() wrap.addArrangedSubview(row) usedWidth = 0 countInRow = 0 } row.addArrangedSubview(chip) usedWidth += (countInRow == 0 ? 0 : spacing) + chipWidth countInRow += 1 // Tap action chip.onTap = { [weak self] in self?.onSelect?(b.id, b.name) } } return wrap } private func makeLoading() -> UIView { let l = UIActivityIndicatorView(style: .medium) l.startAnimating() return l } private func showError(_ msg: String) { let lb = UILabel() lb.text = msg lb.textAlignment = .center lb.textColor = UIColor(hex: "#A8A8A8") content.addArrangedSubview(lb) } } final class BreedChip: UIView { private let button = UIButton(type: .system) var onTap: (() -> Void)? init(title: String) { super.init(frame: .zero) button.setTitle(title, for: .normal) button.setTitleColor(UIColor(hex: "#5B5B5B"), for: .normal) button.titleLabel?.font = .systemFont(ofSize: 13) button.backgroundColor = UIColor(hex: "#EFECEA") button.layer.cornerRadius = 18 button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) // 让 chip 保持自身宽度,不被横向拉伸 setContentHuggingPriority(.required, for: .horizontal) setContentCompressionResistancePriority(.required, for: .horizontal) button.setContentHuggingPriority(.required, for: .horizontal) button.setContentCompressionResistancePriority(.required, for: .horizontal) button.titleLabel?.lineBreakMode = .byTruncatingTail button.addTarget(self, action: #selector(tap), for: .touchUpInside) addSubview(button) button.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ button.topAnchor.constraint(equalTo: topAnchor), button.bottomAnchor.constraint(equalTo: bottomAnchor), button.leadingAnchor.constraint(equalTo: leadingAnchor), button.trailingAnchor.constraint(equalTo: trailingAnchor) ]) } required init?(coder: NSCoder) { fatalError() } @objc private func tap() { onTap?() } func estimatedWidth() -> CGFloat { let target = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 36) let size = button.sizeThatFits(target) return size.width } }