| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961 |
- //
- // AddPetViewController.swift
- // VenusKitto
- //
- // Created by Neoa on 2025/8/22.
- //
- import Foundation
- import UIKit
- // MARK: - AddPetViewController (添加宠物)
- final class AddPetViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
- private let scrollView = UIScrollView()
- private let contentView = UIView()
- private let avatarButton = UIButton(type: .custom)
- private let avatarHint = UILabel()
- private let nickCard = UIView()
- private let nickTitle = UILabel()
- private let nickField = UITextField()
- private let infoCard = UIView()
- // rows
- private let weightRow = InputRow(title: "体重", placeholder: "请输入体重(kg)", showsChevron: false, keyboard: .decimalPad)
- private let speciesRow = SelectRow(title: "种类")
- private let breedRow = SelectRow(title: "品种")
- private let genderRow = SelectRow(title: "性别")
- private let birthRow = SelectRow(title: "出生日期")
- private let arriveRow = SelectRow(title: "到家日期")
- private let cancelBtn = UIButton(type: .system)
- private let confirmBtn = UIButton(type: .system)
- private var selectedSpecies: String?
- private var selectedBreed: String? // For display (breed name)
- private var selectedBreedId: String? // For storing breedId
- private var selectedGender: String?
- private var birthDate: Date?
- private var arriveDate: Date?
- // For avatar upload
- private var avatarImageUrl: String?
- override func viewDidLoad() {
- super.viewDidLoad()
- view.backgroundColor = .white
- title = "添加宠物"
- // Set custom back button image without text or back label
- if let backImage = UIImage(named: "AddPet385") {
- let backButton = UIBarButtonItem(image: backImage.withRenderingMode(.alwaysOriginal), style: .plain, target: self, action: #selector(tapCancel))
- navigationItem.leftBarButtonItem = backButton
- }
- layout()
- wire()
- }
- private func layout() {
- // scroll
- view.addSubview(scrollView)
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.showsVerticalScrollIndicator = false
- scrollView.keyboardDismissMode = .onDrag
- scrollView.addSubview(contentView)
- contentView.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
- scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
- contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
- contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
- contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
- contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
- ])
- // avatar
- avatarButton.translatesAutoresizingMaskIntoConstraints = false
- avatarButton.setImage(UIImage(named: "Home372") ?? UIImage(systemName: "pawprint.circle")!, for: .normal)
- avatarButton.imageView?.contentMode = .scaleAspectFill
- avatarButton.layer.cornerRadius = 40
- avatarButton.clipsToBounds = true
- avatarButton.backgroundColor = UIColor(hex: "#FFF3D9")
- avatarHint.text = "点击上传头像"
- avatarHint.font = .systemFont(ofSize: 12)
- avatarHint.textColor = UIColor(hex: "#8B8B8B")
- avatarHint.textAlignment = .center
- avatarHint.translatesAutoresizingMaskIntoConstraints = false
- contentView.addSubview(avatarButton)
- contentView.addSubview(avatarHint)
- NSLayoutConstraint.activate([
- avatarButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
- avatarButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
- avatarButton.widthAnchor.constraint(equalToConstant: 80),
- avatarButton.heightAnchor.constraint(equalToConstant: 80),
- avatarHint.topAnchor.constraint(equalTo: avatarButton.bottomAnchor, constant: 6),
- avatarHint.centerXAnchor.constraint(equalTo: avatarButton.centerXAnchor)
- ])
- // Nick card
- nickCard.backgroundColor = .white
- nickCard.layer.cornerRadius = 12
- nickCard.layer.shadowColor = UIColor.black.cgColor
- nickCard.layer.shadowOpacity = 0.06
- nickCard.layer.shadowRadius = 6
- nickCard.layer.shadowOffset = .init(width: 0, height: 2)
- nickCard.translatesAutoresizingMaskIntoConstraints = false
- nickTitle.text = "昵称"
- nickTitle.font = .systemFont(ofSize: 14)
- nickTitle.textColor = UIColor(hex: "#5B5B5B")
- nickField.placeholder = "请输入昵称"
- nickField.font = .systemFont(ofSize: 14)
- nickField.textAlignment = .right
- nickField.clearButtonMode = .whileEditing
- let nickSeparator = DashedSeparator()
- [nickTitle, nickField].forEach { nickCard.addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false }
- contentView.addSubview(nickCard)
- NSLayoutConstraint.activate([
- nickCard.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 120),
- nickCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
- nickCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
- nickCard.heightAnchor.constraint(equalToConstant: 64),
- nickTitle.centerYAnchor.constraint(equalTo: nickCard.centerYAnchor),
- nickTitle.leadingAnchor.constraint(equalTo: nickCard.leadingAnchor, constant: 16),
- nickField.centerYAnchor.constraint(equalTo: nickCard.centerYAnchor),
- nickField.trailingAnchor.constraint(equalTo: nickCard.trailingAnchor, constant: -24),
- nickField.leadingAnchor.constraint(greaterThanOrEqualTo: nickTitle.trailingAnchor, constant: 12)
- ])
- // Info card
- infoCard.backgroundColor = .white
- infoCard.layer.cornerRadius = 12
- infoCard.layer.shadowColor = UIColor.black.cgColor
- infoCard.layer.shadowOpacity = 0.06
- infoCard.layer.shadowRadius = 6
- infoCard.layer.shadowOffset = .init(width: 0, height: 2)
- infoCard.translatesAutoresizingMaskIntoConstraints = false
- contentView.addSubview(infoCard)
- let rows: [UIView] = [weightRow, speciesRow, breedRow, genderRow, birthRow, arriveRow]
- rows.forEach { r in
- r.translatesAutoresizingMaskIntoConstraints = false
- infoCard.addSubview(r)
- }
- // separators
- let seps = (0..<(rows.count-1)).map { _ in DashedSeparator() }
- seps.forEach { s in infoCard.addSubview(s); s.translatesAutoresizingMaskIntoConstraints = false }
- NSLayoutConstraint.activate([
- infoCard.topAnchor.constraint(equalTo: nickCard.bottomAnchor, constant: 16),
- infoCard.leadingAnchor.constraint(equalTo: nickCard.leadingAnchor),
- infoCard.trailingAnchor.constraint(equalTo: nickCard.trailingAnchor)
- ])
- // Row heights
- let h: CGFloat = 52
- var prev: UIView? = nil
- for (idx, row) in rows.enumerated() {
- NSLayoutConstraint.activate([
- row.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 12),
- row.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -12),
- row.heightAnchor.constraint(equalToConstant: h)
- ])
- if let p = prev {
- row.topAnchor.constraint(equalTo: p.bottomAnchor).isActive = true
- } else {
- row.topAnchor.constraint(equalTo: infoCard.topAnchor).isActive = true
- }
- if idx < seps.count {
- let sep = seps[idx]
- NSLayoutConstraint.activate([
- sep.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 12),
- sep.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -12),
- sep.topAnchor.constraint(equalTo: row.bottomAnchor),
- sep.heightAnchor.constraint(equalToConstant: 1)
- ])
- }
- prev = row
- }
- prev!.bottomAnchor.constraint(equalTo: infoCard.bottomAnchor).isActive = true
- // Bottom buttons
- cancelBtn.setTitle("取消", for: .normal)
- cancelBtn.setTitleColor(.white, for: .normal)
- cancelBtn.backgroundColor = UIColor(hex: "#CFC7BD")
- cancelBtn.layer.cornerRadius = 22
- confirmBtn.setTitle("确定", for: .normal)
- confirmBtn.setTitleColor(.black, for: .normal)
- confirmBtn.backgroundColor = UIColor(hex: "#FFE059")
- confirmBtn.layer.cornerRadius = 22
- [cancelBtn, confirmBtn].forEach { v in
- contentView.addSubview(v)
- v.translatesAutoresizingMaskIntoConstraints = false
- v.heightAnchor.constraint(equalToConstant: 44).isActive = true
- }
- NSLayoutConstraint.activate([
- cancelBtn.topAnchor.constraint(equalTo: infoCard.bottomAnchor, constant: 28),
- cancelBtn.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor),
- cancelBtn.trailingAnchor.constraint(equalTo: view.centerXAnchor, constant: -8),
- confirmBtn.topAnchor.constraint(equalTo: cancelBtn.topAnchor),
- confirmBtn.leadingAnchor.constraint(equalTo: view.centerXAnchor, constant: 8),
- confirmBtn.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor),
- confirmBtn.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -28)
- ])
- }
- private func wire() {
- let tap = UITapGestureRecognizer(target: self, action: #selector(endEdit))
- tap.cancelsTouchesInView = false
- view.addGestureRecognizer(tap)
- avatarButton.addTarget(self, action: #selector(pickAvatar), for: .touchUpInside)
- cancelBtn.addTarget(self, action: #selector(tapCancel), for: .touchUpInside)
- confirmBtn.addTarget(self, action: #selector(tapConfirm), for: .touchUpInside)
- speciesRow.onTap = { [weak self] in self?.chooseSpecies() }
- breedRow.onTap = { [weak self] in self?.chooseBreed() }
- genderRow.onTap = { [weak self] in self?.chooseGender() }
- birthRow.onTap = { [weak self] in self?.chooseDate(for: .birth) }
- arriveRow.onTap = { [weak self] in self?.chooseDate(for: .arrive) }
- }
- @objc private func endEdit() { view.endEditing(true) }
- // MARK: - Actions
- // Upload avatar image
- @objc private func pickAvatar() {
- let picker = UIImagePickerController()
- picker.sourceType = .photoLibrary
- picker.delegate = self
- present(picker, animated: true)
- }
- func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
- if let img = info[.originalImage] as? UIImage {
- avatarButton.setImage(img, for: .normal)
- uploadAvatar(image: img) // Upload avatar after picking
- }
- picker.dismiss(animated: true)
- }
- private func uploadAvatar(image: UIImage) {
- guard let imageData = image.jpegData(compressionQuality: 0.7) else { return }
- let url = URL(string: "\(baseURL)/common/upload")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- if let token = UserDefaults.standard.string(forKey: "userToken") {
- request.setValue(token, forHTTPHeaderField: "Authorization")
- }
- // 创建多部分表单数据
- let boundary = "Boundary-\(UUID().uuidString)"
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
-
- var body = Data()
-
- // 添加图片数据
- body.append("--\(boundary)\r\n".data(using: .utf8)!)
- body.append("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\"\r\n".data(using: .utf8)!)
- body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
- body.append(imageData)
- body.append("\r\n".data(using: .utf8)!)
-
- // 结束边界
- body.append("--\(boundary)--\r\n".data(using: .utf8)!)
- request.httpBody = body
- let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
- if let error = error {
- print("Error uploading image: \(error)")
- DispatchQueue.main.async {
- self?.showToast(message: "头像上传失败,请重试。")
- }
- return
- }
- guard let data = data else {
- DispatchQueue.main.async {
- self?.showToast(message: "头像上传失败,请重试。")
- }
- return
- }
- do {
- let decoder = JSONDecoder()
- let responseObject = try decoder.decode(UploadImageResponse.self, from: data)
- if responseObject.code == "200" {
- // Get imageUrl and save it
- self?.avatarImageUrl = responseObject.data.url
- print("ssss \(String(describing: self?.avatarImageUrl))")
- DispatchQueue.main.async {
- self?.showToast(message: "头像上传成功!")
- }
- } else {
- print("Error: \(responseObject.msg ?? "Unknown error")")
- DispatchQueue.main.async {
- self?.showToast(message: "头像上传失败,请重试。")
- }
- }
- } catch {
- print("Error decoding upload response: \(error)")
- DispatchQueue.main.async {
- self?.showToast(message: "头像上传失败,请重试。")
- }
- }
- }
- task.resume()
- }
- @objc private func tapCancel() { navigationController?.popViewController(animated: true) }
- @objc private func tapConfirm() {
- // Fetch breedId from selectedBreedId and categoryId from selectedSpecies
- let breedId = Int(selectedBreedId ?? "") ?? 0
- let categoryId = getCategoryId(from: selectedSpecies)
- let userId = UserDefaults.standard.integer(forKey: "userId") // Fetch userId from UserDefaults (or other storage)
- // DateFormatter to format the dates in the desired format
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "yyyy-MM-dd"
- // Format the arrivalDate and birthDate
- let formattedArrivalDate = dateFormatter.string(from: arriveDate ?? Date())
- let formattedBirthDate = dateFormatter.string(from: birthDate ?? Date())
- // Collect other data for pet save
- let name = nickField.text ?? ""
- let weight = weightRow.text
- let petData = PetSaveRequest(
- arrivalDate: formattedArrivalDate,
- avatar: avatarImageUrl ?? "",
- birthDate: formattedBirthDate,
- breedId: breedId,
- categoryId: categoryId,
- createTime: "",
- gender: selectedGender ?? "",
- id: 0,
- name: name,
- nickname: nickField.text ?? "",
- updateTime: "",
- userId: userId,
- weight: Double(weight ?? "0") ?? 0
- )
- print("dkkd \(petData)")
- savePetData(petData)
- }
- // Helper methods to get categoryId
- private func getCategoryId(from species: String?) -> Int {
- // Replace with actual logic to fetch categoryId based on species
- switch species {
- case "狗": return 3
- case "猫": return 4
- default: return 0
- }
- }
- private func savePetData(_ petData: PetSaveRequest) {
- guard let url = URL(string: "\(baseURL)/petRecordPet/petSave") else { return }
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- if let token = UserDefaults.standard.string(forKey: "userToken") {
- request.setValue(token, forHTTPHeaderField: "Authorization")
- }
- do {
- let encoder = JSONEncoder()
- let body = try encoder.encode(petData)
- request.httpBody = body
- } catch {
- print("Error encoding pet data: \(error)")
- DispatchQueue.main.async {
- self.showToast(message: "宠物信息保存失败,请重试。")
- }
- return
- }
- let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
- if let error = error {
- print("Error saving pet: \(error)")
- DispatchQueue.main.async {
- self?.showToast(message: "宠物信息保存失败,请重试。")
- }
- return
- }
- guard let data = data else {
- DispatchQueue.main.async {
- self?.showToast(message: "宠物信息保存失败,请重试。")
- }
- return
- }
- do {
- if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
- if let code = json["code"] as? String, code != "500" {
- print("Pet saved successfully!")
- // 通知首页刷新宠物列表
- let uidStr = UserDefaults.standard.string(forKey: "userId") ?? {
- let v = UserDefaults.standard.integer(forKey: "userId"); return v == 0 ? "" : String(v)
- }()
- NotificationCenter.default.post(name: .petDidSave, object: nil, userInfo: ["userId": uidStr])
- DispatchQueue.main.async {
- self?.showToast(message: "宠物信息保存成功!")
- }
- // Add delay of 1.5 seconds before going back
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
- self?.navigationController?.popViewController(animated: true)
- }
- } else {
- DispatchQueue.main.async {
- self?.showToast(message: "宠物信息保存失败,请重试。")
- }
- }
- }
- } catch {
- print("Error decoding pet save response: \(error)")
- // 通知首页刷新宠物列表(异常也发通知)
- let uidStr = UserDefaults.standard.string(forKey: "userId") ?? {
- let v = UserDefaults.standard.integer(forKey: "userId"); return v == 0 ? "" : String(v)
- }()
- NotificationCenter.default.post(name: .petDidSave, object: nil, userInfo: ["userId": uidStr])
- DispatchQueue.main.async {
- self?.showToast(message: "宠物信息保存成功!")
- }
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
- self?.navigationController?.popViewController(animated: true)
- }
- }
- }
- task.resume()
- }
- // MARK: - Present action sheet safely on iPad
- private func presentSheet(_ ac: UIAlertController, from source: UIView) {
- if let pop = ac.popoverPresentationController {
- // 优先用触发的控件作为锚点
- if source.window != nil {
- pop.sourceView = source
- pop.sourceRect = source.bounds
- pop.permittedArrowDirections = [.up, .down]
- } else {
- // 兜底:用整个视图中心作为锚点(极少数情况下 source 还未在窗口层级里)
- pop.sourceView = self.view
- pop.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.midY, width: 1, height: 1)
- pop.permittedArrowDirections = []
- }
- }
- self.present(ac, animated: true)
- }
-
- // MARK: - Toast
- private func showToast(message: String) {
- let toastLabel = UILabel(frame: CGRect(x: self.view.frame.size.width / 2 - 150, y: self.view.frame.size.height/2 - 50, width: 300, height: 50))
- toastLabel.backgroundColor = UIColor.black.withAlphaComponent(0.7)
- toastLabel.textColor = UIColor.white
- toastLabel.textAlignment = .center
- toastLabel.font = .systemFont(ofSize: 14)
- toastLabel.text = message
- toastLabel.alpha = 1.0
- toastLabel.layer.cornerRadius = 10
- toastLabel.clipsToBounds = true
- self.view.addSubview(toastLabel)
- UIView.animate(withDuration: 1, delay: 1, options: .curveEaseOut, animations: {
- toastLabel.alpha = 0.0
- }, completion: { _ in
- toastLabel.removeFromSuperview()
- })
- }
- // Models for requests and responses
- struct UploadImageResponse: Codable {
- let code: String
- let msg: String?
- let data: UploadImageData
- }
- struct UploadImageData: Codable {
- let url: String
- }
- struct PetSaveRequest: Codable {
- let arrivalDate: String
- let avatar: String
- let birthDate: String
- let breedId: Int
- let categoryId: Int
- let createTime: String
- let gender: String
- let id: Int
- let name: String
- let nickname: String
- let updateTime: String
- let userId: Int
- let weight: Double
- }
- struct PetSaveResponse: Codable {
- let code: String
- let msg: String?
- let data: PetSaveData?
- }
- struct PetSaveData: Codable {
- let petId: Int
- }
- private func chooseSpecies() {
- let ac = UIAlertController(title: "选择种类", message: nil, preferredStyle: .actionSheet)
- ["猫", "狗"].forEach { t in ac.addAction(UIAlertAction(title: t, style: .default, handler: { _ in
- self.selectedSpecies = t
- self.speciesRow.value = t
- // 清空品种
- self.selectedBreed = nil
- self.selectedBreedId = nil
- self.breedRow.value = "请选择"
- })) }
- ac.addAction(UIAlertAction(title: "取消", style: .cancel))
- // ✅ 关键:指定 popover 锚点
- presentSheet(ac, from: self.speciesRow)
- }
- private func chooseBreed() {
- guard let sp = selectedSpecies else {
- let ac = UIAlertController(title: "请先选择种类", message: nil, preferredStyle: .alert)
- ac.addAction(UIAlertAction(title: "确定", style: .default))
- present(ac, animated: true); return
- }
- let categoryId: Int
- switch sp {
- case "狗": categoryId = 3
- case "猫": categoryId = 4
- default: categoryId = 0
- }
- let vc = BreedPickerViewController(categoryId: categoryId, token: UserDefaults.standard.string(forKey: "userToken"))
- vc.onSelect = { [weak self] breedId, breedName in
- self?.selectedBreed = breedName
- self?.breedRow.value = breedName
- self?.selectedBreedId = breedId
- self?.navigationController?.popViewController(animated: true)
- }
- navigationController?.pushViewController(vc, animated: true)
- }
- private func chooseGender() {
- let ac = UIAlertController(title: "选择性别", message: nil, preferredStyle: .actionSheet)
- ["公", "母"].forEach { t in ac.addAction(UIAlertAction(title: t, style: .default, handler: { _ in
- self.selectedGender = t
- self.genderRow.value = t
- })) }
- ac.addAction(UIAlertAction(title: "取消", style: .cancel))
- // ✅ 关键:指定 popover 锚点
- presentSheet(ac, from: self.genderRow)
- }
- private enum DateKind { case birth, arrive }
- private func chooseDate(for kind: DateKind) {
- let initDate = (kind == .birth) ? (self.birthDate ?? Date()) : (self.arriveDate ?? Date())
- let vc = DatePickerSheetController(initial: initDate)
- vc.onDone = { [weak self] date in
- guard let self = self else { return }
- let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
- let text = fmt.string(from: date)
- if kind == .birth { self.birthDate = date; self.birthRow.value = text }
- else { self.arriveDate = date; self.arriveRow.value = text }
- }
- present(vc, animated: false)
- }
- }
- // MARK: - Helper Views
- final class DashedSeparator: UIView {
- private let shape = CAShapeLayer()
- override init(frame: CGRect) {
- super.init(frame: frame)
- backgroundColor = .clear
- shape.strokeColor = UIColor(hex: "#DADADA").cgColor
- shape.lineDashPattern = [4, 4]
- shape.lineWidth = 1
- layer.addSublayer(shape)
- }
- required init?(coder: NSCoder) { fatalError() }
- override func layoutSubviews() {
- super.layoutSubviews()
- shape.frame = bounds
- let path = UIBezierPath()
- path.move(to: CGPoint(x: 0, y: bounds.midY))
- path.addLine(to: CGPoint(x: bounds.width, y: bounds.midY))
- shape.path = path.cgPath
- }
- }
- final class InputRow: UIView {
- private let titleLabel = UILabel()
- private let textField = UITextField()
- init(title: String, placeholder: String, showsChevron: Bool, keyboard: UIKeyboardType) {
- super.init(frame: .zero)
- titleLabel.text = title
- titleLabel.font = .systemFont(ofSize: 14)
- titleLabel.textColor = UIColor(hex: "#5B5B5B")
- textField.placeholder = placeholder
- textField.font = .systemFont(ofSize: 14)
- textField.textAlignment = .right
- textField.keyboardType = keyboard
- addSubview(titleLabel)
- addSubview(textField)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- textField.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
- textField.centerYAnchor.constraint(equalTo: centerYAnchor),
- textField.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 8)
- ])
- }
-
- func setEnabled(_ enabled: Bool) {
- isUserInteractionEnabled = enabled
- textField.textColor = enabled ? UIColor(hex: "#8B8B8B") : UIColor(hex: "#C8C8C8")
- titleLabel.textColor = enabled ? UIColor(hex: "#5B5B5B") : UIColor(hex: "#A0A0A0")
- }
- required init?(coder: NSCoder) { fatalError() }
- var text: String {
- get { textField.text ?? "" }
- set { textField.text = newValue }
- }
- }
- final class SelectRow: UIControl {
- private let titleLabel = UILabel()
- private let valueLabel = UILabel()
- private let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
- var onTap: (() -> Void)?
- // 外部控制:显示/隐藏右侧箭头;启用/禁用交互与样式
- func setChevronHidden(_ hidden: Bool) {
- chevron.isHidden = hidden
- }
- func setEnabled(_ enabled: Bool) {
- isUserInteractionEnabled = enabled
- valueLabel.textColor = enabled ? UIColor(hex: "#8B8B8B") : UIColor(hex: "#C8C8C8")
- titleLabel.textColor = enabled ? UIColor(hex: "#5B5B5B") : UIColor(hex: "#A0A0A0")
- }
-
- init(title: String) {
- super.init(frame: .zero)
- addTarget(self, action: #selector(tap), for: .touchUpInside)
- titleLabel.text = title
- titleLabel.font = .systemFont(ofSize: 14)
- titleLabel.textColor = UIColor(hex: "#5B5B5B")
- valueLabel.text = "请选择"
- valueLabel.font = .systemFont(ofSize: 14)
- valueLabel.textColor = UIColor(hex: "#8B8B8B")
- valueLabel.textAlignment = .right
- chevron.tintColor = UIColor(hex: "#C1C1C1")
- chevron.setContentHuggingPriority(.required, for: .horizontal)
- [titleLabel, valueLabel, chevron].forEach { addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false }
- NSLayoutConstraint.activate([
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- chevron.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2),
- chevron.centerYAnchor.constraint(equalTo: centerYAnchor),
- chevron.widthAnchor.constraint(equalToConstant: 10),
- valueLabel.trailingAnchor.constraint(equalTo: chevron.leadingAnchor, constant: -8),
- valueLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- valueLabel.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 8)
- ])
- }
- required init?(coder: NSCoder) { fatalError() }
- @objc private func tap() { onTap?() }
- var value: String? { get { valueLabel.text } set { valueLabel.text = newValue } }
- }
- // MARK: - DatePickerSheetController (仿原型样式的日期弹窗)
- final class DatePickerSheetController: UIViewController, UIPickerViewDataSource, UIPickerViewDelegate {
- var onDone: ((Date) -> Void)?
- private let initial: Date
- private let bg = UIControl()
- private let sheet = UIView()
- private let header = UIView()
- private let titleLabel = UILabel()
- private let closeBtn = UIButton(type: .custom)
- private let doneBtn = UIButton(type: .custom)
- private let picker = UIPickerView()
- private let highlight = UIView()
- private var years: [Int] = []
- private let months = Array(1...12)
- private var days: [Int] = []
- private var selYear: Int = 0
- private var selMonth: Int = 0
- private var selDay: Int = 0
- init(initial: Date) {
- self.initial = initial
- super.init(nibName: nil, bundle: nil)
- modalPresentationStyle = .overFullScreen
- modalTransitionStyle = .crossDissolve
- }
- required init?(coder: NSCoder) { fatalError() }
- override func viewDidLoad() {
- super.viewDidLoad()
- view.backgroundColor = .clear
- buildData()
- buildUI()
- applyInitialSelection()
- }
- private func buildData() {
- let cal = Calendar.current
- let nowYear = cal.component(.year, from: Date())
- years = Array((nowYear - 50)...(nowYear + 5))
- }
- private func buildUI() {
- // Dim background
- bg.backgroundColor = UIColor.black.withAlphaComponent(0.45)
- bg.addTarget(self, action: #selector(close), for: .touchUpInside)
- view.addSubview(bg)
- bg.translatesAutoresizingMaskIntoConstraints = false
- // Sheet
- sheet.backgroundColor = .white
- sheet.layer.cornerRadius = 16
- sheet.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
- view.addSubview(sheet)
- sheet.translatesAutoresizingMaskIntoConstraints = false
- // Header
- titleLabel.text = "选择时间"
- titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
- titleLabel.textColor = UIColor(hex: "#2B2B2B")
- titleLabel.textAlignment = .center
- closeBtn.setImage(UIImage(named: "AddPet385"), for: .normal)
- // closeBtn.tintColor = UIColor(hex: "#2B2B2B")
- closeBtn.addTarget(self, action: #selector(close), for: .touchUpInside)
- doneBtn.setImage(UIImage(named: "AddPet389"), for: .normal)
- // doneBtn.tintColor = UIColor(hex: "#2B2B2B")
- doneBtn.addTarget(self, action: #selector(doneTap), for: .touchUpInside)
- header.translatesAutoresizingMaskIntoConstraints = false
- header.addSubview(titleLabel)
- header.addSubview(closeBtn)
- header.addSubview(doneBtn)
- [titleLabel, closeBtn, doneBtn].forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
- // Picker
- picker.dataSource = self
- picker.delegate = self
- picker.translatesAutoresizingMaskIntoConstraints = false
- picker.backgroundColor = .clear
- // Highlight row (FFE059, 25% alpha)
- highlight.backgroundColor = UIColor(hex: "#FFE059").withAlphaComponent(0.25)
- highlight.layer.cornerRadius = 22
- highlight.isUserInteractionEnabled = false
- highlight.translatesAutoresizingMaskIntoConstraints = false
- sheet.addSubview(header)
- sheet.addSubview(picker)
- sheet.addSubview(highlight)
- // Layout
- NSLayoutConstraint.activate([
- bg.topAnchor.constraint(equalTo: view.topAnchor),
- bg.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- bg.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- bg.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- sheet.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- sheet.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- sheet.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
- header.topAnchor.constraint(equalTo: sheet.topAnchor, constant: 8),
- header.leadingAnchor.constraint(equalTo: sheet.leadingAnchor, constant: 12),
- header.trailingAnchor.constraint(equalTo: sheet.trailingAnchor, constant: -12),
- header.heightAnchor.constraint(equalToConstant: 36),
- closeBtn.centerYAnchor.constraint(equalTo: header.centerYAnchor),
- closeBtn.leadingAnchor.constraint(equalTo: header.leadingAnchor),
- closeBtn.widthAnchor.constraint(equalToConstant: 28),
- closeBtn.heightAnchor.constraint(equalToConstant: 28),
- doneBtn.centerYAnchor.constraint(equalTo: header.centerYAnchor),
- doneBtn.trailingAnchor.constraint(equalTo: header.trailingAnchor),
- doneBtn.widthAnchor.constraint(equalToConstant: 28),
- doneBtn.heightAnchor.constraint(equalToConstant: 28),
- titleLabel.centerXAnchor.constraint(equalTo: header.centerXAnchor),
- titleLabel.centerYAnchor.constraint(equalTo: header.centerYAnchor),
- picker.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 4),
- picker.leadingAnchor.constraint(equalTo: sheet.leadingAnchor),
- picker.trailingAnchor.constraint(equalTo: sheet.trailingAnchor),
- picker.bottomAnchor.constraint(equalTo: sheet.bottomAnchor),
- highlight.leadingAnchor.constraint(equalTo: sheet.leadingAnchor, constant: 16),
- highlight.trailingAnchor.constraint(equalTo: sheet.trailingAnchor, constant: -16),
- highlight.centerYAnchor.constraint(equalTo: picker.centerYAnchor),
- highlight.heightAnchor.constraint(equalToConstant: 44)
- ])
- }
- override func viewDidAppear(_ animated: Bool) {
- super.viewDidAppear(animated)
- // Present animation
- sheet.transform = CGAffineTransform(translationX: 0, y: 400)
- header.alpha = 0
- picker.alpha = 0
- bg.alpha = 0
- UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) {
- self.sheet.transform = .identity
- self.header.alpha = 1
- self.picker.alpha = 1
- self.bg.alpha = 1
- }
- }
- // MARK: - Picker data
- func numberOfComponents(in pickerView: UIPickerView) -> Int { 3 }
- func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
- switch component {
- case 0: return years.count
- case 1: return months.count
- default: return days.count
- }
- }
- func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
- let w = pickerView.bounds.width
- return component == 0 ? w * 0.4 : w * 0.3
- }
- func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat { 44 }
- func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
- let label = (view as? UILabel) ?? UILabel()
- label.font = .systemFont(ofSize: 18, weight: .medium)
- label.textAlignment = component == 0 ? .center : .center
- label.textColor = UIColor(hex: "#2B2B2B")
- switch component {
- case 0: label.text = "\(years[row])年"
- case 1: label.text = String(format: "%02d月", months[row])
- default: label.text = String(format: "%02d日", days[row])
- }
- // 变灰非选中项
- let isSelected = row == pickerView.selectedRow(inComponent: component)
- label.textColor = isSelected ? UIColor(hex: "#2B2B2B") : UIColor(hex: "#A8A8A8")
- return label
- }
- func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
- switch component {
- case 0: selYear = years[row]
- case 1: selMonth = months[row]
- case 2: selDay = days[row]
- default: break
- }
- if component != 2 {
- rebuildDays()
- pickerView.reloadComponent(2)
- }
- // 让文字颜色即时更新
- pickerView.reloadComponent(component)
- }
- private func applyInitialSelection() {
- let cal = Calendar.current
- selYear = cal.component(.year, from: initial)
- selMonth = cal.component(.month, from: initial)
- selDay = cal.component(.day, from: initial)
- rebuildDays()
- if let yi = years.firstIndex(of: selYear) {
- picker.selectRow(yi, inComponent: 0, animated: false)
- }
- picker.selectRow(selMonth - 1, inComponent: 1, animated: false)
- if let di = days.firstIndex(of: selDay) {
- picker.selectRow(di, inComponent: 2, animated: false)
- }
- picker.reloadAllComponents()
- }
- private func rebuildDays() {
- let cal = Calendar.current
- var comps = DateComponents()
- comps.year = selYear
- comps.month = selMonth
- comps.day = 1
- let date = cal.date(from: comps) ?? Date()
- let range = cal.range(of: .day, in: .month, for: date) ?? 1..<31
- days = Array(range)
- if !days.contains(selDay) { selDay = days.last ?? 1 }
- }
- @objc private func close() { dismissAnimated() }
- @objc private func doneTap() {
- var comps = DateComponents()
- comps.year = selYear; comps.month = selMonth; comps.day = selDay
- let cal = Calendar.current
- let date = cal.date(from: comps) ?? Date()
- onDone?(date)
- dismissAnimated()
- }
- private func dismissAnimated() {
- UIView.animate(withDuration: 0.22, animations: {
- self.sheet.transform = CGAffineTransform(translationX: 0, y: 400)
- self.bg.alpha = 0
- }, completion: { _ in
- self.dismiss(animated: false)
- })
- }
- }
|