HomeViewController.swift 74 KB

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