// // HomeViewController.swift // VenusKitto // // Created by Neoa on 2025/8/22. // import Foundation import UIKit // MARK: - HomeViewController class HomeViewController: UIViewController, UIPopoverPresentationControllerDelegate { // UI private let scrollView = UIScrollView() private let contentView = UIView() private let titleLabel: UILabel = { let l = UILabel() l.text = "记录" l.font = .systemFont(ofSize: 28, weight: .semibold) l.textColor = UIColor(hex: "#2B2B2B") return l }() private lazy var settingsButton: UIButton = { let b = UIButton(type: .system) if let img = UIImage(systemName: "gearshape") { b.setImage(img, for: .normal) } else { b.setTitle("⚙️", for: .normal) } b.tintColor = UIColor(hex: "#2B2B2B") b.addTarget(self, action: #selector(tapSettings), for: .touchUpInside) return b }() private let petCard = PetHeaderCard() private let tagScroll = UIScrollView() private let tagStack = UIStackView() private let recordStack = UIStackView() // 空视图组件 private lazy var emptyStateView: UIView = { let container = UIView() container.backgroundColor = .clear let imageView = UIImageView() imageView.image = UIImage(named: "emptydata") // 使用现有的图片资源 imageView.contentMode = .scaleAspectFit // imageView.tintColor = UIColor(hex: "#CFCFCF") let titleLabel = UILabel() titleLabel.text = "暂无记录" titleLabel.font = .systemFont(ofSize: 18, weight: .medium) titleLabel.textColor = UIColor(hex: "#CFCFCF") titleLabel.textAlignment = .center let subtitleLabel = UILabel() subtitleLabel.text = "快去记录第一个美好时刻吧~" subtitleLabel.font = .systemFont(ofSize: 14) subtitleLabel.textColor = UIColor(hex: "#CFCFCF") subtitleLabel.textAlignment = .center let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel, subtitleLabel]) stackView.axis = .vertical stackView.alignment = .center stackView.spacing = 16 container.addSubview(stackView) stackView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ imageView.widthAnchor.constraint(equalToConstant: 80), imageView.heightAnchor.constraint(equalToConstant: 80), stackView.centerXAnchor.constraint(equalTo: container.centerXAnchor), stackView.centerYAnchor.constraint(equalTo: container.centerYAnchor), stackView.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 40), stackView.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -40) ]) container.isHidden = true return container }() private let addButton: UIButton = { let b = UIButton(type: .custom) b.setImage(UIImage(named: "Home357"), for: .normal) // b.tintColor = .black // b.backgroundColor = UIColor(hex: "#FFE059") b.layer.cornerRadius = 28 b.layer.shadowColor = UIColor.black.cgColor b.layer.shadowOpacity = 0.15 b.layer.shadowRadius = 8 b.layer.shadowOffset = .init(width: 0, height: 4) b.addTarget(self, action: #selector(tapAdd), for: .touchUpInside) return b }() private let tabBarView = BottomTabBar() // --- Action menu (pop when tapping +) --- private var isActionMenuShown = false private let overlayView = UIControl() private let glowView = UIView() private let leftActionStack = UIStackView() private let leftActionButton = UIButton(type: .custom) private let leftActionLabel = UILabel() private let rightActionStack = UIStackView() private let rightActionButton = UIButton(type: .custom) private let rightActionLabel = UILabel() // Glow effect layers/animation private let glowGradient = CAGradientLayer() // --- Create Record Sheet --- private var createRecordSheet: CreateRecordSheet? // 记账分类弹窗(快速记账) private var bookkeepingQuickSheet: BookkeepingQuickSheet? private var bookkeepingAmountSheet: BookkeepingAmountSheet? // Embedded bookkeeping page (keeps bottom tab visible) private var bookkeepingVC: BookkeepingViewController? // --- Pets (list & switching) --- private var pets: [PetSummary] = [] private var currentPetIndex: Int = 0 private let imageCache = NSCache() // --- Empty state & add pet --- private var hasPet: Bool = false private let addPetButton: UIButton = { let b = UIButton(type: .system) b.setTitle("添加+", for: .normal) b.setTitleColor(.black, for: .normal) b.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) // b.contentEdgeInsets = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 18) return b }() private let emptyTipLabel: UILabel = { let l = UILabel() l.text = "还没有添加你的宠物哦~" l.font = .systemFont(ofSize: 16, weight: .medium) l.textColor = UIColor(hex: "#6B6B6B") l.textAlignment = .center return l }() private let emptyBanner: UIImageView = { let v = UIImageView() v.contentMode = .scaleToFill v.image = UIImage(named: "Home374") v.backgroundColor = UIColor(hex: "#FFF3D9") v.layer.cornerRadius = 12 return v }() // MARK: - Pet names row private let petNamesScroll = UIScrollView() private let petNamesStack = UIStackView() private var petNameButtons: [UIButton] = [] override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white addTopGradient() layout() populateDemo() setHasPet(true) fetchMemberInfo() // Tap pet card to switch pets let petTap = UITapGestureRecognizer(target: self, action: #selector(showPetPicker)) petCard.isUserInteractionEnabled = true petCard.addGestureRecognizer(petTap) } private func addTopGradient() { // 顶部淡米色渐变 -> 白色 let g = CAGradientLayer() g.colors = [UIColor(hex: "#FFF3D9").cgColor, UIColor.white.cgColor] g.startPoint = CGPoint(x: 0.5, y: 0.0) g.endPoint = CGPoint(x: 0.5, y: 0.4) g.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 220) view.layer.insertSublayer(g, at: 0) } private func layout() { // Scroll scrollView.showsVerticalScrollIndicator = false view.addSubview(scrollView) scrollView.translatesAutoresizingMaskIntoConstraints = false contentView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(contentView) // Header [titleLabel, settingsButton, petCard, tagScroll, recordStack, emptyStateView].forEach { $0.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview($0) } // Empty state & add pet addPetButton.translatesAutoresizingMaskIntoConstraints = false emptyTipLabel.translatesAutoresizingMaskIntoConstraints = false emptyBanner.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(addPetButton) contentView.addSubview(emptyTipLabel) contentView.addSubview(emptyBanner) styleAddPetButton() addPetButton.addTarget(self, action: #selector(tapAddPet), for: .touchUpInside) // Tag row tagScroll.showsHorizontalScrollIndicator = false tagStack.axis = .horizontal tagStack.spacing = 12 tagStack.alignment = .center tagScroll.addSubview(tagStack) tagStack.translatesAutoresizingMaskIntoConstraints = false // Records recordStack.axis = .vertical recordStack.spacing = 16 // Bottom bar & add button view.addSubview(tabBarView) tabBarView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(addButton) addButton.translatesAutoresizingMaskIntoConstraints = false // —— 添加“所有宠物名字”的横向滚动条 —— // petNamesScroll.showsHorizontalScrollIndicator = false petNamesScroll.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(petNamesScroll) petNamesStack.axis = .horizontal petNamesStack.alignment = .center petNamesStack.spacing = 12 petNamesStack.translatesAutoresizingMaskIntoConstraints = false petNamesScroll.addSubview(petNamesStack) NSLayoutConstraint.activate([ // Scroll/Content scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: tabBarView.topAnchor), contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), // Header row titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), settingsButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), settingsButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), settingsButton.heightAnchor.constraint(equalToConstant: 28), settingsButton.widthAnchor.constraint(equalToConstant: 28), // Add Pet Button (below titleLabel) addPetButton.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), addPetButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), addPetButton.heightAnchor.constraint(equalToConstant: 26), addPetButton.widthAnchor.constraint(equalToConstant: 64), // Pet names row petNamesScroll.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), petNamesScroll.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), petNamesScroll.trailingAnchor.constraint(equalTo: addPetButton.leadingAnchor, constant: -8), petNamesScroll.heightAnchor.constraint(equalToConstant: 26), petNamesStack.leadingAnchor.constraint(equalTo: petNamesScroll.leadingAnchor), petNamesStack.trailingAnchor.constraint(equalTo: petNamesScroll.trailingAnchor), petNamesStack.topAnchor.constraint(equalTo: petNamesScroll.topAnchor), petNamesStack.bottomAnchor.constraint(equalTo: petNamesScroll.bottomAnchor), // Pet card petCard.topAnchor.constraint(equalTo: petNamesScroll.bottomAnchor, constant: 17), petCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), petCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), petCard.heightAnchor.constraint(equalToConstant: 103), // Empty tip label (below petCard) emptyTipLabel.topAnchor.constraint(equalTo: petCard.bottomAnchor, constant: 137), emptyTipLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), // Empty banner (below emptyTipLabel) emptyBanner.topAnchor.constraint(equalTo: emptyTipLabel.bottomAnchor, constant: 16), emptyBanner.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), emptyBanner.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), emptyBanner.heightAnchor.constraint(equalToConstant: 120), // Tag row tagScroll.topAnchor.constraint(equalTo: petCard.bottomAnchor, constant: 12), tagScroll.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), tagScroll.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), tagScroll.heightAnchor.constraint(equalToConstant: 38), tagStack.leadingAnchor.constraint(equalTo: tagScroll.leadingAnchor), tagStack.trailingAnchor.constraint(equalTo: tagScroll.trailingAnchor), tagStack.topAnchor.constraint(equalTo: tagScroll.topAnchor), tagStack.bottomAnchor.constraint(equalTo: tagScroll.bottomAnchor), tagStack.heightAnchor.constraint(equalTo: tagScroll.heightAnchor), // Records list recordStack.topAnchor.constraint(equalTo: tagScroll.bottomAnchor, constant: 12), recordStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), recordStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), recordStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -24), // Empty state view emptyStateView.topAnchor.constraint(equalTo: tagScroll.bottomAnchor, constant: 50), emptyStateView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), emptyStateView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), emptyStateView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -100), // Bottom tab bar tabBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tabBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tabBarView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), // Add button (浮在中间) addButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), addButton.centerYAnchor.constraint(equalTo: tabBarView.topAnchor), addButton.widthAnchor.constraint(equalToConstant: 56), addButton.heightAnchor.constraint(equalToConstant: 56) ]) // Overlay (dim background) overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5) overlayView.alpha = 0 overlayView.isHidden = true overlayView.addTarget(self, action: #selector(hideActionMenu), for: .touchUpInside) view.insertSubview(overlayView, belowSubview: addButton) overlayView.translatesAutoresizingMaskIntoConstraints = false // Glow behind add button glowView.backgroundColor = UIColor.clear glowView.layer.cornerRadius = 28 glowView.alpha = 0 glowView.isHidden = true view.insertSubview(glowView, belowSubview: addButton) glowView.translatesAutoresizingMaskIntoConstraints = false // Configure glow gradient & shadow glowView.backgroundColor = .clear glowGradient.type = .radial // Neon glow color strictly FFE059 glowGradient.colors = [ UIColor(hex: "#FFE059").withAlphaComponent(0.95).cgColor, UIColor(hex: "#FFE059").withAlphaComponent(0.45).cgColor, UIColor(hex: "#FFE059").withAlphaComponent(0.0).cgColor ] glowGradient.locations = [0.0, 0.6, 1.0] // Keep glow gradient sized to the glowView glowGradient.frame = glowView.bounds glowGradient.cornerRadius = glowView.bounds.width/2 glowView.layer.cornerRadius = glowView.bounds.width/2 glowGradient.startPoint = CGPoint(x: 0.5, y: 0.5) glowGradient.endPoint = CGPoint(x: 1, y: 1) glowGradient.frame = glowView.bounds glowGradient.cornerRadius = glowView.bounds.width/2 glowView.layer.insertSublayer(glowGradient, at: 0) // soft outer shadow glowView.layer.shadowColor = UIColor(hex: "#FFE059").cgColor glowView.layer.shadowOpacity = 0.9 glowView.layer.shadowRadius = 24 glowView.layer.shadowOffset = .zero glowGradient.frame = glowView.bounds glowGradient.cornerRadius = glowView.bounds.width/2 // Action stacks func makeActionStack(button: UIButton, label: UILabel, systemImage: String, title: String) -> UIStackView { if let img = UIImage(named: systemImage) { button.setImage(img.withRenderingMode(.alwaysOriginal), for: .normal) } else { #if DEBUG print("⚠️ Image not found: \(systemImage)") #endif } button.imageView?.contentMode = .scaleAspectFit button.tintColor = .black button.backgroundColor = .white button.layer.cornerRadius = 28 button.layer.shadowColor = UIColor.black.cgColor button.layer.shadowOpacity = 0.15 button.layer.shadowRadius = 6 button.layer.shadowOffset = CGSize(width: 0, height: 3) button.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ button.widthAnchor.constraint(equalToConstant: 56), button.heightAnchor.constraint(equalToConstant: 56) ]) label.text = title label.font = .systemFont(ofSize: 12, weight: .medium) label.textColor = .white label.textAlignment = .center let stack = UIStackView(arrangedSubviews: [button, label]) stack.axis = .vertical stack.alignment = .center stack.spacing = 8 stack.alpha = 0 stack.isHidden = true stack.translatesAutoresizingMaskIntoConstraints = false return stack } let leftStack = makeActionStack(button: leftActionButton, label: leftActionLabel, systemImage: "Home359", title: "事件") let rightStack = makeActionStack(button: rightActionButton, label: rightActionLabel, systemImage: "Home360", title: "记账") // leftActionStack.addArrangedSubview(leftActionButton) // keep references populated (optional) // rightActionStack.addArrangedSubview(rightActionButton) view.addSubview(leftStack) view.addSubview(rightStack) leftActionButton.addTarget(self, action: #selector(tapQuickEvent), for: .touchUpInside) rightActionButton.addTarget(self, action: #selector(tapQuickNote), for: .touchUpInside) NSLayoutConstraint.activate([ overlayView.topAnchor.constraint(equalTo: view.topAnchor), overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor), overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor), overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor), glowView.centerXAnchor.constraint(equalTo: addButton.centerXAnchor), glowView.centerYAnchor.constraint(equalTo: addButton.centerYAnchor), glowView.widthAnchor.constraint(equalToConstant: 64), glowView.heightAnchor.constraint(equalToConstant: 64), leftStack.centerXAnchor.constraint(equalTo: addButton.centerXAnchor, constant: -90), leftStack.bottomAnchor.constraint(equalTo: addButton.topAnchor, constant: -24), rightStack.centerXAnchor.constraint(equalTo: addButton.centerXAnchor, constant: 90), rightStack.bottomAnchor.constraint(equalTo: addButton.topAnchor, constant: -24) ]) // Store stacks for later animations by tagging via accessibilityIdentifier leftStack.accessibilityIdentifier = "leftActionStack" rightStack.accessibilityIdentifier = "rightActionStack" // 注册宠物保存通知 NotificationCenter.default.addObserver(self, selector: #selector(handlePetDidSave(_:)), name: .petDidSave, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleEventRecordCreated(_:)), name: .eventRecordDidCreate, object: nil) } @objc private func handleEventRecordCreated(_ note: Notification) { // 优先取通知里传来的 petId;否则用当前选中的宠物 let pid = (note.userInfo?["petId"] as? String) ?? (currentPetIndex < pets.count ? pets[currentPetIndex].id : "") guard !pid.isEmpty else { return } // 刷新分类与事件记录(默认展示全部) fetchRecordCategories(petId: pid) fetchEventRecords(petId: pid, keyword: "") } @objc private func handlePetDidSave(_ note: Notification) { // Prefer userId from notification; fallback to persisted value let uidFromNote = note.userInfo?["userId"] as? String let persistedStr = UserDefaults.standard.string(forKey: "userId") let persistedInt = UserDefaults.standard.integer(forKey: "userId") let uid = uidFromNote ?? persistedStr ?? (persistedInt == 0 ? "" : String(persistedInt)) guard !uid.isEmpty else { return } // 重新拉取宠物列表并刷新UI fetchPetsForUser(userId: uid) } @objc private func tapAddPet() { // 检查登录状态 let isLoggedIn = UserDefaults.standard.bool(forKey: "isLogggedIn") let userToken = UserDefaults.standard.string(forKey: "userToken") // 如果没有登录状态或者没有有效的token,弹出登录界面 if !isLoggedIn || userToken?.isEmpty != false { showLoginViewController() return } let vc = AddPetViewController() navigationController?.pushViewController(vc, animated: true) } private func showLoginViewController() { let loginVC = LoginViewController() let navController = UINavigationController(rootViewController: loginVC) navController.modalPresentationStyle = .fullScreen present(navController, animated: true) } private func styleAddPetButton() { addPetButton.backgroundColor = UIColor(hex: "#FFE059") addPetButton.layer.cornerRadius = 13 addPetButton.clipsToBounds = true addPetButton.setTitleColor(.black, for: .normal) addPetButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .regular) // addPetButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 18) } private func setHasPet(_ value: Bool) { hasPet = value // addPetButton.isHidden = value tagScroll.isHidden = !value recordStack.isHidden = !value emptyStateView.isHidden = value // 没有宠物时显示空视图 emptyTipLabel.isHidden = value emptyBanner.isHidden = value if !value { petCard.configure( avatar: UIImage(named: "Home372")!, name: "添加我的宠物", breed: "品种", ageText: "年龄", daysText: "一起生活的第XX天" ) } // If true, do not touch petCard (keep current configuration) } private func populateDemo() { // Pet card demo petCard.configure( avatar: UIImage(named: "Home372") ?? UIImage(systemName: "pawprint")!, name: "宠物名字", breed: "宠物品种", ageText: "XX岁", daysText: "一起生活的第XX天" ) tabBarView.onSelect = { [weak self] idx in guard let self = self else { return } if idx == 0 { // 事件:显示首页,关闭记账浮层 self.hideBookkeeping() let topY = -self.scrollView.adjustedContentInset.top self.scrollView.setContentOffset(CGPoint(x: 0, y: topY), animated: true) } else { // 记账:作为子控制器叠加在内容区域(tabbar 之上) self.showBookkeeping() } } } @objc private func tapSettings() { print("Settings tapped") let vc = SettingsViewController() navigationController?.pushViewController(vc, animated: true) } @objc private func tapAdd() { isActionMenuShown ? hideActionMenu() : showActionMenu() } private func actionStacks() -> [UIStackView] { return view.subviews.compactMap { ($0 as? UIStackView)?.accessibilityIdentifier?.contains("ActionStack") == true ? ($0 as! UIStackView) : nil } } private func findStack(id: String) -> UIStackView? { view.subviews.first { ($0 as? UIStackView)?.accessibilityIdentifier == id } as? UIStackView } private func showActionMenu() { guard let leftStack = findStack(id: "leftActionStack"), let rightStack = findStack(id: "rightActionStack") else { return } isActionMenuShown = true overlayView.isHidden = false glowView.isHidden = false glowGradient.frame = glowView.bounds leftStack.isHidden = false rightStack.isHidden = false addButton.setImage(UIImage(named: "Home358"), for: .normal) startGlowPulse() view.layoutIfNeeded() leftStack.transform = CGAffineTransform(translationX: 0, y: 20) rightStack.transform = CGAffineTransform(translationX: 0, y: 20) UIView.animate(withDuration: 0.22) { self.overlayView.alpha = 1 self.glowView.alpha = 1 leftStack.alpha = 1; rightStack.alpha = 1 leftStack.transform = .identity rightStack.transform = .identity } } @objc private func hideActionMenu() { guard let leftStack = findStack(id: "leftActionStack"), let rightStack = findStack(id: "rightActionStack") else { return } UIView.animate(withDuration: 0.2, animations: { self.overlayView.alpha = 0 self.glowView.alpha = 0 leftStack.alpha = 0; rightStack.alpha = 0 leftStack.transform = CGAffineTransform(translationX: 0, y: 10) rightStack.transform = CGAffineTransform(translationX: 0, y: 10) }, completion: { _ in self.overlayView.isHidden = true leftStack.isHidden = true rightStack.isHidden = true self.addButton.setImage(UIImage(named: "Home357"), for: .normal) self.isActionMenuShown = false self.glowView.isHidden = true self.glowGradient.removeAllAnimations() self.stopGlowPulse() }) } @objc private func tapQuickEvent() { print("快速进入 随手小记") // 检查登录状态 let isLoggedIn = UserDefaults.standard.bool(forKey: "isLogggedIn") let userToken = UserDefaults.standard.string(forKey: "userToken") // 如果没有登录状态或者没有有效的token,弹出登录界面 if !isLoggedIn || userToken?.isEmpty != false { showLoginViewController() return } hideActionMenu() showCreateRecordSheet() } @objc private func tapQuickNote() { print("快速进入 记账") // 检查登录状态 let isLoggedIn = UserDefaults.standard.bool(forKey: "isLogggedIn") let userToken = UserDefaults.standard.string(forKey: "userToken") // 如果没有登录状态或者没有有效的token,弹出登录界面 if !isLoggedIn || userToken?.isEmpty != false { showLoginViewController() return } hideActionMenu() showBookkeepingQuickSheet() } private func startGlowPulse() { if glowGradient.animation(forKey: "pulse") != nil { return } let scale = CABasicAnimation(keyPath: "transform.scale") scale.fromValue = 0.95 scale.toValue = 1.15 scale.duration = 1.1 scale.autoreverses = true scale.repeatCount = .infinity let opacity = CABasicAnimation(keyPath: "opacity") opacity.fromValue = 0.7 opacity.toValue = 1.0 opacity.duration = 1.1 opacity.autoreverses = true opacity.repeatCount = .infinity let group = CAAnimationGroup() group.animations = [scale, opacity] group.duration = 1.1 group.autoreverses = true group.repeatCount = .infinity glowGradient.add(group, forKey: "pulse") } private func stopGlowPulse() { glowGradient.removeAnimation(forKey: "pulse") } // MARK: - Embed/Remove Bookkeeping (child VC keeps bottom tab visible) private func showBookkeeping() { // 已经展示就跳过 if let _ = bookkeepingVC { return } let vc = BookkeepingViewController() addChild(vc) view.insertSubview(vc.view, belowSubview: overlayView) vc.view.translatesAutoresizingMaskIntoConstraints = false // 占据安全区顶部到 tabBar 顶部之间的区域(不遮挡底部导航) NSLayoutConstraint.activate([ vc.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), vc.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), vc.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), vc.view.bottomAnchor.constraint(equalTo: tabBarView.topAnchor) ]) // 先确保 view 已加载,再设置宠物 vc.loadViewIfNeeded() vc.setPets(pets.map { ($0.id, $0.name) }, selectedIndex: currentPetIndex) // keep "+"、glow、action stacks and overlay above bookkeeping self.view.bringSubviewToFront(self.overlayView) self.view.bringSubviewToFront(self.addButton) self.view.bringSubviewToFront(self.glowView) if let l = self.findStack(id: "leftActionStack") { self.view.bringSubviewToFront(l) } if let r = self.findStack(id: "rightActionStack") { self.view.bringSubviewToFront(r) } vc.didMove(toParent: self) bookkeepingVC = vc // 可选:淡入 vc.view.alpha = 0 UIView.animate(withDuration: 0.22) { vc.view.alpha = 1 } } private func hideBookkeeping() { guard let vc = bookkeepingVC else { return } // 可选:淡出 UIView.animate(withDuration: 0.18, animations: { vc.view.alpha = 0 }) { _ in vc.willMove(toParent: nil) vc.view.removeFromSuperview() vc.removeFromParent() self.bookkeepingVC = nil } } // MARK: - Create Record Sheet (Custom Modal) private func showCreateRecordSheet() { if createRecordSheet == nil { let sheet = CreateRecordSheet() sheet.viewController = self createRecordSheet = sheet view.addSubview(sheet) sheet.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ sheet.topAnchor.constraint(equalTo: view.topAnchor), sheet.leadingAnchor.constraint(equalTo: view.leadingAnchor), sheet.trailingAnchor.constraint(equalTo: view.trailingAnchor), sheet.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) // Ensure the sheet is above everything (including bookkeeping) self.view.bringSubviewToFront(sheet) } createRecordSheet?.show() } // MARK: - Quick Bookkeeping Sheet (弹窗) private func showBookkeepingQuickSheet() { if bookkeepingQuickSheet == nil { let s = BookkeepingQuickSheet() // 选中某个记账分类后的回调(后续你可以在这里跳转到“新增记账”编辑页、带上已选分类) s.onItemSelected = { [weak self] name in print("选择记账分类: \(name)") // 点击分类后,叠加展示金额输入弹窗 self?.showBookkeepingAmountSheet(category: name) } s.onDismiss = { [weak self] in // 可留空;若想释放:self?.bookkeepingQuickSheet = nil } bookkeepingQuickSheet = s view.addSubview(s) s.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ s.topAnchor.constraint(equalTo: view.topAnchor), s.leadingAnchor.constraint(equalTo: view.leadingAnchor), s.trailingAnchor.constraint(equalTo: view.trailingAnchor), s.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } if let s = bookkeepingQuickSheet { // 确保在最上层(不被子控制器等覆盖) view.bringSubviewToFront(s) s.show() } } private func showBookkeepingAmountSheet(category: String) { if bookkeepingAmountSheet == nil { let s = BookkeepingAmountSheet() // 日期选择:优先用你的 DatePickerSheetController;没有就用内置 actionSheet s.onPickDate = { [weak self] current, handler in guard let self = self else { return } if let cls = NSClassFromString("DatePickerSheetController") as? UIViewController.Type, let picker = (cls as? NSObject.Type)?.init() as? UIViewController { // 如果你项目里的 DatePickerSheetController 对外有回调 onConfirm(Date) picker.setValue({ (d: Date) in handler(d) picker.dismiss(animated: true) }, forKey: "onConfirm") picker.setValue(current, forKey: "initialDate") self.present(picker, animated: true) } else { // 兜底:系统 UIDatePicker let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet) ac.modalPresentationStyle = .popover if let p = ac.popoverPresentationController{ // 锚到视图底部中间;去掉箭头,效果更像底部弹出 p.sourceView = self.view p.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.maxY - 1, width: 1, height: 1) p.permittedArrowDirections = [] p.delegate = self } let dp = UIDatePicker(frame: .zero) dp.datePickerMode = .date if #available(iOS 13.4, *) { dp.preferredDatePickerStyle = .wheels } else { // Fallback on earlier versions } dp.date = current ac.view.addSubview(dp) dp.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ dp.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor), dp.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8) ]) ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { _ in handler(dp.date) })) ac.addAction(UIAlertAction(title: "取消", style: .cancel)) self.present(ac, animated: true) } } // 宠物选择:用当前拿到的 pets 做一个 ActionSheet s.onPickPet = { [weak self] handler in guard let self = self else { return } guard !self.pets.isEmpty else { return } let ac = UIAlertController(title: "选择宠物", message: nil, preferredStyle: .actionSheet) ac.modalPresentationStyle = .popover if let p = ac.popoverPresentationController{ p.sourceView = self.view p.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.maxY - 1, width: 1, height: 1) p.permittedArrowDirections = [] p.delegate = self } for p in self.pets { ac.addAction(UIAlertAction(title: p.name, style: .default, handler: { _ in handler(p) })) } ac.addAction(UIAlertAction(title: "取消", style: .cancel)) self.present(ac, animated: true) } // 保存:发POST到后端 s.onSave = { [weak self] cate, date, pet, note, amountText in guard let self = self else { return } // 1) 解析 petId let current = (self.currentPetIndex >= 0 && self.currentPetIndex < self.pets.count) ? self.pets[self.currentPetIndex] : nil let usePet = pet ?? current guard let petIdStr = usePet?.id, let petId = Int(petIdStr) else { print("❌ 缺少 petId,无法提交") return } // 2) 金额 let amt = Double(amountText.replacingOccurrences(of: ",", with: "")) ?? 0 // 3) recordTypeId & 日期 let typeId = self.recordTypeId(for: cate) let dateStr = self.formatSendDate(date) // 4) 组装请求 guard let url = URL(string: "\(baseURL)/petRecordInfo/createRecord") else { return } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = UserDefaults.standard.string(forKey: "userToken") { req.setValue(token, forHTTPHeaderField: "Authorization") } let body: [String: Any] = [ "amount": amt, "note": note, "petId": petId, "recordDate": dateStr, "recordTypeId": typeId, "title": cate, "module": "account" ] do { req.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) } catch { print("❌ 序列化失败: \(error)") return } URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in if let http = resp as? HTTPURLResponse { print("📮 createRecord status: \(http.statusCode)") } if let err = err { print("❌ createRecord error: \(err)"); return } guard let data = data else { print("❌ createRecord 空响应"); return } if let json = try? JSONSerialization.jsonObject(with: data, options: []) { print("✅ createRecord 响应: \(json)") } // 成功后可刷新当前宠物的列表(如果你希望记账列表也依赖这个接口) DispatchQueue.main.async { // ✅ 通知记账页刷新(携带 petId / 日期 / 类目 / 金额) NotificationCenter.default.post( name: .bookkeepingRecordDidCreate, object: nil, userInfo: [ "petId": String(petId), "recordDate": dateStr, "title": cate, "amount": amt ] ) } }.resume() } bookkeepingAmountSheet = s view.addSubview(s) s.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ s.topAnchor.constraint(equalTo: view.topAnchor), s.leadingAnchor.constraint(equalTo: view.leadingAnchor), s.trailingAnchor.constraint(equalTo: view.trailingAnchor), s.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } if let s = bookkeepingAmountSheet { // 传入当前选中的宠物 let current = (currentPetIndex >= 0 && currentPetIndex < pets.count) ? pets[currentPetIndex] : nil s.configure(category: category, currentPet: current) view.bringSubviewToFront(s) // 放到最上层 s.show() } } private func formatSendDate(_ d: Date) -> String { let f = DateFormatter() f.locale = Locale(identifier: "zh_CN") f.dateFormat = "yyyy-MM-dd" return f.string(from: d) } private func recordTypeId(for title: String) -> Int { // 记账(module: account)映射——按后端给的 id // 为了兼容不同文案,做了少量同义词映射(如“草粮”→25) let map: [String: Int] = [ // 食物 21~25 "干粮": 21, "罐头": 22, "零食": 23, "冻干": 24, "草根": 25, // 生活 26~30 "衣服": 26, "玩具": 27, "餐具": 28, "厕所": 29, "牵引绳": 30, // 治病 31~35 "驱虫药": 31, "保健品": 32, "手术": 33, "药品": 34, "疫苗": 35, // 清洁 36~40 "指甲剪": 36, "梳子": 37, "清洁用品": 38, "洗澡美容": 39, "洗护用品": 40 ] return map[title] ?? 0 } // MARK: - Record Categories (single-select with "全部") private var currentCategories: [String] = [] private var categoryPills: [DottedPill] = [] private var selectedCategoryIndex: Int = 0 // 0 == 全部 // MARK: - Member Info Networking private func fetchMemberInfo() { guard let url = URL(string: "\(baseURL)/petRecordApUser/info") else { return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = UserDefaults.standard.string(forKey: "userToken") { request.setValue(token, forHTTPHeaderField: "Authorization") } let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in if let error = error { print("Error: \(error)") return } guard let data = data else { return } do { let decoder = JSONDecoder() let responseObject = try decoder.decode(MemberInfoResponse.self, from: data) if responseObject.code == "200" { // Store the data self?.storeMemberInfo(responseObject.data) } else { print("Error: \(responseObject.msg ?? "Unknown error")") } } catch { print("Error decoding response: \(error)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { // 切换到登录界面 self?.switchToLoginScreen() } } } task.resume() } private func switchToLoginScreen() { // 清除用户登录状态 UserDefaults.standard.set(false, forKey: "isLogggedIn") UserDefaults.standard.removeObject(forKey: "userToken") // // 创建登录界面 let loginVC = LoginViewController() let navController = UINavigationController(rootViewController: loginVC) navController.modalPresentationStyle = .fullScreen self.present(navController, animated: true) // // // 获取当前窗口 // guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { // return // } // // // 设置根视图控制器为登录界面 // window.rootViewController = navController // // // 添加切换动画 // UIView.transition(with: window, // duration: 0.4, // options: .transitionCrossDissolve, // animations: nil, // completion: nil) } private func storeMemberInfo(_ member: MemberInfo) { print("会员信息:") print("UserID: \(member.userId)") print("Phone: \(member.memberPhone)") print("Name: \(member.memberName)") print("Icon: \(member.memberIcon ?? "No Icon")") print("Email: \(member.memberEmail ?? "No Email")") print("Created Time: \(member.createdTime)") print("Updated Time: \(member.updatedTime)") if let token = UserDefaults.standard.string(forKey: "userToken") { print("token: \(token)") } // Store data in UserDefaults or another storage solution UserDefaults.standard.set(member.userId, forKey: "userId") UserDefaults.standard.set(member.memberPhone, forKey: "memberPhone") UserDefaults.standard.set(member.memberName, forKey: "memberName") UserDefaults.standard.set(member.memberIcon, forKey: "memberIcon") UserDefaults.standard.set(member.memberEmail, forKey: "memberEmail") UserDefaults.standard.set(member.createdTime, forKey: "createdTime") UserDefaults.standard.set(member.updatedTime, forKey: "updatedTime") // After we have userId, fetch pets for this user DispatchQueue.main.async { [weak self] in self?.fetchPetsForUser(userId: member.userId) } } // MARK: - Pets networking & UI private func fetchPetsForUser(userId: String) { var components = URLComponents(string: "\(baseURL)/petRecordPet/queryPetList") components?.queryItems = [URLQueryItem(name: "userId", value: userId)] guard let url = components?.url else { return } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = UserDefaults.standard.string(forKey: "userToken") { request.setValue(token, forHTTPHeaderField: "Authorization") } print("🐾 GET: \(url.absoluteString)") URLSession.shared.dataTask(with: request) { [weak self] data, response, error in if let http = response as? HTTPURLResponse { print("🐾 status: \(http.statusCode)") } if let error = error { print("🐾 pets error: \(error)"); return } guard let data = data else { print("🐾 pets empty data"); return } do { let decoder = JSONDecoder() let resp = try decoder.decode(PetListByUserResponse.self, from: data) if resp.code == "200" { DispatchQueue.main.async { self?.pets = resp.data if let first = resp.data.first { self?.currentPetIndex = 0 self?.buildPetNameChips() self?.applyPet(first) } } } else { print("🐾 pets server msg: \(resp.msg ?? "-") code=\(resp.code)") } } catch { print("🐾 pets decode error: \(error)") } }.resume() } private func applyPet(_ pet: PetSummary) { let breed = pet.breedName ?? "宠物品种" let ageText: String = { if let a = pet.age, !a.isEmpty { return "\(a)岁" } return "年龄" }() let days: String = { if let d = pet.togetherDays, !d.isEmpty { return "一起生活的第\(d)天" } return "一起生活的第XX天" }() let placeholder = UIImage(named: "Home372") ?? UIImage(systemName: "pawprint")! if let avatar = pet.avatar, let url = URL(string: avatar) { loadImage(from: url) { [weak self] img in self?.petCard.configure( avatar: img ?? placeholder, name: pet.name, breed: breed, ageText: ageText, daysText: days ) } } else { petCard.configure( avatar: placeholder, name: pet.name, breed: breed, ageText: ageText, daysText: days ) } updatePetNameSelection() // 拉取该宠物的记录分类(填充到标签行) fetchRecordCategories(petId: pet.id) // 拉取事件记录列表(默认全部) fetchEventRecords(petId: pet.id, keyword: "") } // 拉取记录分类列表(入参 petId)并渲染到 tag 行 private func fetchRecordCategories(petId: String) { var comps = URLComponents(string: "\(baseURL)/petRecordInfo/queryRecordListCategory") comps?.queryItems = [URLQueryItem(name: "petId", value: petId)] guard let url = comps?.url else { return } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = UserDefaults.standard.string(forKey: "userToken") { request.setValue(token, forHTTPHeaderField: "Authorization") } print("🏷️ GET: \(url.absoluteString)") URLSession.shared.dataTask(with: request) { [weak self] data, response, error in if let http = response as? HTTPURLResponse { print("🏷️ status: \(http.statusCode)") } if let error = error { print("🏷️ category error: \(error)"); return } guard let data = data else { print("🏷️ category empty data"); return } do { let decoder = JSONDecoder() let resp = try decoder.decode(RecordCategoriesResponse.self, from: data) if resp.code == "200" { DispatchQueue.main.async { self?.populateTagCategories(resp.data) } } else { print("🏷️ server msg: \(resp.msg ?? "-") code=\(resp.code)") } } catch { print("🏷️ decode error: \(error)") } }.resume() } // 用从接口拿到的分类填充 UI private func populateTagCategories(_ categories: [String]) { // 缓存数据 currentCategories = categories // 清空旧的 categoryPills.removeAll() tagStack.arrangedSubviews.forEach { v in tagStack.removeArrangedSubview(v) v.removeFromSuperview() } // 在最前面加上 “全部” let items: [String] = ["全部"] + categories // 永远展示(至少有“全部”) tagScroll.isHidden = items.isEmpty guard !items.isEmpty else { return } // 默认选中“全部” selectedCategoryIndex = min(selectedCategoryIndex, items.count - 1) for (idx, name) in items.enumerated() { let pill = DottedPill(text: name) pill.isUserInteractionEnabled = true pill.tag = idx // 选中态样式 pill.setSelected(idx == selectedCategoryIndex) // 点击切换(单选) let tap = UITapGestureRecognizer(target: self, action: #selector(tapCategoryPill(_:))) pill.addGestureRecognizer(tap) pill.translatesAutoresizingMaskIntoConstraints = false tagStack.addArrangedSubview(pill) categoryPills.append(pill) } } // MARK: - Event Records networking & UI private func fetchEventRecords(petId: String, keyword: String) { var comps = URLComponents(string: "\(baseURL)/petRecordInfo/queryEventRecordInfoList") comps?.queryItems = [ URLQueryItem(name: "petId", value: petId), URLQueryItem(name: "keyword", value: keyword) ] guard let url = comps?.url else { return } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let token = UserDefaults.standard.string(forKey: "userToken") { request.setValue(token, forHTTPHeaderField: "Authorization") } print("🗒️ GET: \(url.absoluteString)") URLSession.shared.dataTask(with: request) { [weak self] data, response, error in if let http = response as? HTTPURLResponse { print("🗒️ status: \(http.statusCode)") } if let error = error { print("🗒️ records error: \(error)"); return } guard let data = data else { print("🗒️ records empty data"); return } do { let decoder = JSONDecoder() let resp = try decoder.decode(EventRecordListResponse.self, from: data) if resp.code == "200" { DispatchQueue.main.async { self?.renderRecords(resp.data) } } else { print("🗒️ records server msg: \(resp.msg ?? "-") code=\(resp.code)") DispatchQueue.main.async { self?.renderRecords([]) } } } catch { print("🗒️ records decode error: \(error)") DispatchQueue.main.async { self?.renderRecords([]) } } }.resume() } private func renderRecords(_ list: [EventRecord]) { // 清空旧列表 recordStack.arrangedSubviews.forEach { v in recordStack.removeArrangedSubview(v) v.removeFromSuperview() } // 检查是否有数据,决定显示列表还是空视图 if list.isEmpty { showEmptyState() return } hideEmptyState() let placeholder = UIImage(named: "peihead") ?? UIImage(systemName: "pawprint.circle")! for rec in list { let card = RecordCard() // 填充内容 let dateText = formatAPIDate(rec.recordDate) let summary = (rec.content?.isEmpty == false) ? rec.content! : (rec.note ?? "") card.configure( avatar: placeholder, title: rec.title, date: dateText, badge: rec.petName, summary: summary ) // 异步加载头像 // if let urlStr = rec.avatar, let url = URL(string: urlStr) { // loadImage(from: url) { img in // // 简单重配一次以更新图片 // card.configure( // avatar: img ?? placeholder, // title: rec.title, // date: dateText, // badge: rec.petName, // summary: summary // ) // } // } recordStack.addArrangedSubview(card) } } // MARK: - 空视图管理 private func showEmptyState() { emptyStateView.isHidden = false recordStack.isHidden = true } private func hideEmptyState() { emptyStateView.isHidden = true recordStack.isHidden = false } private func formatAPIDate(_ s: String?) -> String { guard let s = s, !s.isEmpty else { return "" } let inFmt = DateFormatter() inFmt.locale = Locale(identifier: "en_US_POSIX") inFmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" // 例: 2025-08-26T00:00:00.000+08:00 if let date = inFmt.date(from: s) { let out = DateFormatter() out.locale = Locale(identifier: "zh_CN") out.dateFormat = "yyyy.MM.dd" return out.string(from: date) } return "" } // 构建“所有宠物名字”的一排按钮 private func buildPetNameChips() { // 清空旧内容 petNameButtons.removeAll() petNamesStack.arrangedSubviews.forEach { v in petNamesStack.removeArrangedSubview(v) v.removeFromSuperview() } for (idx, p) in pets.enumerated() { let btn = UIButton(type: .system) btn.setTitle(p.name, for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) btn.setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal) btn.backgroundColor = .clear btn.contentEdgeInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10) btn.layer.cornerRadius = 6 btn.layer.masksToBounds = true btn.tag = idx btn.addTarget(self, action: #selector(tapPetNameButton(_:)), for: .touchUpInside) petNamesStack.addArrangedSubview(btn) petNameButtons.append(btn) } updatePetNameSelection() } private func updatePetNameSelection() { for (i, b) in petNameButtons.enumerated() { let selected = (i == currentPetIndex) if selected { b.backgroundColor = UIColor(hex: "#FFE059") b.setTitleColor(.black, for: .normal) } else { b.backgroundColor = .clear b.setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal) } } } @objc private func tapCategoryPill(_ g: UITapGestureRecognizer) { guard let pill = g.view as? DottedPill else { return } let idx = pill.tag guard idx != selectedCategoryIndex, idx >= 0, idx < categoryPills.count else { return } // 单选:只允许一个选中 selectedCategoryIndex = idx for (i, p) in categoryPills.enumerated() { p.setSelected(i == selectedCategoryIndex) } // 触发记录过滤(keyword 为空表示全部) let keyword = (idx == 0) ? "" : currentCategories[idx - 1] let currentPetId = (currentPetIndex >= 0 && currentPetIndex < pets.count) ? pets[currentPetIndex].id : "" guard !currentPetId.isEmpty else { return } fetchEventRecords(petId: currentPetId, keyword: keyword) } @objc private func tapPetNameButton(_ sender: UIButton) { let idx = sender.tag guard idx >= 0, idx < pets.count else { return } currentPetIndex = idx applyPet(pets[idx]) // 切换卡片信息 updatePetNameSelection() // 同步高亮 } @objc private func showPetPicker() { guard !pets.isEmpty else { return } let ac = UIAlertController(title: "选择宠物", message: nil, preferredStyle: .actionSheet) // ✅ iPad 必须设置 popover 锚点 ac.modalPresentationStyle = .popover if let p = ac.popoverPresentationController { p.sourceView = petCard p.sourceRect = petCard.bounds p.permittedArrowDirections = .up p.delegate = self } for (idx, p) in pets.enumerated() { ac.addAction(UIAlertAction(title: p.name, style: .default, handler: { [weak self] _ in self?.currentPetIndex = idx self?.applyPet(p) })) } ac.addAction(UIAlertAction(title: "取消", style: .cancel)) present(ac, animated: true) } private func loadImage(from url: URL, completion: @escaping (UIImage?) -> Void) { let key = url.absoluteString as NSString if let cached = imageCache.object(forKey: key) { completion(cached) return } URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in var img: UIImage? = nil if let data = data { img = UIImage(data: data) } if let img = img { self?.imageCache.setObject(img, forKey: key) } DispatchQueue.main.async { completion(img) } }.resume() } // Create a struct to model the response struct MemberInfoResponse: Codable { let code: String let msg: String? let data: MemberInfo } struct MemberInfo: Codable { let userId: String let memberPhone: String let memberName: String let memberIcon: String? let memberEmail: String? let status: String? let createdTime: String let updatedTime: String } // Flexible code type (string or int) enum CodeValue: Codable { case string(String) case int(Int) init(from decoder: Decoder) throws { let c = try decoder.singleValueContainer() if let s = try? c.decode(String.self) { self = .string(s) } else if let i = try? c.decode(Int.self) { self = .int(i) } else { self = .string("") } } func equals200() -> Bool { switch self { case .string(let s): return s == "200"; case .int(let i): return i == 200 } } var rawString: String { switch self { case .string(let s): return s; case .int(let i): return String(i) } } } struct PetListByUserResponse: Codable { let code: String let msg: String? let data: [PetSummary] } struct PetSummary: Codable { let id: String let userId: String let name: String let nickname: String? let weight: String? let categoryId: String? let categoryName: String? let breedId: String? let breedName: String? let gender: String? let birthDate: String? let arrivalDate: String? let avatar: String? let age: String? let togetherDays: String? } // 拉取记录分类接口响应 struct RecordCategoriesResponse: Codable { let code: String let msg: String? let data: [String] } // 拉取事件记录列表响应 struct EventRecordListResponse: Codable { let code: String let msg: String? let data: [EventRecord] } struct EventRecord: Codable { let id: String let petId: String let avatar: String? let petName: String let recordTypeId: String? let recordUrl: String? let title: String let recordDate: String? let content: String? let amount: String? let note: String? let module: String? } // MARK: - UIPopoverPresentationControllerDelegate (fallback) func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) { if popoverPresentationController.sourceView == nil && popoverPresentationController.barButtonItem == nil { popoverPresentationController.sourceView = view popoverPresentationController.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 1, width: 1, height: 1) popoverPresentationController.permittedArrowDirections = [] } } deinit { NotificationCenter.default.removeObserver(self, name: .petDidSave, object: nil) } } // MARK: - PetDidSave Notification Name extension Notification.Name { static let petDidSave = Notification.Name("PetDidSave") // 已有类似事件通知就保留,这里新增记账创建成功通知 static let bookkeepingRecordDidCreate = Notification.Name("BookkeepingRecordDidCreate") } // MARK: - PetHeaderCard final class PetHeaderCard: UIView { private let container = UIImageView() private let avatarView = UIImageView() private let nameLabel = UILabel() private let chipStack = UIStackView() private let daysRibbon = PaddingLabel(insets: .init(top: 6, left: 12, bottom: 6, right: 12)) override init(frame: CGRect) { super.init(frame: frame) translatesAutoresizingMaskIntoConstraints = false container.translatesAutoresizingMaskIntoConstraints = false container.contentMode = .scaleToFill container.image = UIImage(named: "Home377") container.layer.shadowColor = UIColor.black.cgColor container.layer.shadowOpacity = 0.07 container.layer.shadowRadius = 8 container.layer.shadowOffset = .init(width: 0, height: 2) addSubview(container) avatarView.contentMode = .scaleAspectFill avatarView.layer.cornerRadius = 32 avatarView.clipsToBounds = true avatarView.backgroundColor = UIColor(hex: "#FFF3D9") nameLabel.font = .systemFont(ofSize: 15, weight: .semibold) nameLabel.textColor = UIColor(hex: "#2B2B2B") chipStack.axis = .horizontal chipStack.spacing = 8 chipStack.alignment = .center daysRibbon.backgroundColor = UIColor(hex: "#FFE059") daysRibbon.textColor = .black daysRibbon.font = .systemFont(ofSize: 10, weight: .medium) daysRibbon.layer.cornerRadius = 14 daysRibbon.layer.masksToBounds = true [avatarView, nameLabel, chipStack, daysRibbon].forEach { $0.translatesAutoresizingMaskIntoConstraints = false container.addSubview($0) } NSLayoutConstraint.activate([ container.topAnchor.constraint(equalTo: topAnchor), container.leadingAnchor.constraint(equalTo: leadingAnchor), container.trailingAnchor.constraint(equalTo: trailingAnchor), container.bottomAnchor.constraint(equalTo: bottomAnchor), avatarView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12), avatarView.centerYAnchor.constraint(equalTo: container.centerYAnchor), avatarView.widthAnchor.constraint(equalToConstant: 64), avatarView.heightAnchor.constraint(equalToConstant: 64), nameLabel.topAnchor.constraint(equalTo: avatarView.topAnchor, constant: 0), nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 10), nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: daysRibbon.leadingAnchor, constant: -8), chipStack.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor), chipStack.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 8), chipStack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12), daysRibbon.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), daysRibbon.topAnchor.constraint(equalTo: container.topAnchor, constant: 8) ]) } required init?(coder: NSCoder) { fatalError() } func configure(avatar: UIImage, name: String, breed: String, ageText: String, daysText: String) { avatarView.image = avatar nameLabel.text = name chipStack.arrangedSubviews.forEach { $0.removeFromSuperview() } [breed, ageText].forEach { let c = SoftChip(text: $0) chipStack.addArrangedSubview(c) } daysRibbon.text = daysText } } // MARK: - RecordCard final class RecordCard: UIView { private let container = UIImageView() private let iconView = UIImageView() private let titleLabel = UILabel() private let dateLabel = UILabel() private let badge = StrokeBadge() private let bubble = UIView() private let summaryLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) translatesAutoresizingMaskIntoConstraints = false container.translatesAutoresizingMaskIntoConstraints = false container.image = UIImage(named: "Home378") container.contentMode = .scaleToFill container.layer.shadowColor = UIColor.black.cgColor container.layer.shadowOpacity = 0.06 container.layer.shadowRadius = 6 container.layer.shadowOffset = .init(width: 0, height: 2) addSubview(container) iconView.contentMode = .scaleAspectFill iconView.layer.cornerRadius = 0 iconView.image = UIImage(named: "peihead") iconView.layer.masksToBounds = true iconView.backgroundColor = UIColor(hex: "#FFF3D9") titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) titleLabel.textColor = UIColor(hex: "#2B2B2B") dateLabel.font = .systemFont(ofSize: 12) dateLabel.textColor = UIColor(hex: "#8B8B8B") bubble.backgroundColor = .white bubble.layer.cornerRadius = 12 summaryLabel.font = .systemFont(ofSize: 13) summaryLabel.textColor = UIColor(hex: "#6B6B6B") summaryLabel.numberOfLines = 1 summaryLabel.lineBreakMode = .byTruncatingTail [iconView, titleLabel, dateLabel, badge, bubble].forEach { $0.translatesAutoresizingMaskIntoConstraints = false container.addSubview($0) } bubble.addSubview(summaryLabel) summaryLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ container.topAnchor.constraint(equalTo: topAnchor), container.leadingAnchor.constraint(equalTo: leadingAnchor), container.trailingAnchor.constraint(equalTo: trailingAnchor), container.bottomAnchor.constraint(equalTo: bottomAnchor), iconView.topAnchor.constraint(equalTo: container.topAnchor, constant: 17), iconView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 17), iconView.widthAnchor.constraint(equalToConstant: 56), iconView.heightAnchor.constraint(equalToConstant: 56), titleLabel.topAnchor.constraint(equalTo: iconView.topAnchor), titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10), badge.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), badge.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -17), dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10), dateLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), bubble.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 10), bubble.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12), bubble.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12), bubble.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12), bubble.heightAnchor.constraint(equalToConstant: 38), summaryLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12), summaryLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12), summaryLabel.centerYAnchor.constraint(equalTo: bubble.centerYAnchor) ]) } required init?(coder: NSCoder) { fatalError() } func configure(avatar: UIImage, title: String, date: String, badge: String, summary: String) { iconView.image = avatar titleLabel.text = title dateLabel.text = date self.badge.setText(badge) summaryLabel.text = summary } } // MARK: - BottomTabBar final class BottomTabBar: UIView { private let bar = UIView() private let leftBtn = BottomTabItem(normalImageName: "Home293", selectedImageName: "Home290", title: "事件", isSelected: true) private let rightBtn = BottomTabItem(normalImageName: "Home291", selectedImageName: "Home292", title: "记账") private let indicator = UIView() var onSelect: ((Int) -> Void)? override init(frame: CGRect) { super.init(frame: frame) translatesAutoresizingMaskIntoConstraints = false bar.backgroundColor = .white bar.layer.shadowColor = UIColor.black.cgColor bar.layer.shadowOpacity = 0.06 bar.layer.shadowRadius = 6 bar.layer.shadowOffset = .init(width: 0, height: -2) addSubview(bar) [leftBtn, rightBtn].forEach { bar.addSubview($0) $0.translatesAutoresizingMaskIntoConstraints = false } bar.translatesAutoresizingMaskIntoConstraints = false leftBtn.addTarget(self, action: #selector(tapLeft), for: .touchUpInside) rightBtn.addTarget(self, action: #selector(tapRight), for: .touchUpInside) indicator.backgroundColor = .black indicator.layer.cornerRadius = 2 NSLayoutConstraint.activate([ bar.topAnchor.constraint(equalTo: topAnchor), bar.leadingAnchor.constraint(equalTo: leadingAnchor), bar.trailingAnchor.constraint(equalTo: trailingAnchor), bar.bottomAnchor.constraint(equalTo: bottomAnchor), bar.heightAnchor.constraint(equalToConstant: 64), leftBtn.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 82), leftBtn.topAnchor.constraint(equalTo: bar.topAnchor,constant: 8), leftBtn.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -8), leftBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 80), rightBtn.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -82), rightBtn.topAnchor.constraint(equalTo: bar.topAnchor,constant: 8), rightBtn.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -8), rightBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 80), ]) } required init?(coder: NSCoder) { fatalError() } @objc private func tapLeft() { leftBtn.setSelected(true) rightBtn.setSelected(false) UIView.animate(withDuration: 0.2) { self.indicator.center.x = self.leftBtn.center.x } onSelect?(0) } @objc private func tapRight() { leftBtn.setSelected(false) rightBtn.setSelected(true) UIView.animate(withDuration: 0.2) { self.indicator.center.x = self.rightBtn.center.x } onSelect?(1) } } final class BottomTabItem: UIControl { private let iconView = UIImageView() private let label = UILabel() private let normalImage: UIImage? private let selectedImage: UIImage? init(normalImageName: String, selectedImageName: String, title: String, isSelected: Bool = false) { self.normalImage = UIImage(named: normalImageName)?.withRenderingMode(.alwaysOriginal) self.selectedImage = UIImage(named: selectedImageName)?.withRenderingMode(.alwaysOriginal) super.init(frame: .zero) iconView.contentMode = .scaleAspectFit iconView.image = normalImage label.text = title label.font = .systemFont(ofSize: 12) label.textColor = UIColor(hex: "#6B6B6B") let stack = UIStackView(arrangedSubviews: [iconView, label]) stack.axis = .vertical stack.alignment = .center stack.spacing = 4 addSubview(stack) stack.translatesAutoresizingMaskIntoConstraints = false iconView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), iconView.widthAnchor.constraint(equalToConstant: 22), iconView.heightAnchor.constraint(equalToConstant: 22) ]) // 让点击落到整个 control stack.isUserInteractionEnabled = false setSelected(isSelected) } required init?(coder: NSCoder) { fatalError() } func setSelected(_ sel: Bool) { iconView.image = sel ? selectedImage : normalImage label.textColor = sel ? .black : UIColor(hex: "#6B6B6B") } } // MARK: - Small Components final class SoftChip: PaddingLabel { init(text: String) { super.init(insets: .init(top: 6, left: 12, bottom: 6, right: 12)) self.text = text font = .systemFont(ofSize: 12, weight: .medium) textColor = UIColor(hex: "#6B6B6B") backgroundColor = .white layer.cornerRadius = 14 layer.masksToBounds = true layer.shadowColor = UIColor.black.cgColor layer.shadowOpacity = 0.06 layer.shadowRadius = 4 layer.shadowOffset = .init(width: 0, height: 1) } required init?(coder: NSCoder) { fatalError() } } final class DottedPill: PaddingLabel { private let dashLayer = CAShapeLayer() private(set) var isOn: Bool = false init(text: String) { super.init(insets: .init(top: 8, left: 16, bottom: 8, right: 16)) self.text = text font = .systemFont(ofSize: 12) textColor = UIColor(hex: "#5B4227") backgroundColor = .clear layer.cornerRadius = 10 layer.masksToBounds = true dashLayer.strokeColor = UIColor(hex: "#5B4227").cgColor dashLayer.fillColor = UIColor.clear.cgColor dashLayer.lineDashPattern = [6, 4] dashLayer.lineWidth = 1 layer.addSublayer(dashLayer) applySelectionStyle() } required init?(coder: NSCoder) { fatalError() } func setSelected(_ selected: Bool) { isOn = selected applySelectionStyle() } private func applySelectionStyle() { if isOn { backgroundColor = UIColor(hex: "#FFE059") textColor = .black dashLayer.isHidden = true } else { backgroundColor = .clear textColor = UIColor(hex: "#5B4227") dashLayer.isHidden = false } } override func layoutSubviews() { super.layoutSubviews() dashLayer.frame = bounds dashLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 18).cgPath } } final class StrokeBadge: PaddingLabel { init() { super.init(insets: .init(top: 4, left: 10, bottom: 4, right: 10)) textColor = UIColor(hex: "#5B4227") font = .systemFont(ofSize: 12) layer.cornerRadius = 12 layer.masksToBounds = true layer.borderWidth = 1 layer.borderColor = UIColor(hex: "#5B4227").cgColor } required init?(coder: NSCoder) { fatalError() } func setText(_ t: String) { text = t } } // MARK: - PaddingLabel (with insets) class PaddingLabel: UILabel { private var insets: UIEdgeInsets init(insets: UIEdgeInsets) { self.insets = insets super.init(frame: .zero) } required init?(coder: NSCoder) { fatalError() } override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) } override var intrinsicContentSize: CGSize { let size = super.intrinsicContentSize return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) } }