HomeViewController.swift 78 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933
  1. //
  2. // HomeViewController.swift
  3. // VenusKitto
  4. //
  5. // Created by Neoa on 2025/8/22.
  6. //
  7. import Foundation
  8. import UIKit
  9. // MARK: - HomeViewController
  10. class HomeViewController: UIViewController, UIPopoverPresentationControllerDelegate {
  11. // UI
  12. private let scrollView = UIScrollView()
  13. private let contentView = UIView()
  14. private let titleLabel: UILabel = {
  15. let l = UILabel()
  16. l.text = "记录"
  17. l.font = .systemFont(ofSize: 28, weight: .semibold)
  18. l.textColor = UIColor(hex: "#2B2B2B")
  19. return l
  20. }()
  21. private lazy var settingsButton: UIButton = {
  22. let b = UIButton(type: .system)
  23. if let img = UIImage(systemName: "gearshape") {
  24. b.setImage(img, for: .normal)
  25. } else {
  26. b.setTitle("⚙️", for: .normal)
  27. }
  28. b.tintColor = UIColor(hex: "#2B2B2B")
  29. b.addTarget(self, action: #selector(tapSettings), for: .touchUpInside)
  30. return b
  31. }()
  32. private let petCard = PetHeaderCard()
  33. private let tagScroll = UIScrollView()
  34. private let tagStack = UIStackView()
  35. private let recordStack = UIStackView()
  36. // 空视图组件
  37. private lazy var emptyStateView: UIView = {
  38. let container = UIView()
  39. container.backgroundColor = .clear
  40. let imageView = UIImageView()
  41. imageView.image = UIImage(named: "emptydata") // 使用现有的图片资源
  42. imageView.contentMode = .scaleAspectFit
  43. // imageView.tintColor = UIColor(hex: "#CFCFCF")
  44. let titleLabel = UILabel()
  45. titleLabel.text = "暂无记录"
  46. titleLabel.font = .systemFont(ofSize: 18, weight: .medium)
  47. titleLabel.textColor = UIColor(hex: "#CFCFCF")
  48. titleLabel.textAlignment = .center
  49. let subtitleLabel = UILabel()
  50. subtitleLabel.text = "快去记录第一个美好时刻吧~"
  51. subtitleLabel.font = .systemFont(ofSize: 14)
  52. subtitleLabel.textColor = UIColor(hex: "#CFCFCF")
  53. subtitleLabel.textAlignment = .center
  54. let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel, subtitleLabel])
  55. stackView.axis = .vertical
  56. stackView.alignment = .center
  57. stackView.spacing = 16
  58. container.addSubview(stackView)
  59. stackView.translatesAutoresizingMaskIntoConstraints = false
  60. imageView.translatesAutoresizingMaskIntoConstraints = false
  61. NSLayoutConstraint.activate([
  62. imageView.widthAnchor.constraint(equalToConstant: 80),
  63. imageView.heightAnchor.constraint(equalToConstant: 80),
  64. stackView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  65. stackView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  66. stackView.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 40),
  67. stackView.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -40)
  68. ])
  69. container.isHidden = true
  70. return container
  71. }()
  72. private let addButton: UIButton = {
  73. let b = UIButton(type: .custom)
  74. b.setImage(UIImage(named: "Home357"), for: .normal)
  75. // b.tintColor = .black
  76. // b.backgroundColor = UIColor(hex: "#FFE059")
  77. b.layer.cornerRadius = 28
  78. b.layer.shadowColor = UIColor.black.cgColor
  79. b.layer.shadowOpacity = 0.15
  80. b.layer.shadowRadius = 8
  81. b.layer.shadowOffset = .init(width: 0, height: 4)
  82. b.addTarget(self, action: #selector(tapAdd), for: .touchUpInside)
  83. return b
  84. }()
  85. private let tabBarView = BottomTabBar()
  86. // --- Action menu (pop when tapping +) ---
  87. private var isActionMenuShown = false
  88. private let overlayView = UIControl()
  89. private let glowView = UIView()
  90. private let leftActionStack = UIStackView()
  91. private let leftActionButton = UIButton(type: .custom)
  92. private let leftActionLabel = UILabel()
  93. private let rightActionStack = UIStackView()
  94. private let rightActionButton = UIButton(type: .custom)
  95. private let rightActionLabel = UILabel()
  96. // Glow effect layers/animation
  97. private let glowGradient = CAGradientLayer()
  98. // --- Create Record Sheet ---
  99. private var createRecordSheet: CreateRecordSheet?
  100. // 记账分类弹窗(快速记账)
  101. private var bookkeepingQuickSheet: BookkeepingQuickSheet?
  102. private var bookkeepingAmountSheet: BookkeepingAmountSheet?
  103. // Embedded bookkeeping page (keeps bottom tab visible)
  104. private var bookkeepingVC: BookkeepingViewController?
  105. // --- Pets (list & switching) ---
  106. private var pets: [PetSummary] = []
  107. private var currentPetIndex: Int = 0
  108. private let imageCache = NSCache<NSString, UIImage>()
  109. // --- Empty state & add pet ---
  110. private var hasPet: Bool = false
  111. private let addPetButton: UIButton = {
  112. let b = UIButton(type: .system)
  113. b.setTitle("添加+", for: .normal)
  114. b.setTitleColor(.black, for: .normal)
  115. b.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
  116. // b.contentEdgeInsets = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 18)
  117. return b
  118. }()
  119. private let emptyTipLabel: UILabel = {
  120. let l = UILabel()
  121. l.text = "还没有添加你的宠物哦~"
  122. l.font = .systemFont(ofSize: 16, weight: .medium)
  123. l.textColor = UIColor(hex: "#6B6B6B")
  124. l.textAlignment = .center
  125. return l
  126. }()
  127. private let emptyBanner: UIImageView = {
  128. let v = UIImageView()
  129. v.contentMode = .scaleToFill
  130. v.image = UIImage(named: "Home374")
  131. v.backgroundColor = UIColor(hex: "#FFF3D9")
  132. v.layer.cornerRadius = 12
  133. return v
  134. }()
  135. // MARK: - Pet names row
  136. private let petNamesScroll = UIScrollView()
  137. private let petNamesStack = UIStackView()
  138. private var petNameButtons: [UIButton] = []
  139. override func viewDidLoad() {
  140. super.viewDidLoad()
  141. view.backgroundColor = .white
  142. addTopGradient()
  143. layout()
  144. populateDemo()
  145. setHasPet(true)
  146. fetchMemberInfo()
  147. // Tap pet card to switch pets
  148. let petTap = UITapGestureRecognizer(target: self, action: #selector(showPetPicker))
  149. petCard.isUserInteractionEnabled = true
  150. petCard.addGestureRecognizer(petTap)
  151. }
  152. private func addTopGradient() {
  153. // 顶部淡米色渐变 -> 白色
  154. let g = CAGradientLayer()
  155. g.colors = [UIColor(hex: "#FFF3D9").cgColor, UIColor.white.cgColor]
  156. g.startPoint = CGPoint(x: 0.5, y: 0.0)
  157. g.endPoint = CGPoint(x: 0.5, y: 0.4)
  158. g.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 220)
  159. view.layer.insertSublayer(g, at: 0)
  160. }
  161. private func layout() {
  162. // Scroll
  163. scrollView.showsVerticalScrollIndicator = false
  164. view.addSubview(scrollView)
  165. scrollView.translatesAutoresizingMaskIntoConstraints = false
  166. contentView.translatesAutoresizingMaskIntoConstraints = false
  167. scrollView.addSubview(contentView)
  168. // Header
  169. [titleLabel, settingsButton, petCard, tagScroll, recordStack, emptyStateView].forEach {
  170. $0.translatesAutoresizingMaskIntoConstraints = false
  171. contentView.addSubview($0)
  172. }
  173. // Empty state & add pet
  174. addPetButton.translatesAutoresizingMaskIntoConstraints = false
  175. emptyTipLabel.translatesAutoresizingMaskIntoConstraints = false
  176. emptyBanner.translatesAutoresizingMaskIntoConstraints = false
  177. contentView.addSubview(addPetButton)
  178. contentView.addSubview(emptyTipLabel)
  179. contentView.addSubview(emptyBanner)
  180. styleAddPetButton()
  181. addPetButton.addTarget(self, action: #selector(tapAddPet), for: .touchUpInside)
  182. // Tag row
  183. tagScroll.showsHorizontalScrollIndicator = false
  184. tagStack.axis = .horizontal
  185. tagStack.spacing = 12
  186. tagStack.alignment = .center
  187. tagScroll.addSubview(tagStack)
  188. tagStack.translatesAutoresizingMaskIntoConstraints = false
  189. // Records
  190. recordStack.axis = .vertical
  191. recordStack.spacing = 16
  192. // Bottom bar & add button
  193. view.addSubview(tabBarView)
  194. tabBarView.translatesAutoresizingMaskIntoConstraints = false
  195. view.addSubview(addButton)
  196. addButton.translatesAutoresizingMaskIntoConstraints = false
  197. // —— 添加“所有宠物名字”的横向滚动条 —— //
  198. petNamesScroll.showsHorizontalScrollIndicator = false
  199. petNamesScroll.translatesAutoresizingMaskIntoConstraints = false
  200. contentView.addSubview(petNamesScroll)
  201. petNamesStack.axis = .horizontal
  202. petNamesStack.alignment = .center
  203. petNamesStack.spacing = 12
  204. petNamesStack.translatesAutoresizingMaskIntoConstraints = false
  205. petNamesScroll.addSubview(petNamesStack)
  206. NSLayoutConstraint.activate([
  207. // Scroll/Content
  208. scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
  209. scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  210. scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  211. scrollView.bottomAnchor.constraint(equalTo: tabBarView.topAnchor),
  212. contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
  213. contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
  214. contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
  215. contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
  216. contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
  217. // Header row
  218. titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
  219. titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
  220. settingsButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
  221. settingsButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
  222. settingsButton.heightAnchor.constraint(equalToConstant: 28),
  223. settingsButton.widthAnchor.constraint(equalToConstant: 28),
  224. // Add Pet Button (below titleLabel)
  225. addPetButton.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
  226. addPetButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), addPetButton.heightAnchor.constraint(equalToConstant: 26),
  227. addPetButton.widthAnchor.constraint(equalToConstant: 64),
  228. // Pet names row
  229. petNamesScroll.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
  230. petNamesScroll.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
  231. petNamesScroll.trailingAnchor.constraint(equalTo: addPetButton.leadingAnchor, constant: -8),
  232. petNamesScroll.heightAnchor.constraint(equalToConstant: 26),
  233. petNamesStack.leadingAnchor.constraint(equalTo: petNamesScroll.leadingAnchor),
  234. petNamesStack.trailingAnchor.constraint(equalTo: petNamesScroll.trailingAnchor),
  235. petNamesStack.topAnchor.constraint(equalTo: petNamesScroll.topAnchor),
  236. petNamesStack.bottomAnchor.constraint(equalTo: petNamesScroll.bottomAnchor),
  237. // Pet card
  238. petCard.topAnchor.constraint(equalTo: petNamesScroll.bottomAnchor, constant: 17), petCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
  239. petCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
  240. petCard.heightAnchor.constraint(equalToConstant: 103),
  241. // Empty tip label (below petCard)
  242. emptyTipLabel.topAnchor.constraint(equalTo: petCard.bottomAnchor, constant: 137),
  243. emptyTipLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
  244. // Empty banner (below emptyTipLabel)
  245. emptyBanner.topAnchor.constraint(equalTo: emptyTipLabel.bottomAnchor, constant: 16),
  246. emptyBanner.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
  247. emptyBanner.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
  248. emptyBanner.heightAnchor.constraint(equalToConstant: 120),
  249. // Tag row
  250. tagScroll.topAnchor.constraint(equalTo: petCard.bottomAnchor, constant: 12),
  251. tagScroll.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
  252. tagScroll.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
  253. tagScroll.heightAnchor.constraint(equalToConstant: 38),
  254. tagStack.leadingAnchor.constraint(equalTo: tagScroll.leadingAnchor),
  255. tagStack.trailingAnchor.constraint(equalTo: tagScroll.trailingAnchor),
  256. tagStack.topAnchor.constraint(equalTo: tagScroll.topAnchor),
  257. tagStack.bottomAnchor.constraint(equalTo: tagScroll.bottomAnchor),
  258. tagStack.heightAnchor.constraint(equalTo: tagScroll.heightAnchor),
  259. // Records list
  260. recordStack.topAnchor.constraint(equalTo: tagScroll.bottomAnchor, constant: 12),
  261. recordStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
  262. recordStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
  263. recordStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -24),
  264. // Empty state view
  265. emptyStateView.topAnchor.constraint(equalTo: tagScroll.bottomAnchor, constant: 50),
  266. emptyStateView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
  267. emptyStateView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
  268. emptyStateView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -100),
  269. // Bottom tab bar
  270. tabBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  271. tabBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  272. tabBarView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
  273. // Add button (浮在中间)
  274. addButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  275. addButton.centerYAnchor.constraint(equalTo: tabBarView.topAnchor),
  276. addButton.widthAnchor.constraint(equalToConstant: 56),
  277. addButton.heightAnchor.constraint(equalToConstant: 56)
  278. ])
  279. // Overlay (dim background)
  280. overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
  281. overlayView.alpha = 0
  282. overlayView.isHidden = true
  283. overlayView.addTarget(self, action: #selector(hideActionMenu), for: .touchUpInside)
  284. view.insertSubview(overlayView, belowSubview: addButton)
  285. overlayView.translatesAutoresizingMaskIntoConstraints = false
  286. // Glow behind add button
  287. glowView.backgroundColor = UIColor.clear
  288. glowView.layer.cornerRadius = 28
  289. glowView.alpha = 0
  290. glowView.isHidden = true
  291. view.insertSubview(glowView, belowSubview: addButton)
  292. glowView.translatesAutoresizingMaskIntoConstraints = false
  293. // Configure glow gradient & shadow
  294. glowView.backgroundColor = .clear
  295. glowGradient.type = .radial
  296. // Neon glow color strictly FFE059
  297. glowGradient.colors = [
  298. UIColor(hex: "#FFE059").withAlphaComponent(0.95).cgColor,
  299. UIColor(hex: "#FFE059").withAlphaComponent(0.45).cgColor,
  300. UIColor(hex: "#FFE059").withAlphaComponent(0.0).cgColor
  301. ]
  302. glowGradient.locations = [0.0, 0.6, 1.0]
  303. // Keep glow gradient sized to the glowView
  304. glowGradient.frame = glowView.bounds
  305. glowGradient.cornerRadius = glowView.bounds.width/2
  306. glowView.layer.cornerRadius = glowView.bounds.width/2
  307. glowGradient.startPoint = CGPoint(x: 0.5, y: 0.5)
  308. glowGradient.endPoint = CGPoint(x: 1, y: 1)
  309. glowGradient.frame = glowView.bounds
  310. glowGradient.cornerRadius = glowView.bounds.width/2
  311. glowView.layer.insertSublayer(glowGradient, at: 0)
  312. // soft outer shadow
  313. glowView.layer.shadowColor = UIColor(hex: "#FFE059").cgColor
  314. glowView.layer.shadowOpacity = 0.9
  315. glowView.layer.shadowRadius = 24
  316. glowView.layer.shadowOffset = .zero
  317. glowGradient.frame = glowView.bounds
  318. glowGradient.cornerRadius = glowView.bounds.width/2
  319. // Action stacks
  320. func makeActionStack(button: UIButton, label: UILabel, systemImage: String, title: String) -> UIStackView {
  321. if let img = UIImage(named: systemImage) {
  322. button.setImage(img.withRenderingMode(.alwaysOriginal), for: .normal)
  323. } else {
  324. #if DEBUG
  325. print("⚠️ Image not found: \(systemImage)")
  326. #endif
  327. }
  328. button.imageView?.contentMode = .scaleAspectFit
  329. button.tintColor = .black
  330. button.backgroundColor = .white
  331. button.layer.cornerRadius = 28
  332. button.layer.shadowColor = UIColor.black.cgColor
  333. button.layer.shadowOpacity = 0.15
  334. button.layer.shadowRadius = 6
  335. button.layer.shadowOffset = CGSize(width: 0, height: 3)
  336. button.translatesAutoresizingMaskIntoConstraints = false
  337. NSLayoutConstraint.activate([
  338. button.widthAnchor.constraint(equalToConstant: 56),
  339. button.heightAnchor.constraint(equalToConstant: 56)
  340. ])
  341. label.text = title
  342. label.font = .systemFont(ofSize: 12, weight: .medium)
  343. label.textColor = .white
  344. label.textAlignment = .center
  345. let stack = UIStackView(arrangedSubviews: [button, label])
  346. stack.axis = .vertical
  347. stack.alignment = .center
  348. stack.spacing = 8
  349. stack.alpha = 0
  350. stack.isHidden = true
  351. stack.translatesAutoresizingMaskIntoConstraints = false
  352. return stack
  353. }
  354. let leftStack = makeActionStack(button: leftActionButton, label: leftActionLabel, systemImage: "Home359", title: "事件")
  355. let rightStack = makeActionStack(button: rightActionButton, label: rightActionLabel, systemImage: "Home360", title: "记账")
  356. // leftActionStack.addArrangedSubview(leftActionButton) // keep references populated (optional)
  357. // rightActionStack.addArrangedSubview(rightActionButton)
  358. view.addSubview(leftStack)
  359. view.addSubview(rightStack)
  360. leftActionButton.addTarget(self, action: #selector(tapQuickEvent), for: .touchUpInside)
  361. rightActionButton.addTarget(self, action: #selector(tapQuickNote), for: .touchUpInside)
  362. NSLayoutConstraint.activate([
  363. overlayView.topAnchor.constraint(equalTo: view.topAnchor),
  364. overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  365. overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  366. overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  367. glowView.centerXAnchor.constraint(equalTo: addButton.centerXAnchor),
  368. glowView.centerYAnchor.constraint(equalTo: addButton.centerYAnchor),
  369. glowView.widthAnchor.constraint(equalToConstant: 64),
  370. glowView.heightAnchor.constraint(equalToConstant: 64),
  371. leftStack.centerXAnchor.constraint(equalTo: addButton.centerXAnchor, constant: -90),
  372. leftStack.bottomAnchor.constraint(equalTo: addButton.topAnchor, constant: -24),
  373. rightStack.centerXAnchor.constraint(equalTo: addButton.centerXAnchor, constant: 90),
  374. rightStack.bottomAnchor.constraint(equalTo: addButton.topAnchor, constant: -24)
  375. ])
  376. // Store stacks for later animations by tagging via accessibilityIdentifier
  377. leftStack.accessibilityIdentifier = "leftActionStack"
  378. rightStack.accessibilityIdentifier = "rightActionStack"
  379. // 注册宠物保存通知
  380. NotificationCenter.default.addObserver(self, selector: #selector(handlePetDidSave(_:)), name: .petDidSave, object: nil)
  381. NotificationCenter.default.addObserver(self, selector: #selector(handleEventRecordCreated(_:)), name: .eventRecordDidCreate, object: nil)
  382. }
  383. @objc private func handleEventRecordCreated(_ note: Notification) {
  384. // 优先取通知里传来的 petId;否则用当前选中的宠物
  385. let pid = (note.userInfo?["petId"] as? String) ?? (currentPetIndex < pets.count ? pets[currentPetIndex].id : "")
  386. guard !pid.isEmpty else { return }
  387. // 刷新分类与事件记录(默认展示全部)
  388. fetchRecordCategories(petId: pid)
  389. fetchEventRecords(petId: pid, keyword: "")
  390. }
  391. @objc private func handlePetDidSave(_ note: Notification) {
  392. // Prefer userId from notification; fallback to persisted value
  393. let uidFromNote = note.userInfo?["userId"] as? String
  394. let persistedStr = UserDefaults.standard.string(forKey: "userId")
  395. let persistedInt = UserDefaults.standard.integer(forKey: "userId")
  396. let uid = uidFromNote ?? persistedStr ?? (persistedInt == 0 ? "" : String(persistedInt))
  397. guard !uid.isEmpty else { return }
  398. // 重新拉取宠物列表并刷新UI
  399. fetchPetsForUser(userId: uid)
  400. }
  401. @objc private func tapAddPet() {
  402. // 检查登录状态
  403. let isLoggedIn = UserDefaults.standard.bool(forKey: "isLogggedIn")
  404. let userToken = UserDefaults.standard.string(forKey: "userToken")
  405. // 如果没有登录状态或者没有有效的token,弹出登录界面
  406. if !isLoggedIn || userToken?.isEmpty != false {
  407. showLoginViewController()
  408. return
  409. }
  410. let vc = AddPetViewController()
  411. navigationController?.pushViewController(vc, animated: true)
  412. }
  413. private func showLoginViewController() {
  414. let loginVC = LoginViewController()
  415. let navController = UINavigationController(rootViewController: loginVC)
  416. navController.modalPresentationStyle = .fullScreen
  417. present(navController, animated: true)
  418. }
  419. private func styleAddPetButton() {
  420. addPetButton.backgroundColor = UIColor(hex: "#FFE059")
  421. addPetButton.layer.cornerRadius = 13
  422. addPetButton.clipsToBounds = true
  423. addPetButton.setTitleColor(.black, for: .normal)
  424. addPetButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .regular)
  425. // addPetButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 18)
  426. }
  427. private func setHasPet(_ value: Bool) {
  428. hasPet = value
  429. // addPetButton.isHidden = value
  430. tagScroll.isHidden = !value
  431. recordStack.isHidden = !value
  432. emptyStateView.isHidden = value // 没有宠物时显示空视图
  433. emptyTipLabel.isHidden = value
  434. emptyBanner.isHidden = value
  435. if !value {
  436. petCard.configure(
  437. avatar: UIImage(named: "Home372")!,
  438. name: "添加我的宠物",
  439. breed: "品种",
  440. ageText: "年龄",
  441. daysText: "一起生活的第XX天"
  442. )
  443. }
  444. // If true, do not touch petCard (keep current configuration)
  445. }
  446. private func populateDemo() {
  447. // Pet card demo
  448. petCard.configure(
  449. avatar: UIImage(named: "Home372") ?? UIImage(systemName: "pawprint")!,
  450. name: "宠物名字",
  451. breed: "宠物品种",
  452. ageText: "XX岁",
  453. daysText: "一起生活的第XX天"
  454. )
  455. tabBarView.onSelect = { [weak self] idx in
  456. guard let self = self else { return }
  457. if idx == 0 {
  458. // 事件:显示首页,关闭记账浮层
  459. self.hideBookkeeping()
  460. let topY = -self.scrollView.adjustedContentInset.top
  461. self.scrollView.setContentOffset(CGPoint(x: 0, y: topY), animated: true)
  462. } else {
  463. // 记账:作为子控制器叠加在内容区域(tabbar 之上)
  464. self.showBookkeeping()
  465. }
  466. }
  467. }
  468. @objc private func tapSettings() {
  469. print("Settings tapped")
  470. let vc = SettingsViewController()
  471. navigationController?.pushViewController(vc, animated: true)
  472. }
  473. @objc private func tapAdd() {
  474. isActionMenuShown ? hideActionMenu() : showActionMenu()
  475. }
  476. private func actionStacks() -> [UIStackView] {
  477. return view.subviews.compactMap { ($0 as? UIStackView)?.accessibilityIdentifier?.contains("ActionStack") == true ? ($0 as! UIStackView) : nil }
  478. }
  479. private func findStack(id: String) -> UIStackView? {
  480. view.subviews.first { ($0 as? UIStackView)?.accessibilityIdentifier == id } as? UIStackView
  481. }
  482. private func showActionMenu() {
  483. guard let leftStack = findStack(id: "leftActionStack"), let rightStack = findStack(id: "rightActionStack") else { return }
  484. isActionMenuShown = true
  485. overlayView.isHidden = false
  486. glowView.isHidden = false
  487. glowGradient.frame = glowView.bounds
  488. leftStack.isHidden = false
  489. rightStack.isHidden = false
  490. addButton.setImage(UIImage(named: "Home358"), for: .normal)
  491. startGlowPulse()
  492. view.layoutIfNeeded()
  493. leftStack.transform = CGAffineTransform(translationX: 0, y: 20)
  494. rightStack.transform = CGAffineTransform(translationX: 0, y: 20)
  495. UIView.animate(withDuration: 0.22) {
  496. self.overlayView.alpha = 1
  497. self.glowView.alpha = 1
  498. leftStack.alpha = 1; rightStack.alpha = 1
  499. leftStack.transform = .identity
  500. rightStack.transform = .identity
  501. }
  502. }
  503. @objc private func hideActionMenu() {
  504. guard let leftStack = findStack(id: "leftActionStack"), let rightStack = findStack(id: "rightActionStack") else { return }
  505. UIView.animate(withDuration: 0.2, animations: {
  506. self.overlayView.alpha = 0
  507. self.glowView.alpha = 0
  508. leftStack.alpha = 0; rightStack.alpha = 0
  509. leftStack.transform = CGAffineTransform(translationX: 0, y: 10)
  510. rightStack.transform = CGAffineTransform(translationX: 0, y: 10)
  511. }, completion: { _ in
  512. self.overlayView.isHidden = true
  513. leftStack.isHidden = true
  514. rightStack.isHidden = true
  515. self.addButton.setImage(UIImage(named: "Home357"), for: .normal)
  516. self.isActionMenuShown = false
  517. self.glowView.isHidden = true
  518. self.glowGradient.removeAllAnimations()
  519. self.stopGlowPulse()
  520. })
  521. }
  522. @objc private func tapQuickEvent() {
  523. print("快速进入 随手小记")
  524. // 检查登录状态
  525. let isLoggedIn = UserDefaults.standard.bool(forKey: "isLogggedIn")
  526. let userToken = UserDefaults.standard.string(forKey: "userToken")
  527. // 如果没有登录状态或者没有有效的token,弹出登录界面
  528. if !isLoggedIn || userToken?.isEmpty != false {
  529. showLoginViewController()
  530. return
  531. }
  532. hideActionMenu()
  533. showCreateRecordSheet()
  534. }
  535. @objc private func tapQuickNote() {
  536. print("快速进入 记账")
  537. // 检查登录状态
  538. let isLoggedIn = UserDefaults.standard.bool(forKey: "isLogggedIn")
  539. let userToken = UserDefaults.standard.string(forKey: "userToken")
  540. // 如果没有登录状态或者没有有效的token,弹出登录界面
  541. if !isLoggedIn || userToken?.isEmpty != false {
  542. showLoginViewController()
  543. return
  544. }
  545. hideActionMenu()
  546. showBookkeepingQuickSheet()
  547. }
  548. private func startGlowPulse() {
  549. if glowGradient.animation(forKey: "pulse") != nil { return }
  550. let scale = CABasicAnimation(keyPath: "transform.scale")
  551. scale.fromValue = 0.95
  552. scale.toValue = 1.15
  553. scale.duration = 1.1
  554. scale.autoreverses = true
  555. scale.repeatCount = .infinity
  556. let opacity = CABasicAnimation(keyPath: "opacity")
  557. opacity.fromValue = 0.7
  558. opacity.toValue = 1.0
  559. opacity.duration = 1.1
  560. opacity.autoreverses = true
  561. opacity.repeatCount = .infinity
  562. let group = CAAnimationGroup()
  563. group.animations = [scale, opacity]
  564. group.duration = 1.1
  565. group.autoreverses = true
  566. group.repeatCount = .infinity
  567. glowGradient.add(group, forKey: "pulse")
  568. }
  569. private func stopGlowPulse() {
  570. glowGradient.removeAnimation(forKey: "pulse")
  571. }
  572. // MARK: - Embed/Remove Bookkeeping (child VC keeps bottom tab visible)
  573. private func showBookkeeping() {
  574. // 已经展示就跳过
  575. if let _ = bookkeepingVC { return }
  576. let vc = BookkeepingViewController()
  577. addChild(vc)
  578. view.insertSubview(vc.view, belowSubview: overlayView)
  579. vc.view.translatesAutoresizingMaskIntoConstraints = false
  580. // 占据安全区顶部到 tabBar 顶部之间的区域(不遮挡底部导航)
  581. NSLayoutConstraint.activate([
  582. vc.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
  583. vc.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  584. vc.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  585. vc.view.bottomAnchor.constraint(equalTo: tabBarView.topAnchor)
  586. ])
  587. // 先确保 view 已加载,再设置宠物
  588. vc.loadViewIfNeeded()
  589. vc.setPets(pets.map { ($0.id, $0.name) }, selectedIndex: currentPetIndex)
  590. // keep "+"、glow、action stacks and overlay above bookkeeping
  591. self.view.bringSubviewToFront(self.overlayView)
  592. self.view.bringSubviewToFront(self.addButton)
  593. self.view.bringSubviewToFront(self.glowView)
  594. if let l = self.findStack(id: "leftActionStack") { self.view.bringSubviewToFront(l) }
  595. if let r = self.findStack(id: "rightActionStack") { self.view.bringSubviewToFront(r) }
  596. vc.didMove(toParent: self)
  597. bookkeepingVC = vc
  598. // 可选:淡入
  599. vc.view.alpha = 0
  600. UIView.animate(withDuration: 0.22) { vc.view.alpha = 1 }
  601. }
  602. private func hideBookkeeping() {
  603. guard let vc = bookkeepingVC else { return }
  604. // 可选:淡出
  605. UIView.animate(withDuration: 0.18, animations: { vc.view.alpha = 0 }) { _ in
  606. vc.willMove(toParent: nil)
  607. vc.view.removeFromSuperview()
  608. vc.removeFromParent()
  609. self.bookkeepingVC = nil
  610. }
  611. }
  612. // MARK: - Create Record Sheet (Custom Modal)
  613. private func showCreateRecordSheet() {
  614. if createRecordSheet == nil {
  615. let sheet = CreateRecordSheet()
  616. sheet.viewController = self
  617. createRecordSheet = sheet
  618. view.addSubview(sheet)
  619. sheet.translatesAutoresizingMaskIntoConstraints = false
  620. NSLayoutConstraint.activate([
  621. sheet.topAnchor.constraint(equalTo: view.topAnchor),
  622. sheet.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  623. sheet.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  624. sheet.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  625. ])
  626. // Ensure the sheet is above everything (including bookkeeping)
  627. self.view.bringSubviewToFront(sheet)
  628. }
  629. createRecordSheet?.show()
  630. }
  631. // MARK: - Quick Bookkeeping Sheet (弹窗)
  632. private func showBookkeepingQuickSheet() {
  633. if bookkeepingQuickSheet == nil {
  634. let s = BookkeepingQuickSheet()
  635. // 选中某个记账分类后的回调(后续你可以在这里跳转到“新增记账”编辑页、带上已选分类)
  636. s.onItemSelected = { [weak self] name in
  637. print("选择记账分类: \(name)")
  638. // 点击分类后,叠加展示金额输入弹窗
  639. self?.showBookkeepingAmountSheet(category: name)
  640. }
  641. s.onDismiss = { [weak self] in
  642. // 可留空;若想释放:self?.bookkeepingQuickSheet = nil
  643. }
  644. bookkeepingQuickSheet = s
  645. view.addSubview(s)
  646. s.translatesAutoresizingMaskIntoConstraints = false
  647. NSLayoutConstraint.activate([
  648. s.topAnchor.constraint(equalTo: view.topAnchor),
  649. s.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  650. s.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  651. s.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  652. ])
  653. }
  654. if let s = bookkeepingQuickSheet {
  655. // 确保在最上层(不被子控制器等覆盖)
  656. view.bringSubviewToFront(s)
  657. s.show()
  658. }
  659. }
  660. private func showBookkeepingAmountSheet(category: String) {
  661. if bookkeepingAmountSheet == nil {
  662. let s = BookkeepingAmountSheet()
  663. // 日期选择:优先用你的 DatePickerSheetController;没有就用内置 actionSheet
  664. s.onPickDate = { [weak self] current, handler in
  665. guard let self = self else { return }
  666. if let cls = NSClassFromString("DatePickerSheetController") as? UIViewController.Type,
  667. let picker = (cls as? NSObject.Type)?.init() as? UIViewController {
  668. // 如果你项目里的 DatePickerSheetController 对外有回调 onConfirm(Date)
  669. picker.setValue({ (d: Date) in
  670. handler(d)
  671. picker.dismiss(animated: true)
  672. }, forKey: "onConfirm")
  673. picker.setValue(current, forKey: "initialDate")
  674. self.present(picker, animated: true)
  675. } else {
  676. // 兜底:系统 UIDatePicker
  677. let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet)
  678. ac.modalPresentationStyle = .popover
  679. if let p = ac.popoverPresentationController{
  680. // 锚到视图底部中间;去掉箭头,效果更像底部弹出
  681. p.sourceView = self.view
  682. p.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.maxY - 1, width: 1, height: 1)
  683. p.permittedArrowDirections = []
  684. p.delegate = self
  685. }
  686. let dp = UIDatePicker(frame: .zero)
  687. dp.datePickerMode = .date
  688. if #available(iOS 13.4, *) {
  689. dp.preferredDatePickerStyle = .wheels
  690. } else {
  691. // Fallback on earlier versions
  692. }
  693. dp.date = current
  694. ac.view.addSubview(dp)
  695. dp.translatesAutoresizingMaskIntoConstraints = false
  696. NSLayoutConstraint.activate([
  697. dp.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor),
  698. dp.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8)
  699. ])
  700. ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { _ in handler(dp.date) }))
  701. ac.addAction(UIAlertAction(title: "取消", style: .cancel))
  702. self.present(ac, animated: true)
  703. }
  704. }
  705. // 宠物选择:用当前拿到的 pets 做一个 ActionSheet
  706. s.onPickPet = { [weak self] handler in
  707. guard let self = self else { return }
  708. guard !self.pets.isEmpty else { return }
  709. let ac = UIAlertController(title: "选择宠物", message: nil, preferredStyle: .actionSheet)
  710. ac.modalPresentationStyle = .popover
  711. if let p = ac.popoverPresentationController{
  712. p.sourceView = self.view
  713. p.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.maxY - 1, width: 1, height: 1)
  714. p.permittedArrowDirections = []
  715. p.delegate = self
  716. }
  717. for p in self.pets {
  718. ac.addAction(UIAlertAction(title: p.name, style: .default, handler: { _ in handler(p) }))
  719. }
  720. ac.addAction(UIAlertAction(title: "取消", style: .cancel))
  721. self.present(ac, animated: true)
  722. }
  723. // 保存:发POST到后端
  724. s.onSave = { [weak self] cate, date, pet, note, amountText in
  725. guard let self = self else { return }
  726. // 1) 解析 petId
  727. let current = (self.currentPetIndex >= 0 && self.currentPetIndex < self.pets.count) ? self.pets[self.currentPetIndex] : nil
  728. let usePet = pet ?? current
  729. guard let petIdStr = usePet?.id, let petId = Int(petIdStr) else {
  730. print("❌ 缺少 petId,无法提交")
  731. return
  732. }
  733. // 2) 金额
  734. let amt = Double(amountText.replacingOccurrences(of: ",", with: "")) ?? 0
  735. // 3) recordTypeId & 日期
  736. let typeId = self.recordTypeId(for: cate)
  737. let dateStr = self.formatSendDate(date)
  738. // 4) 组装请求
  739. guard let url = URL(string: "\(baseURL)/petRecordInfo/createRecord") else { return }
  740. var req = URLRequest(url: url)
  741. req.httpMethod = "POST"
  742. req.setValue("application/json", forHTTPHeaderField: "Content-Type")
  743. if let token = UserDefaults.standard.string(forKey: "userToken") {
  744. req.setValue(token, forHTTPHeaderField: "Authorization")
  745. }
  746. let body: [String: Any] = [
  747. "amount": amt,
  748. "note": note,
  749. "petId": petId,
  750. "recordDate": dateStr,
  751. "recordTypeId": typeId,
  752. "title": cate,
  753. "module": "account"
  754. ]
  755. do {
  756. req.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
  757. } catch {
  758. print("❌ 序列化失败: \(error)")
  759. return
  760. }
  761. URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in
  762. if let http = resp as? HTTPURLResponse { print("📮 createRecord status: \(http.statusCode)") }
  763. if let err = err { print("❌ createRecord error: \(err)"); return }
  764. guard let data = data else { print("❌ createRecord 空响应"); return }
  765. if let json = try? JSONSerialization.jsonObject(with: data, options: []) {
  766. print("✅ createRecord 响应: \(json)")
  767. }
  768. // 成功后可刷新当前宠物的列表(如果你希望记账列表也依赖这个接口)
  769. DispatchQueue.main.async {
  770. // ✅ 通知记账页刷新(携带 petId / 日期 / 类目 / 金额)
  771. NotificationCenter.default.post(
  772. name: .bookkeepingRecordDidCreate,
  773. object: nil,
  774. userInfo: [
  775. "petId": String(petId),
  776. "recordDate": dateStr,
  777. "title": cate,
  778. "amount": amt
  779. ]
  780. )
  781. }
  782. }.resume()
  783. }
  784. bookkeepingAmountSheet = s
  785. view.addSubview(s)
  786. s.translatesAutoresizingMaskIntoConstraints = false
  787. NSLayoutConstraint.activate([
  788. s.topAnchor.constraint(equalTo: view.topAnchor),
  789. s.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  790. s.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  791. s.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  792. ])
  793. }
  794. if let s = bookkeepingAmountSheet {
  795. // 传入当前选中的宠物
  796. let current = (currentPetIndex >= 0 && currentPetIndex < pets.count) ? pets[currentPetIndex] : nil
  797. s.configure(category: category, currentPet: current)
  798. view.bringSubviewToFront(s) // 放到最上层
  799. s.show()
  800. }
  801. }
  802. private func formatSendDate(_ d: Date) -> String {
  803. let f = DateFormatter()
  804. f.locale = Locale(identifier: "zh_CN")
  805. f.dateFormat = "yyyy-MM-dd"
  806. return f.string(from: d)
  807. }
  808. private func recordTypeId(for title: String) -> Int {
  809. // 记账(module: account)映射——按后端给的 id
  810. // 为了兼容不同文案,做了少量同义词映射(如“草粮”→25)
  811. let map: [String: Int] = [
  812. // 食物 21~25
  813. "干粮": 21,
  814. "罐头": 22,
  815. "零食": 23,
  816. "冻干": 24,
  817. "草根": 25,
  818. // 生活 26~30
  819. "衣服": 26,
  820. "玩具": 27,
  821. "餐具": 28,
  822. "厕所": 29,
  823. "牵引绳": 30,
  824. // 治病 31~35
  825. "驱虫药": 31,
  826. "保健品": 32,
  827. "手术": 33,
  828. "药品": 34,
  829. "疫苗": 35,
  830. // 清洁 36~40
  831. "指甲剪": 36,
  832. "梳子": 37,
  833. "清洁用品": 38,
  834. "洗澡美容": 39,
  835. "洗护用品": 40
  836. ]
  837. return map[title] ?? 0
  838. }
  839. // MARK: - Record Categories (single-select with "全部")
  840. private var currentCategories: [String] = []
  841. private var categoryPills: [DottedPill] = []
  842. private var selectedCategoryIndex: Int = 0 // 0 == 全部
  843. // MARK: - Member Info Networking
  844. private func fetchMemberInfo() {
  845. guard let url = URL(string: "\(baseURL)/petRecordApUser/info") else { return }
  846. var request = URLRequest(url: url)
  847. request.httpMethod = "POST"
  848. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  849. if let token = UserDefaults.standard.string(forKey: "userToken") {
  850. request.setValue(token, forHTTPHeaderField: "Authorization")
  851. }
  852. let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
  853. if let error = error {
  854. print("Error: \(error)")
  855. return
  856. }
  857. guard let data = data else { return }
  858. do {
  859. let decoder = JSONDecoder()
  860. let responseObject = try decoder.decode(MemberInfoResponse.self, from: data)
  861. if responseObject.code == "200" {
  862. // Store the data
  863. self?.storeMemberInfo(responseObject.data)
  864. } else {
  865. print("Error: \(responseObject.msg ?? "Unknown error")")
  866. }
  867. } catch {
  868. print("Error decoding response: \(error)")
  869. DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
  870. // 切换到登录界面
  871. self?.switchToLoginScreen()
  872. }
  873. }
  874. }
  875. task.resume()
  876. }
  877. private func switchToLoginScreen() {
  878. // 清除用户登录状态
  879. UserDefaults.standard.set(false, forKey: "isLogggedIn")
  880. UserDefaults.standard.removeObject(forKey: "userToken")
  881. // // 创建登录界面
  882. let loginVC = LoginViewController()
  883. let navController = UINavigationController(rootViewController: loginVC)
  884. navController.modalPresentationStyle = .fullScreen
  885. self.present(navController, animated: true)
  886. //
  887. // // 获取当前窗口
  888. // guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
  889. // return
  890. // }
  891. //
  892. // // 设置根视图控制器为登录界面
  893. // window.rootViewController = navController
  894. //
  895. // // 添加切换动画
  896. // UIView.transition(with: window,
  897. // duration: 0.4,
  898. // options: .transitionCrossDissolve,
  899. // animations: nil,
  900. // completion: nil)
  901. }
  902. private func storeMemberInfo(_ member: MemberInfo) {
  903. print("会员信息:")
  904. print("UserID: \(member.userId)")
  905. print("Phone: \(member.memberPhone)")
  906. print("Name: \(member.memberName)")
  907. print("Icon: \(member.memberIcon ?? "No Icon")")
  908. print("Email: \(member.memberEmail ?? "No Email")")
  909. print("Created Time: \(member.createdTime)")
  910. print("Updated Time: \(member.updatedTime)")
  911. if let token = UserDefaults.standard.string(forKey: "userToken") {
  912. print("token: \(token)")
  913. }
  914. // Store data in UserDefaults or another storage solution
  915. UserDefaults.standard.set(member.userId, forKey: "userId")
  916. UserDefaults.standard.set(member.memberPhone, forKey: "memberPhone")
  917. UserDefaults.standard.set(member.memberName, forKey: "memberName")
  918. UserDefaults.standard.set(member.memberIcon, forKey: "memberIcon")
  919. UserDefaults.standard.set(member.memberEmail, forKey: "memberEmail")
  920. UserDefaults.standard.set(member.createdTime, forKey: "createdTime")
  921. UserDefaults.standard.set(member.updatedTime, forKey: "updatedTime")
  922. // After we have userId, fetch pets for this user
  923. DispatchQueue.main.async { [weak self] in
  924. self?.fetchPetsForUser(userId: member.userId)
  925. }
  926. }
  927. // MARK: - Pets networking & UI
  928. private func fetchPetsForUser(userId: String) {
  929. var components = URLComponents(string: "\(baseURL)/petRecordPet/queryPetList")
  930. components?.queryItems = [URLQueryItem(name: "userId", value: userId)]
  931. guard let url = components?.url else { return }
  932. var request = URLRequest(url: url)
  933. request.httpMethod = "GET"
  934. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  935. if let token = UserDefaults.standard.string(forKey: "userToken") {
  936. request.setValue(token, forHTTPHeaderField: "Authorization")
  937. }
  938. print("🐾 GET: \(url.absoluteString)")
  939. URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
  940. if let http = response as? HTTPURLResponse { print("🐾 status: \(http.statusCode)") }
  941. if let error = error { print("🐾 pets error: \(error)"); return }
  942. guard let data = data else { print("🐾 pets empty data"); return }
  943. do {
  944. let decoder = JSONDecoder()
  945. let resp = try decoder.decode(PetListByUserResponse.self, from: data)
  946. if resp.code == "200" {
  947. DispatchQueue.main.async {
  948. self?.pets = resp.data
  949. if let first = resp.data.first {
  950. self?.currentPetIndex = 0
  951. self?.buildPetNameChips()
  952. self?.applyPet(first)
  953. }
  954. }
  955. } else {
  956. print("🐾 pets server msg: \(resp.msg ?? "-") code=\(resp.code)")
  957. }
  958. } catch {
  959. print("🐾 pets decode error: \(error)")
  960. }
  961. }.resume()
  962. }
  963. private func applyPet(_ pet: PetSummary) {
  964. let breed = pet.breedName ?? "宠物品种"
  965. let ageText: String = {
  966. if let a = pet.age, !a.isEmpty { return "\(a)岁" }
  967. return "年龄"
  968. }()
  969. let days: String = {
  970. if let d = pet.togetherDays, !d.isEmpty { return "一起生活的第\(d)天" }
  971. return "一起生活的第XX天"
  972. }()
  973. let placeholder = UIImage(named: "Home372") ?? UIImage(systemName: "pawprint")!
  974. if let avatar = pet.avatar, let url = URL(string: avatar) {
  975. loadImage(from: url) { [weak self] img in
  976. self?.petCard.configure(
  977. avatar: img ?? placeholder,
  978. name: pet.name,
  979. breed: breed,
  980. ageText: ageText,
  981. daysText: days
  982. )
  983. }
  984. } else {
  985. petCard.configure(
  986. avatar: placeholder,
  987. name: pet.name,
  988. breed: breed,
  989. ageText: ageText,
  990. daysText: days
  991. )
  992. }
  993. updatePetNameSelection()
  994. // 拉取该宠物的记录分类(填充到标签行)
  995. fetchRecordCategories(petId: pet.id)
  996. // 拉取事件记录列表(默认全部)
  997. fetchEventRecords(petId: pet.id, keyword: "")
  998. }
  999. // 拉取记录分类列表(入参 petId)并渲染到 tag 行
  1000. private func fetchRecordCategories(petId: String) {
  1001. var comps = URLComponents(string: "\(baseURL)/petRecordInfo/queryRecordListCategory")
  1002. comps?.queryItems = [URLQueryItem(name: "petId", value: petId)]
  1003. guard let url = comps?.url else { return }
  1004. var request = URLRequest(url: url)
  1005. request.httpMethod = "GET"
  1006. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  1007. if let token = UserDefaults.standard.string(forKey: "userToken") {
  1008. request.setValue(token, forHTTPHeaderField: "Authorization")
  1009. }
  1010. print("🏷️ GET: \(url.absoluteString)")
  1011. URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
  1012. if let http = response as? HTTPURLResponse { print("🏷️ status: \(http.statusCode)") }
  1013. if let error = error { print("🏷️ category error: \(error)"); return }
  1014. guard let data = data else { print("🏷️ category empty data"); return }
  1015. do {
  1016. let decoder = JSONDecoder()
  1017. let resp = try decoder.decode(RecordCategoriesResponse.self, from: data)
  1018. if resp.code == "200" {
  1019. DispatchQueue.main.async {
  1020. self?.populateTagCategories(resp.data)
  1021. }
  1022. } else {
  1023. print("🏷️ server msg: \(resp.msg ?? "-") code=\(resp.code)")
  1024. }
  1025. } catch {
  1026. print("🏷️ decode error: \(error)")
  1027. }
  1028. }.resume()
  1029. }
  1030. // 用从接口拿到的分类填充 UI
  1031. private func populateTagCategories(_ categories: [String]) {
  1032. // 缓存数据
  1033. currentCategories = categories
  1034. // 清空旧的
  1035. categoryPills.removeAll()
  1036. tagStack.arrangedSubviews.forEach { v in
  1037. tagStack.removeArrangedSubview(v)
  1038. v.removeFromSuperview()
  1039. }
  1040. // 在最前面加上 “全部”
  1041. let items: [String] = ["全部"] + categories
  1042. // 永远展示(至少有“全部”)
  1043. tagScroll.isHidden = items.isEmpty
  1044. guard !items.isEmpty else { return }
  1045. // 默认选中“全部”
  1046. selectedCategoryIndex = min(selectedCategoryIndex, items.count - 1)
  1047. for (idx, name) in items.enumerated() {
  1048. let pill = DottedPill(text: name)
  1049. pill.isUserInteractionEnabled = true
  1050. pill.tag = idx
  1051. // 选中态样式
  1052. pill.setSelected(idx == selectedCategoryIndex)
  1053. // 点击切换(单选)
  1054. let tap = UITapGestureRecognizer(target: self, action: #selector(tapCategoryPill(_:)))
  1055. pill.addGestureRecognizer(tap)
  1056. pill.translatesAutoresizingMaskIntoConstraints = false
  1057. tagStack.addArrangedSubview(pill)
  1058. categoryPills.append(pill)
  1059. }
  1060. }
  1061. // MARK: - Event Records networking & UI
  1062. private func fetchEventRecords(petId: String, keyword: String) {
  1063. var comps = URLComponents(string: "\(baseURL)/petRecordInfo/queryEventRecordInfoList")
  1064. comps?.queryItems = [
  1065. URLQueryItem(name: "petId", value: petId),
  1066. URLQueryItem(name: "keyword", value: keyword)
  1067. ]
  1068. guard let url = comps?.url else { return }
  1069. var request = URLRequest(url: url)
  1070. request.httpMethod = "GET"
  1071. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  1072. if let token = UserDefaults.standard.string(forKey: "userToken") {
  1073. request.setValue(token, forHTTPHeaderField: "Authorization")
  1074. }
  1075. print("🗒️ GET: \(url.absoluteString)")
  1076. URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
  1077. if let http = response as? HTTPURLResponse { print("🗒️ status: \(http.statusCode)") }
  1078. if let error = error { print("🗒️ records error: \(error)"); return }
  1079. guard let data = data else { print("🗒️ records empty data"); return }
  1080. do {
  1081. let decoder = JSONDecoder()
  1082. let resp = try decoder.decode(EventRecordListResponse.self, from: data)
  1083. if resp.code == "200" {
  1084. DispatchQueue.main.async { self?.renderRecords(resp.data) }
  1085. } else {
  1086. print("🗒️ records server msg: \(resp.msg ?? "-") code=\(resp.code)")
  1087. DispatchQueue.main.async { self?.renderRecords([]) }
  1088. }
  1089. } catch {
  1090. print("🗒️ records decode error: \(error)")
  1091. DispatchQueue.main.async { self?.renderRecords([]) }
  1092. }
  1093. }.resume()
  1094. }
  1095. private func renderRecords(_ list: [EventRecord]) {
  1096. // 清空旧列表
  1097. recordStack.arrangedSubviews.forEach { v in
  1098. recordStack.removeArrangedSubview(v)
  1099. v.removeFromSuperview()
  1100. }
  1101. // 检查是否有数据,决定显示列表还是空视图
  1102. if list.isEmpty {
  1103. showEmptyState()
  1104. return
  1105. }
  1106. hideEmptyState()
  1107. let placeholder = UIImage(named: "peihead") ?? UIImage(systemName: "pawprint.circle")!
  1108. for rec in list {
  1109. let card = RecordCard()
  1110. // 填充内容
  1111. let dateText = formatAPIDate(rec.recordDate)
  1112. let summary = (rec.content?.isEmpty == false) ? rec.content! : (rec.note ?? "")
  1113. card.configure(
  1114. avatar: placeholder,
  1115. title: rec.title,
  1116. date: dateText,
  1117. badge: rec.petName,
  1118. summary: summary
  1119. )
  1120. // 异步加载头像
  1121. // if let urlStr = rec.avatar, let url = URL(string: urlStr) {
  1122. // loadImage(from: url) { img in
  1123. // // 简单重配一次以更新图片
  1124. // card.configure(
  1125. // avatar: img ?? placeholder,
  1126. // title: rec.title,
  1127. // date: dateText,
  1128. // badge: rec.petName,
  1129. // summary: summary
  1130. // )
  1131. // }
  1132. // }
  1133. recordStack.addArrangedSubview(card)
  1134. }
  1135. }
  1136. // MARK: - 空视图管理
  1137. private func showEmptyState() {
  1138. emptyStateView.isHidden = false
  1139. recordStack.isHidden = true
  1140. }
  1141. private func hideEmptyState() {
  1142. emptyStateView.isHidden = true
  1143. recordStack.isHidden = false
  1144. }
  1145. private func formatAPIDate(_ s: String?) -> String {
  1146. guard let s = s, !s.isEmpty else { return "" }
  1147. let inFmt = DateFormatter()
  1148. inFmt.locale = Locale(identifier: "en_US_POSIX")
  1149. inFmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" // 例: 2025-08-26T00:00:00.000+08:00
  1150. if let date = inFmt.date(from: s) {
  1151. let out = DateFormatter()
  1152. out.locale = Locale(identifier: "zh_CN")
  1153. out.dateFormat = "yyyy.MM.dd"
  1154. return out.string(from: date)
  1155. }
  1156. return ""
  1157. }
  1158. // 构建“所有宠物名字”的一排按钮
  1159. private func buildPetNameChips() {
  1160. // 清空旧内容
  1161. petNameButtons.removeAll()
  1162. petNamesStack.arrangedSubviews.forEach { v in
  1163. petNamesStack.removeArrangedSubview(v)
  1164. v.removeFromSuperview()
  1165. }
  1166. for (idx, p) in pets.enumerated() {
  1167. let btn = UIButton(type: .system)
  1168. btn.setTitle(p.name, for: .normal)
  1169. btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
  1170. btn.setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  1171. btn.backgroundColor = .clear
  1172. btn.contentEdgeInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10)
  1173. btn.layer.cornerRadius = 6
  1174. btn.layer.masksToBounds = true
  1175. btn.tag = idx
  1176. btn.addTarget(self, action: #selector(tapPetNameButton(_:)), for: .touchUpInside)
  1177. petNamesStack.addArrangedSubview(btn)
  1178. petNameButtons.append(btn)
  1179. }
  1180. updatePetNameSelection()
  1181. }
  1182. private func updatePetNameSelection() {
  1183. for (i, b) in petNameButtons.enumerated() {
  1184. let selected = (i == currentPetIndex)
  1185. if selected {
  1186. b.backgroundColor = UIColor(hex: "#FFE059")
  1187. b.setTitleColor(.black, for: .normal)
  1188. } else {
  1189. b.backgroundColor = .clear
  1190. b.setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  1191. }
  1192. }
  1193. }
  1194. @objc private func tapCategoryPill(_ g: UITapGestureRecognizer) {
  1195. guard let pill = g.view as? DottedPill else { return }
  1196. let idx = pill.tag
  1197. guard idx != selectedCategoryIndex, idx >= 0, idx < categoryPills.count else { return }
  1198. // 单选:只允许一个选中
  1199. selectedCategoryIndex = idx
  1200. for (i, p) in categoryPills.enumerated() {
  1201. p.setSelected(i == selectedCategoryIndex)
  1202. }
  1203. // 触发记录过滤(keyword 为空表示全部)
  1204. let keyword = (idx == 0) ? "" : currentCategories[idx - 1]
  1205. let currentPetId = (currentPetIndex >= 0 && currentPetIndex < pets.count) ? pets[currentPetIndex].id : ""
  1206. guard !currentPetId.isEmpty else { return }
  1207. fetchEventRecords(petId: currentPetId, keyword: keyword)
  1208. }
  1209. @objc private func tapPetNameButton(_ sender: UIButton) {
  1210. let idx = sender.tag
  1211. guard idx >= 0, idx < pets.count else { return }
  1212. currentPetIndex = idx
  1213. applyPet(pets[idx]) // 切换卡片信息
  1214. updatePetNameSelection() // 同步高亮
  1215. }
  1216. @objc private func showPetPicker() {
  1217. guard !pets.isEmpty else { return }
  1218. let ac = UIAlertController(title: "选择宠物", message: nil, preferredStyle: .actionSheet)
  1219. // ✅ iPad 必须设置 popover 锚点
  1220. ac.modalPresentationStyle = .popover
  1221. if let p = ac.popoverPresentationController {
  1222. p.sourceView = petCard
  1223. p.sourceRect = petCard.bounds
  1224. p.permittedArrowDirections = .up
  1225. p.delegate = self
  1226. }
  1227. for (idx, p) in pets.enumerated() {
  1228. ac.addAction(UIAlertAction(title: p.name, style: .default, handler: { [weak self] _ in
  1229. self?.currentPetIndex = idx
  1230. self?.applyPet(p)
  1231. }))
  1232. }
  1233. ac.addAction(UIAlertAction(title: "取消", style: .cancel))
  1234. present(ac, animated: true)
  1235. }
  1236. private func loadImage(from url: URL, completion: @escaping (UIImage?) -> Void) {
  1237. let key = url.absoluteString as NSString
  1238. if let cached = imageCache.object(forKey: key) {
  1239. completion(cached)
  1240. return
  1241. }
  1242. URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
  1243. var img: UIImage? = nil
  1244. if let data = data { img = UIImage(data: data) }
  1245. if let img = img { self?.imageCache.setObject(img, forKey: key) }
  1246. DispatchQueue.main.async { completion(img) }
  1247. }.resume()
  1248. }
  1249. // Create a struct to model the response
  1250. struct MemberInfoResponse: Codable {
  1251. let code: String
  1252. let msg: String?
  1253. let data: MemberInfo
  1254. }
  1255. struct MemberInfo: Codable {
  1256. let userId: String
  1257. let memberPhone: String
  1258. let memberName: String
  1259. let memberIcon: String?
  1260. let memberEmail: String?
  1261. let status: String?
  1262. let createdTime: String
  1263. let updatedTime: String
  1264. }
  1265. // Flexible code type (string or int)
  1266. enum CodeValue: Codable {
  1267. case string(String)
  1268. case int(Int)
  1269. init(from decoder: Decoder) throws {
  1270. let c = try decoder.singleValueContainer()
  1271. if let s = try? c.decode(String.self) { self = .string(s) }
  1272. else if let i = try? c.decode(Int.self) { self = .int(i) }
  1273. else { self = .string("") }
  1274. }
  1275. func equals200() -> Bool {
  1276. switch self { case .string(let s): return s == "200"; case .int(let i): return i == 200 }
  1277. }
  1278. var rawString: String { switch self { case .string(let s): return s; case .int(let i): return String(i) } }
  1279. }
  1280. struct PetListByUserResponse: Codable {
  1281. let code: String
  1282. let msg: String?
  1283. let data: [PetSummary]
  1284. }
  1285. struct PetSummary: Codable {
  1286. let id: String
  1287. let userId: String
  1288. let name: String
  1289. let nickname: String?
  1290. let weight: String?
  1291. let categoryId: String?
  1292. let categoryName: String?
  1293. let breedId: String?
  1294. let breedName: String?
  1295. let gender: String?
  1296. let birthDate: String?
  1297. let arrivalDate: String?
  1298. let avatar: String?
  1299. let age: String?
  1300. let togetherDays: String?
  1301. }
  1302. // 拉取记录分类接口响应
  1303. struct RecordCategoriesResponse: Codable {
  1304. let code: String
  1305. let msg: String?
  1306. let data: [String]
  1307. }
  1308. // 拉取事件记录列表响应
  1309. struct EventRecordListResponse: Codable {
  1310. let code: String
  1311. let msg: String?
  1312. let data: [EventRecord]
  1313. }
  1314. struct EventRecord: Codable {
  1315. let id: String
  1316. let petId: String
  1317. let avatar: String?
  1318. let petName: String
  1319. let recordTypeId: String?
  1320. let recordUrl: String?
  1321. let title: String
  1322. let recordDate: String?
  1323. let content: String?
  1324. let amount: String?
  1325. let note: String?
  1326. let module: String?
  1327. }
  1328. // MARK: - UIPopoverPresentationControllerDelegate (fallback)
  1329. func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) {
  1330. if popoverPresentationController.sourceView == nil && popoverPresentationController.barButtonItem == nil {
  1331. popoverPresentationController.sourceView = view
  1332. popoverPresentationController.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 1, width: 1, height: 1)
  1333. popoverPresentationController.permittedArrowDirections = []
  1334. }
  1335. }
  1336. deinit {
  1337. NotificationCenter.default.removeObserver(self, name: .petDidSave, object: nil)
  1338. }
  1339. }
  1340. // MARK: - PetDidSave Notification Name
  1341. extension Notification.Name {
  1342. static let petDidSave = Notification.Name("PetDidSave")
  1343. // 已有类似事件通知就保留,这里新增记账创建成功通知
  1344. static let bookkeepingRecordDidCreate = Notification.Name("BookkeepingRecordDidCreate")
  1345. }
  1346. // MARK: - PetHeaderCard
  1347. final class PetHeaderCard: UIView {
  1348. private let container = UIImageView()
  1349. private let avatarView = UIImageView()
  1350. private let nameLabel = UILabel()
  1351. private let chipStack = UIStackView()
  1352. private let daysRibbon = PaddingLabel(insets: .init(top: 6, left: 12, bottom: 6, right: 12))
  1353. override init(frame: CGRect) {
  1354. super.init(frame: frame)
  1355. translatesAutoresizingMaskIntoConstraints = false
  1356. container.translatesAutoresizingMaskIntoConstraints = false
  1357. container.contentMode = .scaleToFill
  1358. container.image = UIImage(named: "Home377")
  1359. container.layer.shadowColor = UIColor.black.cgColor
  1360. container.layer.shadowOpacity = 0.07
  1361. container.layer.shadowRadius = 8
  1362. container.layer.shadowOffset = .init(width: 0, height: 2)
  1363. addSubview(container)
  1364. avatarView.contentMode = .scaleAspectFill
  1365. avatarView.layer.cornerRadius = 32
  1366. avatarView.clipsToBounds = true
  1367. avatarView.backgroundColor = UIColor(hex: "#FFF3D9")
  1368. nameLabel.font = .systemFont(ofSize: 15, weight: .semibold)
  1369. nameLabel.textColor = UIColor(hex: "#2B2B2B")
  1370. chipStack.axis = .horizontal
  1371. chipStack.spacing = 8
  1372. chipStack.alignment = .center
  1373. daysRibbon.backgroundColor = UIColor(hex: "#FFE059")
  1374. daysRibbon.textColor = .black
  1375. daysRibbon.font = .systemFont(ofSize: 10, weight: .medium)
  1376. daysRibbon.layer.cornerRadius = 14
  1377. daysRibbon.layer.masksToBounds = true
  1378. [avatarView, nameLabel, chipStack, daysRibbon].forEach {
  1379. $0.translatesAutoresizingMaskIntoConstraints = false
  1380. container.addSubview($0)
  1381. }
  1382. NSLayoutConstraint.activate([
  1383. container.topAnchor.constraint(equalTo: topAnchor),
  1384. container.leadingAnchor.constraint(equalTo: leadingAnchor),
  1385. container.trailingAnchor.constraint(equalTo: trailingAnchor),
  1386. container.bottomAnchor.constraint(equalTo: bottomAnchor),
  1387. avatarView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
  1388. avatarView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  1389. avatarView.widthAnchor.constraint(equalToConstant: 64),
  1390. avatarView.heightAnchor.constraint(equalToConstant: 64),
  1391. nameLabel.topAnchor.constraint(equalTo: avatarView.topAnchor, constant: 0),
  1392. nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 10),
  1393. nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: daysRibbon.leadingAnchor, constant: -8),
  1394. chipStack.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor),
  1395. chipStack.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 8),
  1396. chipStack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12),
  1397. daysRibbon.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8),
  1398. daysRibbon.topAnchor.constraint(equalTo: container.topAnchor, constant: 8)
  1399. ])
  1400. }
  1401. required init?(coder: NSCoder) { fatalError() }
  1402. func configure(avatar: UIImage, name: String, breed: String, ageText: String, daysText: String) {
  1403. avatarView.image = avatar
  1404. nameLabel.text = name
  1405. chipStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
  1406. [breed, ageText].forEach {
  1407. let c = SoftChip(text: $0)
  1408. chipStack.addArrangedSubview(c)
  1409. }
  1410. daysRibbon.text = daysText
  1411. }
  1412. }
  1413. // MARK: - RecordCard
  1414. final class RecordCard: UIView {
  1415. private let container = UIImageView()
  1416. private let iconView = UIImageView()
  1417. private let titleLabel = UILabel()
  1418. private let dateLabel = UILabel()
  1419. private let badge = StrokeBadge()
  1420. private let bubble = UIView()
  1421. private let summaryLabel = UILabel()
  1422. override init(frame: CGRect) {
  1423. super.init(frame: frame)
  1424. translatesAutoresizingMaskIntoConstraints = false
  1425. container.translatesAutoresizingMaskIntoConstraints = false
  1426. container.image = UIImage(named: "Home378")
  1427. container.contentMode = .scaleToFill
  1428. container.layer.shadowColor = UIColor.black.cgColor
  1429. container.layer.shadowOpacity = 0.06
  1430. container.layer.shadowRadius = 6
  1431. container.layer.shadowOffset = .init(width: 0, height: 2)
  1432. addSubview(container)
  1433. iconView.contentMode = .scaleAspectFill
  1434. iconView.layer.cornerRadius = 0
  1435. iconView.image = UIImage(named: "peihead")
  1436. iconView.layer.masksToBounds = true
  1437. iconView.backgroundColor = UIColor(hex: "#FFF3D9")
  1438. titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
  1439. titleLabel.textColor = UIColor(hex: "#2B2B2B")
  1440. dateLabel.font = .systemFont(ofSize: 12)
  1441. dateLabel.textColor = UIColor(hex: "#8B8B8B")
  1442. bubble.backgroundColor = .white
  1443. bubble.layer.cornerRadius = 12
  1444. summaryLabel.font = .systemFont(ofSize: 13)
  1445. summaryLabel.textColor = UIColor(hex: "#6B6B6B")
  1446. summaryLabel.numberOfLines = 1
  1447. summaryLabel.lineBreakMode = .byTruncatingTail
  1448. [iconView, titleLabel, dateLabel, badge, bubble].forEach {
  1449. $0.translatesAutoresizingMaskIntoConstraints = false
  1450. container.addSubview($0)
  1451. }
  1452. bubble.addSubview(summaryLabel)
  1453. summaryLabel.translatesAutoresizingMaskIntoConstraints = false
  1454. NSLayoutConstraint.activate([
  1455. container.topAnchor.constraint(equalTo: topAnchor),
  1456. container.leadingAnchor.constraint(equalTo: leadingAnchor),
  1457. container.trailingAnchor.constraint(equalTo: trailingAnchor),
  1458. container.bottomAnchor.constraint(equalTo: bottomAnchor),
  1459. iconView.topAnchor.constraint(equalTo: container.topAnchor, constant: 17),
  1460. iconView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 17),
  1461. iconView.widthAnchor.constraint(equalToConstant: 56),
  1462. iconView.heightAnchor.constraint(equalToConstant: 56),
  1463. titleLabel.topAnchor.constraint(equalTo: iconView.topAnchor),
  1464. titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10),
  1465. badge.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
  1466. badge.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -17),
  1467. dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
  1468. dateLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  1469. bubble.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 10),
  1470. bubble.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
  1471. bubble.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
  1472. bubble.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12),
  1473. bubble.heightAnchor.constraint(equalToConstant: 38),
  1474. summaryLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
  1475. summaryLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
  1476. summaryLabel.centerYAnchor.constraint(equalTo: bubble.centerYAnchor)
  1477. ])
  1478. }
  1479. required init?(coder: NSCoder) { fatalError() }
  1480. func configure(avatar: UIImage, title: String, date: String, badge: String, summary: String) {
  1481. iconView.image = avatar
  1482. titleLabel.text = title
  1483. dateLabel.text = date
  1484. self.badge.setText(badge)
  1485. summaryLabel.text = summary
  1486. }
  1487. }
  1488. // MARK: - BottomTabBar
  1489. final class BottomTabBar: UIView {
  1490. private let bar = UIView()
  1491. private let leftBtn = BottomTabItem(normalImageName: "Home293", selectedImageName: "Home290", title: "事件", isSelected: true)
  1492. private let rightBtn = BottomTabItem(normalImageName: "Home291", selectedImageName: "Home292", title: "记账")
  1493. private let indicator = UIView()
  1494. var onSelect: ((Int) -> Void)?
  1495. override init(frame: CGRect) {
  1496. super.init(frame: frame)
  1497. translatesAutoresizingMaskIntoConstraints = false
  1498. bar.backgroundColor = .white
  1499. bar.layer.shadowColor = UIColor.black.cgColor
  1500. bar.layer.shadowOpacity = 0.06
  1501. bar.layer.shadowRadius = 6
  1502. bar.layer.shadowOffset = .init(width: 0, height: -2)
  1503. addSubview(bar)
  1504. [leftBtn, rightBtn].forEach {
  1505. bar.addSubview($0)
  1506. $0.translatesAutoresizingMaskIntoConstraints = false
  1507. }
  1508. bar.translatesAutoresizingMaskIntoConstraints = false
  1509. leftBtn.addTarget(self, action: #selector(tapLeft), for: .touchUpInside)
  1510. rightBtn.addTarget(self, action: #selector(tapRight), for: .touchUpInside)
  1511. indicator.backgroundColor = .black
  1512. indicator.layer.cornerRadius = 2
  1513. NSLayoutConstraint.activate([
  1514. bar.topAnchor.constraint(equalTo: topAnchor),
  1515. bar.leadingAnchor.constraint(equalTo: leadingAnchor),
  1516. bar.trailingAnchor.constraint(equalTo: trailingAnchor),
  1517. bar.bottomAnchor.constraint(equalTo: bottomAnchor),
  1518. bar.heightAnchor.constraint(equalToConstant: 64),
  1519. leftBtn.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 82),
  1520. leftBtn.topAnchor.constraint(equalTo: bar.topAnchor,constant: 8),
  1521. leftBtn.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -8),
  1522. leftBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 80),
  1523. rightBtn.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -82),
  1524. rightBtn.topAnchor.constraint(equalTo: bar.topAnchor,constant: 8),
  1525. rightBtn.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -8),
  1526. rightBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 80),
  1527. ])
  1528. }
  1529. required init?(coder: NSCoder) { fatalError() }
  1530. @objc private func tapLeft() {
  1531. leftBtn.setSelected(true)
  1532. rightBtn.setSelected(false)
  1533. UIView.animate(withDuration: 0.2) {
  1534. self.indicator.center.x = self.leftBtn.center.x
  1535. }
  1536. onSelect?(0)
  1537. }
  1538. @objc private func tapRight() {
  1539. leftBtn.setSelected(false)
  1540. rightBtn.setSelected(true)
  1541. UIView.animate(withDuration: 0.2) {
  1542. self.indicator.center.x = self.rightBtn.center.x
  1543. }
  1544. onSelect?(1)
  1545. }
  1546. }
  1547. final class BottomTabItem: UIControl {
  1548. private let iconView = UIImageView()
  1549. private let label = UILabel()
  1550. private let normalImage: UIImage?
  1551. private let selectedImage: UIImage?
  1552. init(normalImageName: String, selectedImageName: String, title: String, isSelected: Bool = false) {
  1553. self.normalImage = UIImage(named: normalImageName)?.withRenderingMode(.alwaysOriginal)
  1554. self.selectedImage = UIImage(named: selectedImageName)?.withRenderingMode(.alwaysOriginal)
  1555. super.init(frame: .zero)
  1556. iconView.contentMode = .scaleAspectFit
  1557. iconView.image = normalImage
  1558. label.text = title
  1559. label.font = .systemFont(ofSize: 12)
  1560. label.textColor = UIColor(hex: "#6B6B6B")
  1561. let stack = UIStackView(arrangedSubviews: [iconView, label])
  1562. stack.axis = .vertical
  1563. stack.alignment = .center
  1564. stack.spacing = 4
  1565. addSubview(stack)
  1566. stack.translatesAutoresizingMaskIntoConstraints = false
  1567. iconView.translatesAutoresizingMaskIntoConstraints = false
  1568. NSLayoutConstraint.activate([
  1569. stack.topAnchor.constraint(equalTo: topAnchor),
  1570. stack.leadingAnchor.constraint(equalTo: leadingAnchor),
  1571. stack.trailingAnchor.constraint(equalTo: trailingAnchor),
  1572. stack.bottomAnchor.constraint(equalTo: bottomAnchor),
  1573. iconView.widthAnchor.constraint(equalToConstant: 22),
  1574. iconView.heightAnchor.constraint(equalToConstant: 22)
  1575. ])
  1576. // 让点击落到整个 control
  1577. stack.isUserInteractionEnabled = false
  1578. setSelected(isSelected)
  1579. }
  1580. required init?(coder: NSCoder) { fatalError() }
  1581. func setSelected(_ sel: Bool) {
  1582. iconView.image = sel ? selectedImage : normalImage
  1583. label.textColor = sel ? .black : UIColor(hex: "#6B6B6B")
  1584. }
  1585. }
  1586. // MARK: - Small Components
  1587. final class SoftChip: PaddingLabel {
  1588. init(text: String) {
  1589. super.init(insets: .init(top: 6, left: 12, bottom: 6, right: 12))
  1590. self.text = text
  1591. font = .systemFont(ofSize: 12, weight: .medium)
  1592. textColor = UIColor(hex: "#6B6B6B")
  1593. backgroundColor = .white
  1594. layer.cornerRadius = 14
  1595. layer.masksToBounds = true
  1596. layer.shadowColor = UIColor.black.cgColor
  1597. layer.shadowOpacity = 0.06
  1598. layer.shadowRadius = 4
  1599. layer.shadowOffset = .init(width: 0, height: 1)
  1600. }
  1601. required init?(coder: NSCoder) { fatalError() }
  1602. }
  1603. final class DottedPill: PaddingLabel {
  1604. private let dashLayer = CAShapeLayer()
  1605. private(set) var isOn: Bool = false
  1606. init(text: String) {
  1607. super.init(insets: .init(top: 8, left: 16, bottom: 8, right: 16))
  1608. self.text = text
  1609. font = .systemFont(ofSize: 12)
  1610. textColor = UIColor(hex: "#5B4227")
  1611. backgroundColor = .clear
  1612. layer.cornerRadius = 10
  1613. layer.masksToBounds = true
  1614. dashLayer.strokeColor = UIColor(hex: "#5B4227").cgColor
  1615. dashLayer.fillColor = UIColor.clear.cgColor
  1616. dashLayer.lineDashPattern = [6, 4]
  1617. dashLayer.lineWidth = 1
  1618. layer.addSublayer(dashLayer)
  1619. applySelectionStyle()
  1620. }
  1621. required init?(coder: NSCoder) { fatalError() }
  1622. func setSelected(_ selected: Bool) {
  1623. isOn = selected
  1624. applySelectionStyle()
  1625. }
  1626. private func applySelectionStyle() {
  1627. if isOn {
  1628. backgroundColor = UIColor(hex: "#FFE059")
  1629. textColor = .black
  1630. dashLayer.isHidden = true
  1631. } else {
  1632. backgroundColor = .clear
  1633. textColor = UIColor(hex: "#5B4227")
  1634. dashLayer.isHidden = false
  1635. }
  1636. }
  1637. override func layoutSubviews() {
  1638. super.layoutSubviews()
  1639. dashLayer.frame = bounds
  1640. dashLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 18).cgPath
  1641. }
  1642. }
  1643. final class StrokeBadge: PaddingLabel {
  1644. init() {
  1645. super.init(insets: .init(top: 4, left: 10, bottom: 4, right: 10))
  1646. textColor = UIColor(hex: "#5B4227")
  1647. font = .systemFont(ofSize: 12)
  1648. layer.cornerRadius = 12
  1649. layer.masksToBounds = true
  1650. layer.borderWidth = 1
  1651. layer.borderColor = UIColor(hex: "#5B4227").cgColor
  1652. }
  1653. required init?(coder: NSCoder) { fatalError() }
  1654. func setText(_ t: String) { text = t }
  1655. }
  1656. // MARK: - PaddingLabel (with insets)
  1657. class PaddingLabel: UILabel {
  1658. private var insets: UIEdgeInsets
  1659. init(insets: UIEdgeInsets) {
  1660. self.insets = insets
  1661. super.init(frame: .zero)
  1662. }
  1663. required init?(coder: NSCoder) { fatalError() }
  1664. override func drawText(in rect: CGRect) {
  1665. super.drawText(in: rect.inset(by: insets))
  1666. }
  1667. override var intrinsicContentSize: CGSize {
  1668. let size = super.intrinsicContentSize
  1669. return CGSize(width: size.width + insets.left + insets.right,
  1670. height: size.height + insets.top + insets.bottom)
  1671. }
  1672. }