PetDetailViewController.swift 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. //
  2. // PetDetailViewController.swift
  3. // VenusKitto
  4. //
  5. // Created by Neoa on 2025/8/27.
  6. //
  7. import Foundation
  8. import UIKit
  9. // MARK: - PetDetailViewController (宠物详情 + 编辑/保存)
  10. final class PetDetailViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
  11. // 输入/选择行与 AddPet 基本一致
  12. private let scrollView = UIScrollView()
  13. private let contentView = UIView()
  14. private let avatarButton = UIButton(type: .custom)
  15. private let avatarHint = UILabel()
  16. private let nickCard = UIView()
  17. private let nickTitle = UILabel()
  18. private let nickField = UITextField()
  19. private let infoCard = UIView()
  20. private let weightRow = InputRow(title: "体重", placeholder: "请输入体重(kg)", showsChevron: false, keyboard: .decimalPad)
  21. private let speciesRow = SelectRow(title: "种类")
  22. private let breedRow = SelectRow(title: "品种")
  23. private let genderRow = SelectRow(title: "性别")
  24. private let birthRow = SelectRow(title: "出生日期")
  25. private let arriveRow = SelectRow(title: "到家日期")
  26. private let saveButton = UIButton(type: .system)
  27. private var isEditingMode = false { didSet { applyEditingState() } }
  28. private var selectedSpecies: String?
  29. private var selectedBreedName: String?
  30. private var selectedBreedId: String?
  31. private var selectedGender: String?
  32. private var birthDate: Date?
  33. private var arriveDate: Date?
  34. private var avatarImageUrl: String?
  35. private let pet: HomeViewController.PetSummary
  36. private let imageCache = NSCache<NSString, UIImage>()
  37. private let rightEditButton = UIButton(type: .system)
  38. init(pet: HomeViewController.PetSummary) {
  39. self.pet = pet
  40. super.init(nibName: nil, bundle: nil)
  41. }
  42. required init?(coder: NSCoder) { fatalError() }
  43. override func viewDidLoad() {
  44. super.viewDidLoad()
  45. view.backgroundColor = .white
  46. title = "宠物详情"
  47. // 左上角:返回 + 编辑
  48. if let backImage = UIImage(named: "AddPet385") {
  49. let back = UIBarButtonItem(image: backImage.withRenderingMode(.alwaysOriginal), style: .plain, target: self, action: #selector(tapBack))
  50. navigationItem.leftBarButtonItem = back
  51. }
  52. // 右上角:编辑/保存(自定义按钮以支持背景色)
  53. rightEditButton.setTitle("编辑", for: .normal)
  54. rightEditButton.setTitleColor(.black, for: .normal)
  55. rightEditButton.backgroundColor = .clear
  56. rightEditButton.layer.cornerRadius = 16
  57. rightEditButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
  58. rightEditButton.addTarget(self, action: #selector(onRightButtonTap), for: .touchUpInside)
  59. navigationItem.rightBarButtonItem = UIBarButtonItem(customView: rightEditButton)
  60. layout()
  61. wire()
  62. fillWithPet()
  63. isEditingMode = false // 默认不可编辑(箭头隐藏、不可点)
  64. }
  65. @objc private func tapBack() { navigationController?.popViewController(animated: true) }
  66. private func layout() {
  67. // scroll
  68. view.addSubview(scrollView)
  69. scrollView.translatesAutoresizingMaskIntoConstraints = false
  70. scrollView.showsVerticalScrollIndicator = false
  71. scrollView.keyboardDismissMode = .onDrag
  72. scrollView.addSubview(contentView)
  73. contentView.translatesAutoresizingMaskIntoConstraints = false
  74. NSLayoutConstraint.activate([
  75. scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
  76. scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  77. scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  78. scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  79. contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
  80. contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
  81. contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
  82. contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
  83. contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
  84. ])
  85. // avatar
  86. avatarButton.translatesAutoresizingMaskIntoConstraints = false
  87. avatarButton.setImage(UIImage(named: "Home372") ?? UIImage(systemName: "pawprint.circle")!, for: .normal)
  88. avatarButton.imageView?.contentMode = .scaleAspectFill
  89. avatarButton.layer.cornerRadius = 40
  90. avatarButton.clipsToBounds = true
  91. avatarButton.backgroundColor = UIColor(hex: "#FFF3D9")
  92. avatarHint.text = "点击上传头像"
  93. avatarHint.font = .systemFont(ofSize: 12)
  94. avatarHint.textColor = UIColor(hex: "#8B8B8B")
  95. avatarHint.textAlignment = .center
  96. avatarHint.translatesAutoresizingMaskIntoConstraints = false
  97. contentView.addSubview(avatarButton)
  98. contentView.addSubview(avatarHint)
  99. NSLayoutConstraint.activate([
  100. avatarButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
  101. avatarButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
  102. avatarButton.widthAnchor.constraint(equalToConstant: 80),
  103. avatarButton.heightAnchor.constraint(equalToConstant: 80),
  104. avatarHint.topAnchor.constraint(equalTo: avatarButton.bottomAnchor, constant: 6),
  105. avatarHint.centerXAnchor.constraint(equalTo: avatarButton.centerXAnchor)
  106. ])
  107. // Nick card
  108. nickCard.backgroundColor = .white
  109. nickCard.layer.cornerRadius = 12
  110. nickCard.layer.shadowColor = UIColor.black.cgColor
  111. nickCard.layer.shadowOpacity = 0.06
  112. nickCard.layer.shadowRadius = 6
  113. nickCard.layer.shadowOffset = .init(width: 0, height: 2)
  114. nickCard.translatesAutoresizingMaskIntoConstraints = false
  115. nickTitle.text = "昵称"
  116. nickTitle.font = .systemFont(ofSize: 14)
  117. nickTitle.textColor = UIColor(hex: "#5B5B5B")
  118. nickField.placeholder = "请输入昵称"
  119. nickField.font = .systemFont(ofSize: 14)
  120. nickField.textAlignment = .right
  121. nickField.clearButtonMode = .whileEditing
  122. [nickTitle, nickField].forEach { nickCard.addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false }
  123. contentView.addSubview(nickCard)
  124. NSLayoutConstraint.activate([
  125. nickCard.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 120),
  126. nickCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
  127. nickCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
  128. nickCard.heightAnchor.constraint(equalToConstant: 64),
  129. nickTitle.centerYAnchor.constraint(equalTo: nickCard.centerYAnchor),
  130. nickTitle.leadingAnchor.constraint(equalTo: nickCard.leadingAnchor, constant: 16),
  131. nickField.centerYAnchor.constraint(equalTo: nickCard.centerYAnchor),
  132. nickField.trailingAnchor.constraint(equalTo: nickCard.trailingAnchor, constant: -24),
  133. nickField.leadingAnchor.constraint(greaterThanOrEqualTo: nickTitle.trailingAnchor, constant: 12)
  134. ])
  135. // Info card
  136. infoCard.backgroundColor = .white
  137. infoCard.layer.cornerRadius = 12
  138. infoCard.layer.shadowColor = UIColor.black.cgColor
  139. infoCard.layer.shadowOpacity = 0.06
  140. infoCard.layer.shadowRadius = 6
  141. infoCard.layer.shadowOffset = .init(width: 0, height: 2)
  142. infoCard.translatesAutoresizingMaskIntoConstraints = false
  143. contentView.addSubview(infoCard)
  144. let rows: [UIView] = [weightRow, speciesRow, breedRow, genderRow, birthRow, arriveRow]
  145. rows.forEach { r in
  146. r.translatesAutoresizingMaskIntoConstraints = false
  147. infoCard.addSubview(r)
  148. }
  149. // 分隔线
  150. let seps = (0..<(rows.count - 1)).map { _ in DashedSeparator() }
  151. seps.forEach { s in infoCard.addSubview(s); s.translatesAutoresizingMaskIntoConstraints = false }
  152. NSLayoutConstraint.activate([
  153. infoCard.topAnchor.constraint(equalTo: nickCard.bottomAnchor, constant: 16),
  154. infoCard.leadingAnchor.constraint(equalTo: nickCard.leadingAnchor),
  155. infoCard.trailingAnchor.constraint(equalTo: nickCard.trailingAnchor)
  156. ])
  157. // Row heights
  158. let h: CGFloat = 52
  159. var prev: UIView? = nil
  160. for (idx, row) in rows.enumerated() {
  161. NSLayoutConstraint.activate([
  162. row.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 12),
  163. row.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -12),
  164. row.heightAnchor.constraint(equalToConstant: h)
  165. ])
  166. if let p = prev {
  167. row.topAnchor.constraint(equalTo: p.bottomAnchor).isActive = true
  168. } else {
  169. row.topAnchor.constraint(equalTo: infoCard.topAnchor).isActive = true
  170. }
  171. if idx < seps.count {
  172. let sep = seps[idx]
  173. NSLayoutConstraint.activate([
  174. sep.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 12),
  175. sep.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -12),
  176. sep.topAnchor.constraint(equalTo: row.bottomAnchor),
  177. sep.heightAnchor.constraint(equalToConstant: 1)
  178. ])
  179. }
  180. prev = row
  181. }
  182. prev!.bottomAnchor.constraint(equalTo: infoCard.bottomAnchor).isActive = true
  183. // Save button
  184. saveButton.setTitle("保存", for: .normal)
  185. saveButton.setTitleColor(.black, for: .normal)
  186. saveButton.backgroundColor = UIColor(hex: "#FFE059")
  187. saveButton.layer.cornerRadius = 22
  188. contentView.addSubview(saveButton)
  189. saveButton.translatesAutoresizingMaskIntoConstraints = false
  190. saveButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
  191. NSLayoutConstraint.activate([
  192. saveButton.topAnchor.constraint(equalTo: infoCard.bottomAnchor, constant: 28),
  193. saveButton.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor),
  194. saveButton.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor),
  195. saveButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -28)
  196. ])
  197. }
  198. private func wire() {
  199. let tap = UITapGestureRecognizer(target: self, action: #selector(endEdit))
  200. tap.cancelsTouchesInView = false
  201. view.addGestureRecognizer(tap)
  202. avatarButton.addTarget(self, action: #selector(pickAvatar), for: .touchUpInside)
  203. saveButton.addTarget(self, action: #selector(tapSave), for: .touchUpInside)
  204. speciesRow.onTap = { [weak self] in self?.chooseSpecies() }
  205. breedRow.onTap = { [weak self] in self?.chooseBreed() }
  206. genderRow.onTap = { [weak self] in self?.chooseGender() }
  207. birthRow.onTap = { [weak self] in self?.chooseDate(for: .birth) }
  208. arriveRow.onTap = { [weak self] in self?.chooseDate(for: .arrive) }
  209. }
  210. @objc private func endEdit() { view.endEditing(true) }
  211. // 只读/编辑模式切换
  212. private func applyEditingState() {
  213. // chevron 显隐 & 交互
  214. speciesRow.setChevronHidden(!isEditingMode)
  215. breedRow.setChevronHidden(!isEditingMode)
  216. genderRow.setChevronHidden(!isEditingMode)
  217. birthRow.setChevronHidden(!isEditingMode)
  218. arriveRow.setChevronHidden(!isEditingMode)
  219. weightRow.setEnabled(isEditingMode)
  220. speciesRow.setEnabled(isEditingMode)
  221. breedRow.setEnabled(isEditingMode)
  222. genderRow.setEnabled(isEditingMode)
  223. birthRow.setEnabled(isEditingMode)
  224. arriveRow.setEnabled(isEditingMode)
  225. nickField.isUserInteractionEnabled = isEditingMode
  226. avatarButton.isUserInteractionEnabled = isEditingMode
  227. // InputRow 的 textField是私有,这里不直接访问,整体禁用/启用这一行
  228. weightRow.isUserInteractionEnabled = isEditingMode
  229. saveButton.isHidden = !isEditingMode
  230. avatarHint.isHidden = !isEditingMode
  231. updateRightButton()
  232. }
  233. // 更新右上角按钮外观
  234. private func updateRightButton() {
  235. if isEditingMode {
  236. rightEditButton.setTitle("保存", for: .normal)
  237. rightEditButton.backgroundColor = UIColor(hex: "#FFE059")
  238. } else {
  239. rightEditButton.setTitle("编辑", for: .normal)
  240. rightEditButton.backgroundColor = .clear
  241. }
  242. }
  243. // 右上角按钮点击:编辑 -> 进入编辑;保存 -> 调用保存
  244. @objc private func onRightButtonTap() {
  245. if isEditingMode {
  246. tapSave()
  247. } else {
  248. isEditingMode = true
  249. }
  250. }
  251. private func fillWithPet() {
  252. nickField.text = pet.nickname?.isEmpty == false ? pet.nickname : pet.name
  253. // 种类/品种/性别
  254. if let cid = Int(pet.categoryId ?? "0") {
  255. selectedSpecies = (cid == 3) ? "狗" : (cid == 4 ? "猫" : nil)
  256. speciesRow.value = selectedSpecies ?? "请选择"
  257. }
  258. selectedBreedName = pet.breedName
  259. breedRow.value = pet.breedName ?? "请选择"
  260. selectedBreedId = pet.breedId
  261. selectedGender = pet.gender
  262. genderRow.value = pet.gender ?? "请选择"
  263. // 体重
  264. if let w = pet.weight, !w.isEmpty {
  265. weightRow.text = w
  266. }
  267. // 日期
  268. let dateFmt = DateFormatter(); dateFmt.dateFormat = "yyyy-MM-dd"
  269. if let d = parseServerDate(pet.birthDate) {
  270. birthDate = d; birthRow.value = dateFmt.string(from: d)
  271. }
  272. if let d = parseServerDate(pet.arrivalDate) {
  273. arriveDate = d; arriveRow.value = dateFmt.string(from: d)
  274. }
  275. // 头像
  276. if let urlStr = pet.avatar, let url = URL(string: urlStr) {
  277. loadImage(from: url) { [weak self] img in
  278. self?.avatarButton.setImage(img ?? (UIImage(named: "Home372") ?? UIImage(systemName: "pawprint.circle")!), for: .normal)
  279. }
  280. }
  281. }
  282. // MARK: - Actions
  283. @objc private func pickAvatar() {
  284. guard isEditingMode else { return }
  285. let picker = UIImagePickerController()
  286. picker.sourceType = .photoLibrary
  287. picker.delegate = self
  288. present(picker, animated: true)
  289. }
  290. func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
  291. if let img = info[.originalImage] as? UIImage {
  292. avatarButton.setImage(img, for: .normal)
  293. uploadAvatar(image: img)
  294. }
  295. picker.dismiss(animated: true)
  296. }
  297. private func uploadAvatar(image: UIImage) {
  298. guard let imageData = image.jpegData(compressionQuality: 0.7) else { return }
  299. let url = URL(string: "\(baseURL)/common/upload")!
  300. var request = URLRequest(url: url)
  301. request.httpMethod = "POST"
  302. if let token = UserDefaults.standard.string(forKey: "userToken") {
  303. request.setValue(token, forHTTPHeaderField: "Authorization")
  304. }
  305. let boundary = "Boundary-\(UUID().uuidString)"
  306. request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
  307. var body = Data()
  308. body.append("--\(boundary)\r\n".data(using: .utf8)!)
  309. body.append("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\"\r\n".data(using: .utf8)!)
  310. body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
  311. body.append(imageData)
  312. body.append("\r\n".data(using: .utf8)!)
  313. body.append("--\(boundary)--\r\n".data(using: .utf8)!)
  314. request.httpBody = body
  315. URLSession.shared.dataTask(with: request) { [weak self] data, _, _ in
  316. guard let data = data else { return }
  317. if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
  318. let code = (json["code"] as? String) ?? ((json["code"] as? Int).map { String($0) }),
  319. code == "200",
  320. let dataObj = json["data"] as? [String: Any],
  321. let urlStr = dataObj["url"] as? String {
  322. self?.avatarImageUrl = urlStr
  323. }
  324. }.resume()
  325. }
  326. @objc private func tapSave() {
  327. // 组装参数
  328. let userId = Int(UserDefaults.standard.string(forKey: "userId") ?? "") ?? UserDefaults.standard.integer(forKey: "userId")
  329. let breedId = Int(selectedBreedId ?? (pet.breedId ?? "0")) ?? 0
  330. let categoryId = (selectedSpecies == "狗") ? 3 : (selectedSpecies == "猫" ? 4 : (Int(pet.categoryId ?? "0") ?? 0))
  331. let gender = selectedGender ?? (pet.gender ?? "")
  332. let name = nickField.text ?? pet.name
  333. let nickname = nickField.text ?? pet.nickname ?? pet.name
  334. let dateFormatter = DateFormatter(); dateFormatter.dateFormat = "yyyy-MM-dd"
  335. let birth = birthDate != nil ? dateFormatter.string(from: birthDate!) : (dateFormatter.string(from: parseServerDate(pet.birthDate) ?? Date()))
  336. let arrive = arriveDate != nil ? dateFormatter.string(from: arriveDate!) : (dateFormatter.string(from: parseServerDate(pet.arrivalDate) ?? Date()))
  337. let weightDouble: Double = {
  338. if let d = Double(weightRow.text), !weightRow.text.isEmpty { return d }
  339. if let w = pet.weight, let d = Double(w) { return d }
  340. return 0
  341. }()
  342. let bodyDict: [String: Any] = [
  343. "arrivalDate": arrive,
  344. "avatar": avatarImageUrl ?? (pet.avatar ?? ""),
  345. "birthDate": birth,
  346. "breedId": breedId,
  347. "categoryId": categoryId,
  348. "createTime": "",
  349. "gender": gender,
  350. "id": Int(pet.id ?? "0") ?? 0,
  351. "name": name,
  352. "nickname": nickname,
  353. "updateTime": "",
  354. "userId": userId,
  355. "weight": weightDouble
  356. ]
  357. guard let url = URL(string: "\(baseURL)/petRecordPet/petUpdate") else { return }
  358. var req = URLRequest(url: url)
  359. req.httpMethod = "POST"
  360. req.setValue("application/json", forHTTPHeaderField: "Content-Type")
  361. if let token = UserDefaults.standard.string(forKey: "userToken") {
  362. req.setValue(token, forHTTPHeaderField: "Authorization")
  363. }
  364. req.httpBody = try? JSONSerialization.data(withJSONObject: bodyDict, options: [])
  365. URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in
  366. if let http = resp as? HTTPURLResponse { print("🐾 petUpdate status: \(http.statusCode)") }
  367. if let err = err { print("🐾 petUpdate error: \(err)"); return }
  368. guard let data = data else { return }
  369. if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
  370. print()
  371. let code = (json["code"] as? String) ?? ((json["code"] as? Int).map { String($0) })
  372. DispatchQueue.main.async {
  373. if code == "200" {
  374. DispatchQueue.main.async {
  375. self?.showToast("更新成功")
  376. // 通知首页/列表刷新
  377. NotificationCenter.default.post(name: .petDidSave, object: nil, userInfo: ["userId": String(userId)])
  378. self?.navigationController?.popViewController(animated: true)
  379. }
  380. } else {
  381. DispatchQueue.main.async {
  382. self?.showToast("更新失败")
  383. }
  384. }
  385. }
  386. }else{
  387. DispatchQueue.main.async {
  388. self?.showToast("更新成功")
  389. // 通知首页/列表刷新
  390. NotificationCenter.default.post(name: .petDidSave, object: nil, userInfo: ["userId": String(userId)])
  391. self?.navigationController?.popViewController(animated: true)
  392. }
  393. }
  394. }.resume()
  395. }
  396. // MARK: - 选择器(与 AddPet 一致)
  397. private enum DateKind { case birth, arrive }
  398. private func chooseDate(for kind: DateKind) {
  399. guard isEditingMode else { return }
  400. let initDate = (kind == .birth) ? (self.birthDate ?? Date()) : (self.arriveDate ?? Date())
  401. let vc = DatePickerSheetController(initial: initDate)
  402. vc.onDone = { [weak self] date in
  403. guard let self = self else { return }
  404. let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
  405. let text = fmt.string(from: date)
  406. if kind == .birth { self.birthDate = date; self.birthRow.value = text }
  407. else { self.arriveDate = date; self.arriveRow.value = text }
  408. }
  409. present(vc, animated: false)
  410. }
  411. private func chooseSpecies() {
  412. guard isEditingMode else { return }
  413. let ac = UIAlertController(title: "选择种类", message: nil, preferredStyle: .actionSheet)
  414. ["猫", "狗"].forEach { t in ac.addAction(UIAlertAction(title: t, style: .default, handler: { _ in
  415. self.selectedSpecies = t
  416. self.speciesRow.value = t
  417. // 清空品种
  418. self.selectedBreedName = nil
  419. self.selectedBreedId = nil
  420. self.breedRow.value = "请选择"
  421. })) }
  422. ac.addAction(UIAlertAction(title: "取消", style: .cancel))
  423. // ✅ 关键:指定 popover 锚点
  424. presentSheet(ac, from: self.speciesRow)
  425. }
  426. private func chooseBreed() {
  427. guard isEditingMode else { return }
  428. guard let sp = selectedSpecies ?? ((Int(pet.categoryId ?? "0") ?? 0) == 3 ? "狗" : ((Int(pet.categoryId ?? "0") ?? 0) == 4 ? "猫" : nil)) else {
  429. let ac = UIAlertController(title: "请先选择种类", message: nil, preferredStyle: .alert)
  430. ac.addAction(UIAlertAction(title: "确定", style: .default))
  431. present(ac, animated: true); return
  432. }
  433. let categoryId: Int
  434. switch sp {
  435. case "狗": categoryId = 3
  436. case "猫": categoryId = 4
  437. default: categoryId = 0
  438. }
  439. let vc = BreedPickerViewController(categoryId: categoryId, token: UserDefaults.standard.string(forKey: "userToken"))
  440. vc.onSelect = { [weak self] breedId, breedName in
  441. self?.selectedBreedName = breedName
  442. self?.breedRow.value = breedName
  443. self?.selectedBreedId = breedId
  444. self?.navigationController?.popViewController(animated: true)
  445. }
  446. navigationController?.pushViewController(vc, animated: true)
  447. }
  448. private func chooseGender() {
  449. guard isEditingMode else { return }
  450. let ac = UIAlertController(title: "选择性别", message: nil, preferredStyle: .actionSheet)
  451. ["公", "母"].forEach { t in ac.addAction(UIAlertAction(title: t, style: .default, handler: { _ in
  452. self.selectedGender = t
  453. self.genderRow.value = t
  454. })) }
  455. ac.addAction(UIAlertAction(title: "取消", style: .cancel))
  456. // ✅ 关键:指定 popover 锚点
  457. presentSheet(ac, from: self.genderRow)
  458. }
  459. // MARK: - Present action sheet safely on iPad
  460. private func presentSheet(_ ac: UIAlertController, from source: UIView) {
  461. if let pop = ac.popoverPresentationController {
  462. // 优先用触发的控件作为锚点
  463. if source.window != nil {
  464. pop.sourceView = source
  465. pop.sourceRect = source.bounds
  466. pop.permittedArrowDirections = [.up, .down]
  467. } else {
  468. // 兜底:用整个视图中心作为锚点(极少数情况下 source 还未在窗口层级里)
  469. pop.sourceView = self.view
  470. pop.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.midY, width: 1, height: 1)
  471. pop.permittedArrowDirections = []
  472. }
  473. }
  474. self.present(ac, animated: true)
  475. }
  476. // MARK: - Helpers
  477. private func parseServerDate(_ s: String?) -> Date? {
  478. guard let s = s, !s.isEmpty else { return nil }
  479. // 兼容 2025-08-20T00:00:00.000+08:00
  480. let fmt = DateFormatter()
  481. fmt.locale = Locale(identifier: "en_US_POSIX")
  482. fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"
  483. return fmt.date(from: s)
  484. }
  485. private func loadImage(from url: URL, completion: @escaping (UIImage?) -> Void) {
  486. let key = url.absoluteString as NSString
  487. if let c = imageCache.object(forKey: key) {
  488. completion(c); return
  489. }
  490. URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
  491. var img: UIImage? = nil
  492. if let data = data { img = UIImage(data: data) }
  493. if let i = img { self?.imageCache.setObject(i, forKey: key) }
  494. DispatchQueue.main.async { completion(img) }
  495. }.resume()
  496. }
  497. private func showToast(_ message: String) {
  498. 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))
  499. toastLabel.backgroundColor = UIColor.black.withAlphaComponent(0.7)
  500. toastLabel.textColor = UIColor.white
  501. toastLabel.textAlignment = .center
  502. toastLabel.font = .systemFont(ofSize: 14)
  503. toastLabel.text = message
  504. toastLabel.alpha = 1.0
  505. toastLabel.layer.cornerRadius = 10
  506. toastLabel.clipsToBounds = true
  507. self.view.addSubview(toastLabel)
  508. UIView.animate(withDuration: 1, delay: 1, options: .curveEaseOut, animations: {
  509. toastLabel.alpha = 0.0
  510. }, completion: { _ in
  511. toastLabel.removeFromSuperview()
  512. })
  513. }
  514. }