| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933 |
- //
- // 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<NSString, UIImage>()
-
- // --- 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)
- }
- }
|