| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093 |
- //
- // BookkeepingViewController.swift
- // VenusKitto
- //
- // Created by Neoa on 2025/8/26.
- //
- import Foundation
- import UIKit
- // MARK: - 简单模型(Cell 使用)
- fileprivate struct BookItem {
- let date: Date
- let title: String
- let amount: Double // 负数为支出,正数为收入
- let count: Int // 次数/数量
- let petName: String // ✅ 新增:用于 cell 上显示宠物名
- }
- fileprivate struct DayGroup {
- let date: Date
- let total: Double
- let items: [BookItem]
- }
- // MARK: - 注入的宠物(带 id)
- fileprivate struct BKPet {
- let id: String
- let name: String
- }
- // MARK: - 网络返回模型(记账账单)
- fileprivate struct BillRecordResponse: Codable {
- let code: String
- let msg: String?
- let data: BillData
- }
- fileprivate struct BillData: Codable {
- let petName: String
- let week: String?
- let dayExpend: String?
- let groupedData: [BillGroupDTO]
- let time: String?
- let totalCost: String
- }
- fileprivate struct BillGroupDTO: Codable {
- let date: String // yyyy.MM.dd
- let week: String?
- let dayTotal: String // 当天合计
- let items: [BillItemDTO]
- }
- fileprivate struct BillItemDTO: 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: - BookkeepingViewController (记账页面)
- final class BookkeepingViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate, UIPopoverPresentationControllerDelegate {
- // 顶部标题
- 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 lazy var addBarItem: UIBarButtonItem = {
- UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(tapAdd))
- }()
- // Header 子模块
- private let petsRow = UIStackView()
- private let periodRow = UIStackView()
- private let monthRow = UIStackView()
- private let monthLeft = UIButton(type: .system)
- private let monthRight = UIButton(type: .system)
- private let monthLabel = UILabel()
- private let summaryCard = SummaryCardView()
- // 分段:总 / 年 / 月
- private let totalChip = SegmentChipButton(title: "总",
- maskedCorners: [.layerMinXMinYCorner, .layerMinXMaxYCorner],
- cornerRadius: 12)
- private let yearChip = SegmentChipButton(title: "年",
- maskedCorners: [],
- cornerRadius: 0)
- private let monthChip = SegmentChipButton(title: "月",
- maskedCorners: [.layerMaxXMinYCorner, .layerMaxXMaxYCorner],
- cornerRadius: 12)
- // 列表
- private let tableView = UITableView(frame: .zero, style: .grouped)
- // tableHeader 容器,用于在布局变化时重算高度
- private var headerContainer: UIView?
- // 数据(由外部注入,带 id)
- private var pets: [BKPet] = []
- private var selectedPetIndex: Int = 0
- // 期间:0=总 1=年 2=月(目前服务端以月为主,我们都传当前年+月)
- private var periodIndex: Int = 2
- private var currentMonth: Date = Date()
- private var dayGroups: [DayGroup] = []
- // 年份选择支持
- private var yearOptions: [Int] = []
- private var tempSelectedYear: Int = Calendar.current.component(.year, from: Date())
- private var monthOptions: [Int] = Array(1...12)
- private var tempSelectedMonth: Int = Calendar.current.component(.month, from: Date())
- /// 清空并填充宠物 Chips(避免重复添加)
- private func populatePetsRow() {
- // 先移除旧的
- petsRow.arrangedSubviews.forEach { v in
- petsRow.removeArrangedSubview(v)
- v.removeFromSuperview()
- }
- // 再添加新的
- for (i, p) in pets.enumerated() {
- let btn = ChipButton(title: p.name)
- // 设置强 hugging/压缩优先级,使按钮不被拉伸
- btn.setContentHuggingPriority(.required, for: .horizontal)
- btn.setContentCompressionResistancePriority(.required, for: .horizontal)
- btn.tag = i
- btn.addTarget(self, action: #selector(tapPet(_:)), for: .touchUpInside)
- petsRow.addArrangedSubview(btn)
- }
- // 在末尾加一个弹性占位视图,使宠物名称始终靠左
- let spacer = UIView()
- spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- petsRow.addArrangedSubview(spacer)
- updatePetChips()
- }
- override func viewDidLoad() {
- super.viewDidLoad()
- view.backgroundColor = UIColor(hex: "#FFFEFC")
- navigationItem.rightBarButtonItem = addBarItem
- buildTopBar()
- buildHeader()
- buildTable()
- // 若外部尚未注入宠物,提供一个占位按钮
- if pets.isEmpty { pets = [BKPet(id: "", name: "当前宠物")] }
- refetch() // 优先拉接口;无 petId 时回落到本地 demo
-
- // ✅ 监听“记账创建成功”的通知
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(handleBookkeepingRecordCreated(_:)),
- name: .bookkeepingRecordDidCreate,
- object: nil
- )
- }
-
- @objc private func handleBookkeepingRecordCreated(_ note: Notification) {
- // 如果通知里带了 petId,仅当前选中的宠物匹配时刷新;否则直接刷新
- if let pid = note.userInfo?["petId"] as? String, let cur = currentPetId() {
- if pid == cur {
- refetch()
- }
- } else {
- refetch()
- }
- }
-
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
-
- @objc private func tapSettings() {
- print("Settings tapped")
- let vc = SettingsViewController()
- navigationController?.pushViewController(vc, animated: true)
- }
- // 外部注入宠物列表(带 id)
- func setPets(_ list: [(id: String, name: String)], selectedIndex: Int = 0) {
- self.pets = list.map { BKPet(id: $0.id, name: $0.name) }
- self.selectedPetIndex = max(0, min(selectedIndex, pets.count - 1))
- // 重建宠物 chips(统一入口,避免重复)
- populatePetsRow()
- refetch()
- }
- private func currentPetId() -> String? {
- guard !pets.isEmpty else { return nil }
- let idx = max(0, min(selectedPetIndex, pets.count - 1))
- let pid = pets[idx].id.trimmingCharacters(in: .whitespacesAndNewlines)
- return pid.isEmpty ? nil : pid
- }
- private func buildTopBar() {
- view.addSubview(titleLabel)
- view.addSubview(settingsButton)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- settingsButton.translatesAutoresizingMaskIntoConstraints = false
-
- NSLayoutConstraint.activate([
- titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
- titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
-
- settingsButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
- settingsButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
- settingsButton.heightAnchor.constraint(equalToConstant: 28),
- settingsButton.widthAnchor.constraint(equalToConstant: 28),
- ])
- }
- private func buildHeader() {
- // 宠物行
- petsRow.axis = .horizontal
- petsRow.spacing = 12
- petsRow.alignment = .center
- petsRow.distribution = .fill // 让内容按自身尺寸布局,配合尾部 spacer 左对齐
- // 如果还没填充,才初始化一次,避免在 setPets 之后重复追加
- if petsRow.arrangedSubviews.isEmpty {
- if pets.isEmpty { pets = [BKPet(id: "", name: "当前宠物")] }
- populatePetsRow()
- }
- // 期间行(总 / 年 / 月)—— 左对齐、0 间距;“年”仅左侧圆角,“月”仅右侧圆角
- periodRow.axis = .horizontal
- periodRow.spacing = 0
- periodRow.alignment = .fill
- periodRow.distribution = .fill
- // 处理点击
- totalChip.tag = 100
- totalChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside)
- yearChip.tag = 101
- yearChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside)
- monthChip.tag = 102
- monthChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside)
- // "总 年 月" 作为一个零间距的分段组
- let segmentGroup = UIStackView(arrangedSubviews: [totalChip, yearChip, monthChip])
- segmentGroup.axis = .horizontal
- segmentGroup.spacing = 0
- segmentGroup.alignment = .fill
- segmentGroup.distribution = .fill
- segmentGroup.setContentHuggingPriority(.required, for: .horizontal)
- periodRow.addArrangedSubview(segmentGroup)
- // 不再在 periodRow 内添加 spacer;让外层容器控制左右分布
- updatePeriodChips()
- // 月份选择
- monthRow.axis = .horizontal
- monthRow.alignment = .center
- monthRow.spacing = 8
- monthLeft.setImage(UIImage(systemName: "chevron.left"), for: .normal)
- monthRight.setImage(UIImage(systemName: "chevron.right"), for: .normal)
- [monthLeft, monthRight].forEach { $0.tintColor = UIColor(hex: "#2B2B2B") }
- monthLeft.addTarget(self, action: #selector(prevMonth), for: .touchUpInside)
- monthRight.addTarget(self, action: #selector(nextMonth), for: .touchUpInside)
- monthLabel.font = .systemFont(ofSize: 16, weight: .semibold)
- monthLabel.textColor = UIColor(hex: "#2B2B2B")
- monthLabel.isUserInteractionEnabled = true
- let _tapMonth = UITapGestureRecognizer(target: self, action: #selector(tapMonthLabel))
- monthLabel.addGestureRecognizer(_tapMonth)
- // 推荐:hugging/压缩优先级
- monthLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
- monthLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- monthRow.addArrangedSubview(monthLeft)
- monthRow.addArrangedSubview(monthLabel)
- monthRow.addArrangedSubview(monthRight)
- updateMonthLabel()
- // periodRow + monthRow 合并到同一行:左“总/年/月”,右“月份选择”
- let flexibleSpacer = UIView()
- flexibleSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- flexibleSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- // 将 periodRow 只承载分段(无内部 spacer),monthRow 放右侧并保持自身大小
- monthRow.setContentHuggingPriority(.required, for: .horizontal)
- monthRow.setContentCompressionResistancePriority(.required, for: .horizontal)
- let periodMonthRow = UIStackView(arrangedSubviews: [periodRow, flexibleSpacer, monthRow])
- periodMonthRow.axis = .horizontal
- periodMonthRow.alignment = .center
- periodMonthRow.spacing = 8
- periodMonthRow.distribution = .fill
- // header 内容栈
- let headerStack = UIStackView(arrangedSubviews: [petsRow, periodMonthRow, summaryCard])
- headerStack.axis = .vertical
- headerStack.spacing = 14
- headerStack.alignment = .fill
- headerStack.isLayoutMarginsRelativeArrangement = true
- headerStack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
- // 新实现:不挂到 tableHeaderView,等 viewDidLayoutSubviews
- let container = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
- container.autoresizingMask = [.flexibleWidth]
- container.addSubview(headerStack)
- headerStack.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- headerStack.topAnchor.constraint(equalTo: container.topAnchor),
- headerStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
- headerStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
- headerStack.bottomAnchor.constraint(equalTo: container.bottomAnchor)
- ])
- // 仅缓存,不在此时挂到 tableHeaderView,等待尺寸可用后在 viewDidLayoutSubviews 里再挂
- self.headerContainer = container
- }
- private func buildTable() {
- tableView.backgroundColor = .clear
- tableView.separatorStyle = .none
- tableView.dataSource = self
- tableView.delegate = self
- tableView.register(ExpenseCell.self, forCellReuseIdentifier: "ExpenseCell")
- view.addSubview(tableView)
- tableView.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- tableView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
- tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
- ])
- tableView.estimatedRowHeight = 121
- tableView.rowHeight = UITableView.automaticDimension
- }
- // 让 tableHeader 自适应高度与宽度(用于旋转/尺寸变化)
- override func viewDidLayoutSubviews() {
- super.viewDidLayoutSubviews()
- guard let container = headerContainer,
- let headerStack = container.subviews.first as? UIStackView else { return }
- // 只有当 tableView 已经有了确定的宽度时才挂载/更新 header
- let width = tableView.bounds.width
- guard width > 0 else { return }
- if container.frame.width != width {
- container.frame.size.width = width
- }
- let target = CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)
- let height = headerStack.systemLayoutSizeFitting(
- target,
- withHorizontalFittingPriority: .required,
- verticalFittingPriority: .fittingSizeLevel
- ).height
- if tableView.tableHeaderView == nil {
- // 首次挂载
- container.frame.size.height = height
- tableView.tableHeaderView = container
- } else {
- // 尺寸变化时更新
- if container.frame.height != height {
- container.frame.size.height = height
- tableView.tableHeaderView = container // 重新赋值以使变更生效
- }
- }
- // 避免底部与外层 TabBar 重叠,留出滚动区域
- let extraBottom: CGFloat = 24
- if tableView.contentInset.bottom != extraBottom {
- tableView.contentInset.bottom = extraBottom
- tableView.scrollIndicatorInsets.bottom = extraBottom
- }
- }
- // MARK: - 触发刷新 & 网络请求
- private func refetch() {
- guard let pid = currentPetId() else {
- // 无 petId(占位宠物),展示本地 demo
- reloadDemoData()
- return
- }
- fetchBillRecords(petId: pid, month: currentMonth)
- }
- private func fetchBillRecords(petId: String, month: Date) {
- let yFmt = DateFormatter(); yFmt.dateFormat = "yyyy"
- let ymFmt = DateFormatter(); ymFmt.dateFormat = "yyyy.MM"
- let yearStr: String
- let yearMonthStr: String
- switch periodIndex {
- case 0: // 总:都传空
- yearStr = ""
- yearMonthStr = ""
- case 1: // 年:只传 year
- yearStr = yFmt.string(from: month)
- yearMonthStr = ""
- default: // 月:同时传 year 和 yearMonth
- yearStr = yFmt.string(from: month)
- yearMonthStr = ymFmt.string(from: month)
- }
- var comps = URLComponents(string: "\(baseURL)/petRecordInfo/queryBillRecordInfoList")
- comps?.queryItems = [
- URLQueryItem(name: "petId", value: petId),
- URLQueryItem(name: "year", value: yearStr),
- URLQueryItem(name: "yearMonth", value: yearMonthStr)
- ]
- guard let url = comps?.url else { return }
- var req = URLRequest(url: url)
- req.httpMethod = "GET"
- req.setValue("application/json", forHTTPHeaderField: "Content-Type")
- if let token = UserDefaults.standard.string(forKey: "userToken") {
- req.setValue(token, forHTTPHeaderField: "Authorization")
- }
- URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in
- if let http = resp as? HTTPURLResponse { print("📊 bill status: \(http.statusCode)") }
- if let err = err { print("❌ bill error: \(err)"); return }
- guard let data = data else { print("❌ bill empty data"); return }
- do {
- let decoder = JSONDecoder()
- let rsp = try decoder.decode(BillRecordResponse.self, from: data)
- if rsp.code == "200" {
- DispatchQueue.main.async { self?.applyBillData(rsp.data) }
- } else {
- print("📊 bill server msg: \(rsp.msg ?? "-") code=\(rsp.code)")
- }
- } catch {
- print("❌ bill decode error: \(error)")
- }
- }.resume()
- }
- private func applyBillData(_ data: BillData) {
- // 顶部汇总卡
- let yF = DateFormatter(); yF.dateFormat = "yyyy"
- let ymF = DateFormatter(); ymF.dateFormat = "yyyy.MM"
- let total = Double(data.totalCost) ?? 0
- let suffix: String
- switch periodIndex {
- case 0: suffix = "合计花费"
- case 1: suffix = "\(yF.string(from: currentMonth))花费"
- default: suffix = "\(ymF.string(from: currentMonth))花费"
- }
- summaryCard.configure(title: "\(data.petName) \(suffix)", amount: -total)
-
- // 分组 -> dayGroups
- let df = DateFormatter(); df.dateFormat = "yyyy.MM.dd"; df.locale = Locale(identifier: "zh_CN")
- var groups: [DayGroup] = []
- for g in data.groupedData {
- let date = df.date(from: g.date) ?? Date()
- var items: [BookItem] = []
- for it in g.items {
- let amt = Double(it.amount ?? "0") ?? 0
- items.append(BookItem(date: date, title: it.title, amount: -amt, count: 1, petName: it.petName))
- }
- let dayTotal = -(Double(g.dayTotal) ?? items.reduce(0) { $0 + $1.amount })
- groups.append(DayGroup(date: date, total: dayTotal, items: items))
- }
- groups.sort { $0.date > $1.date }
- self.dayGroups = groups
- self.tableView.reloadData()
- }
- // MARK: - Demo 数据(本地兜底)
- private func reloadDemoData() {
- let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
- let d1 = fmt.date(from: "2025-08-25")!
- let demoPetName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name
- let items = [
- BookItem(date: d1, title: "指甲剪", amount: -10, count: 1, petName: demoPetName),
- BookItem(date: d1, title: "厕所", amount: -50, count: 1, petName: demoPetName)
- ]
- // 分组
- let groups = Dictionary(grouping: items, by: { dayOnly($0.date) })
- dayGroups = groups.keys.sorted(by: >).map { day in
- let its = groups[day]!.sorted { $0.title < $1.title }
- let total = its.reduce(0) { $0 + $1.amount }
- return DayGroup(date: day, total: total, items: its)
- }
- // 顶部汇总卡
- let monthTotal = items.reduce(0) { $0 + $1.amount }
- let petName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name
- summaryCard.configure(title: "\(petName) \(DateFormatter.with("yyyy.MM").string(from: currentMonth))花费", amount: monthTotal)
- tableView.reloadData()
- }
- @objc private func tapAdd() {
- // TODO: push 新增账目
- print("➕ Add bookkeeping")
- }
- @objc private func tapPet(_ sender: UIButton) {
- selectedPetIndex = sender.tag
- updatePetChips()
- refetch()
- }
- @objc private func tapPeriod(_ sender: UIButton) {
- periodIndex = sender.tag - 100
- updatePeriodChips()
- updateMonthLabel() // ← 新增:切换分段后立刻刷新“月份选择”区
- refetch()
- }
- @objc private func prevMonth() {
- guard periodIndex != 0 else { return } // “总”下不允许切换
- if periodIndex == 1 { // 年视图:按年切
- currentMonth = Calendar.current.date(byAdding: .year, value: -1, to: currentMonth)!
- } else { // 月视图:按月切
- currentMonth = Calendar.current.date(byAdding: .month, value: -1, to: currentMonth)!
- }
- updateMonthLabel()
- refetch()
- }
- @objc private func nextMonth() {
- guard periodIndex != 0 else { return }
- if periodIndex == 1 {
- currentMonth = Calendar.current.date(byAdding: .year, value: 1, to: currentMonth)!
- } else {
- currentMonth = Calendar.current.date(byAdding: .month, value: 1, to: currentMonth)!
- }
- updateMonthLabel()
- refetch()
- }
-
- @objc private func tapMonthLabel() {
- switch periodIndex {
- case 1:
- showYearPickerSheet()
- case 2:
- showYearMonthPickerSheet()
- default:
- break
- }
- }
- private func showYearMonthPickerSheet() {
- // 年月两列选择(不显示日)
- let cur = currentMonth
- let curY = Calendar.current.component(.year, from: cur)
- let curM = Calendar.current.component(.month, from: cur)
- // 准备年份与默认选中
- yearOptions = Array((curY - 10)...(curY + 10))
- tempSelectedYear = curY
- tempSelectedMonth = curM
- let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet)
- if let p = ac.popoverPresentationController {
- p.sourceView = monthLabel
- p.sourceRect = monthLabel.bounds
- p.permittedArrowDirections = .up
- p.delegate = self
- }
- let pv = UIPickerView()
- pv.tag = 99 // 99 表示“年月”两列
- pv.dataSource = self
- pv.delegate = self
- ac.view.addSubview(pv)
- pv.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- pv.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor),
- pv.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8),
- pv.widthAnchor.constraint(equalTo: ac.view.widthAnchor)
- ])
- if let yIdx = yearOptions.firstIndex(of: curY) { pv.selectRow(yIdx, inComponent: 0, animated: false) }
- pv.selectRow(curM - 1, inComponent: 1, animated: false)
- ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { [weak self] _ in
- guard let self = self else { return }
- let newDate = Calendar.current.date(from: DateComponents(year: self.tempSelectedYear, month: self.tempSelectedMonth, day: 1)) ?? self.currentMonth
- self.currentMonth = newDate
- self.updateMonthLabel()
- self.refetch()
- }))
- ac.addAction(UIAlertAction(title: "取消", style: .cancel))
- present(ac, animated: true)
- }
- private func showYearPickerSheet() {
- // 备选年份(当前年±10年)
- let curY = Calendar.current.component(.year, from: currentMonth)
- let start = curY - 10
- let end = curY + 10
- yearOptions = Array(start...end)
- tempSelectedYear = curY
- let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet)
- if let p = ac.popoverPresentationController {
- p.sourceView = monthLabel
- p.sourceRect = monthLabel.bounds
- p.permittedArrowDirections = .up
- p.delegate = self
- }
- let pv = UIPickerView()
- pv.tag = 98 // 98 表示“仅年份”一列
- pv.dataSource = self
- pv.delegate = self
- if let idx = yearOptions.firstIndex(of: curY) { pv.selectRow(idx, inComponent: 0, animated: false) }
- ac.view.addSubview(pv)
- pv.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- pv.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor),
- pv.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8),
- pv.widthAnchor.constraint(equalTo: ac.view.widthAnchor)
- ])
- ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { [weak self] _ in
- guard let self = self else { return }
- let comps = Calendar.current.dateComponents([.month], from: self.currentMonth)
- let new = Calendar.current.date(from: DateComponents(year: self.tempSelectedYear, month: comps.month ?? 1, day: 1)) ?? self.currentMonth
- self.currentMonth = new
- self.updateMonthLabel()
- self.refetch()
- }))
- ac.addAction(UIAlertAction(title: "取消", style: .cancel))
- present(ac, animated: true)
- }
- private func updateMonthLabel() {
- switch periodIndex {
- case 0: // 总
- monthLabel.text = "总"
- monthLeft.isHidden = true
- monthRight.isHidden = true
- case 1: // 年
- let y = DateFormatter(); y.dateFormat = "yyyy"
- monthLabel.text = y.string(from: currentMonth)
- monthLeft.isHidden = false
- monthRight.isHidden = false
- default: // 月
- let f = DateFormatter(); f.dateFormat = "yyyy.MM"
- monthLabel.text = f.string(from: currentMonth)
- monthLeft.isHidden = false
- monthRight.isHidden = false
- }
- }
-
- private func updatePetChips() {
- for (i, v) in petsRow.arrangedSubviews.enumerated() {
- (v as? ChipButton)?.isOn = (i == selectedPetIndex)
- }
- }
- private func updatePeriodChips() {
- totalChip.isOn = (periodIndex == 0)
- yearChip.isOn = (periodIndex == 1)
- monthChip.isOn = (periodIndex == 2)
- }
- // MARK: - Table
- func numberOfSections(in tableView: UITableView) -> Int { dayGroups.count }
- func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { dayGroups[section].items.count }
- func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
- let v = DayHeaderView()
- let g = dayGroups[section]
- v.configure(date: g.date, total: g.total)
- return v
- }
- func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { 50 }
- func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- let cell = tableView.dequeueReusableCell(withIdentifier: "ExpenseCell", for: indexPath) as! ExpenseCell
- let item = dayGroups[indexPath.section].items[indexPath.row]
- cell.configure(title: item.title, amount: item.amount, petName: item.petName)
- return cell
- }
- func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- tableView.deselectRow(at: indexPath, animated: true)
- }
- func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
- return 121
- }
- func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
- return 121
- }
-
- // MARK: - UIPickerView DataSource & Delegate (Year/Year-Month)
- func numberOfComponents(in pickerView: UIPickerView) -> Int {
- return pickerView.tag == 99 ? 2 : 1
- }
- func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
- if pickerView.tag == 99 {
- return component == 0 ? yearOptions.count : monthOptions.count
- } else {
- return yearOptions.count
- }
- }
- func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
- if pickerView.tag == 99 {
- if component == 0 {
- return "\(yearOptions[row])年"
- } else {
- return String(format: "%02d月", monthOptions[row])
- }
- } else {
- return "\(yearOptions[row])年"
- }
- }
- func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
- if pickerView.tag == 99 {
- if component == 0, row >= 0, row < yearOptions.count {
- tempSelectedYear = yearOptions[row]
- } else if component == 1, row >= 0, row < monthOptions.count {
- tempSelectedMonth = monthOptions[row]
- }
- } else {
- if row >= 0 && row < yearOptions.count { tempSelectedYear = yearOptions[row] }
- }
- }
-
- // MARK: - UIPopoverPresentationControllerDelegate fallback for iPad popover
- func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) {
- if popoverPresentationController.sourceView == nil && popoverPresentationController.barButtonItem == nil {
- popoverPresentationController.sourceView = monthLabel.superview ?? view
- let base = popoverPresentationController.sourceView!
- popoverPresentationController.sourceRect = base.bounds
- popoverPresentationController.permittedArrowDirections = .up
- }
- }
- }
- // MARK: - 小组件
- fileprivate final class ChipButton: UIButton {
- var isOn: Bool = false { didSet { refresh() } }
- private let inset = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10)
- init(title: String) {
- super.init(frame: .zero)
- setTitle(title, for: .normal)
- titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
- setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
- layer.cornerRadius = 12
- contentEdgeInsets = inset
- backgroundColor = .clear
- refresh()
- }
- required init?(coder: NSCoder) { fatalError() }
- private func refresh() {
- layer.cornerRadius = 12
- if isOn {
- backgroundColor = UIColor(hex: "#FFE059")
- setTitleColor(.black, for: .normal)
- } else {
- backgroundColor = .clear
- setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
- }
- }
- }
- fileprivate final class SegmentChipButton: UIButton {
- var isOn: Bool = false { didSet { refresh() } }
- private let inset = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
- private let masked: CACornerMask
- private let cornerRadius: CGFloat
- init(title: String, maskedCorners: CACornerMask, cornerRadius: CGFloat = 12) {
- self.masked = maskedCorners
- self.cornerRadius = cornerRadius
- super.init(frame: .zero)
- setTitle(title, for: .normal)
- titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
- setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
- contentEdgeInsets = inset
-
- // 仅指定的角为圆角,其余为直角
- layer.cornerRadius = cornerRadius
- layer.maskedCorners = masked
- layer.masksToBounds = true
- backgroundColor = .clear
- refresh()
- }
- required init?(coder: NSCoder) { fatalError() }
- private func refresh() {
- layer.cornerRadius = cornerRadius
- if isOn {
- backgroundColor = UIColor(hex: "#FFE059")
- setTitleColor(.black, for: .normal)
- } else {
- backgroundColor = UIColor(hex: "#FEF3C1")
- setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
- }
- }
- }
- fileprivate final class SummaryCardView: UIView {
- private let container = UIImageView()
- private let titleLabel = UILabel()
- private let amountLabel = UILabel()
- override init(frame: CGRect) {
- super.init(frame: frame)
- translatesAutoresizingMaskIntoConstraints = false
- container.backgroundColor = .white
- container.image = UIImage(named: "Home378")
- container.layer.cornerRadius = 14
- // container.layer.borderWidth = 1
- // container.layer.borderColor = UIColor(hex: "#2B2B2B").withAlphaComponent(0.15).cgColor
- 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)
- [titleLabel, amountLabel].forEach { container.addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false }
- titleLabel.font = .systemFont(ofSize: 14)
- titleLabel.textColor = UIColor(hex: "#2B2B2B")
- amountLabel.font = .systemFont(ofSize: 22, weight: .semibold)
- amountLabel.textColor = UIColor(hex: "#2B2B2B")
- amountLabel.textAlignment = .right
- container.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- container.heightAnchor.constraint(equalToConstant: 100),
- container.leadingAnchor.constraint(equalTo: leadingAnchor),
- container.trailingAnchor.constraint(equalTo: trailingAnchor),
- container.topAnchor.constraint(equalTo: topAnchor),
- container.bottomAnchor.constraint(equalTo: bottomAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
- titleLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor),
- amountLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- amountLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor)
- ])
- }
- required init?(coder: NSCoder) { fatalError() }
- func configure(title: String, amount: Double) {
- titleLabel.text = title
- amountLabel.text = String(format: "¥ %.2f", abs(amount))
- }
- }
- fileprivate final class DayHeaderView: UIView {
- private let dateLabel = UILabel()
- private let totalLabel = UILabel()
- private let under = UIImageView()
- override init(frame: CGRect) {
- super.init(frame: frame)
- backgroundColor = .clear
- dateLabel.font = .systemFont(ofSize: 13, weight: .semibold)
- dateLabel.textColor = UIColor(hex: "#2B2B2B")
- totalLabel.font = .systemFont(ofSize: 13)
- totalLabel.textColor = UIColor(hex: "#2B2B2B")
- // under.backgroundColor = UIColor(hex: "#FFE059")
- // under.layer.cornerRadius = 2
- under.image = UIImage(named: "Bookkeep395")
- addSubview(dateLabel); addSubview(totalLabel); addSubview(under)
- dateLabel.translatesAutoresizingMaskIntoConstraints = false
- totalLabel.translatesAutoresizingMaskIntoConstraints = false
- under.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- dateLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
- dateLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- totalLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
- totalLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- under.heightAnchor.constraint(equalToConstant: 12),
- // under.widthAnchor.constraint(equalToConstant: 60),
- under.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: -5),
- under.leadingAnchor.constraint(equalTo: dateLabel.leadingAnchor),
- under.trailingAnchor.constraint(equalTo: dateLabel.trailingAnchor)
- ])
- }
- required init?(coder: NSCoder) { fatalError() }
- func configure(date: Date, total: Double) {
- let f1 = DateFormatter(); f1.dateFormat = "yyyy.MM.dd EEEE"; f1.locale = Locale(identifier: "zh_CN")
- dateLabel.text = f1.string(from: date)
- totalLabel.text = String(format: "支出: ¥ %.2f", abs(total))
- }
- }
- fileprivate final class PetBadgeView: UIView {
- private let iconView = UIImageView()
- private let nameLabel = UILabel()
- private let container = UIView()
- override init(frame: CGRect) {
- super.init(frame: frame)
- // 带描边的圆角白底
- container.backgroundColor = .white
- container.layer.cornerRadius = 7
- container.layer.masksToBounds = true
- container.layer.borderWidth = 1
- container.layer.borderColor = UIColor(hex: "#5B4227").cgColor
- addSubview(container)
- iconView.contentMode = .scaleAspectFit
- iconView.layer.cornerRadius = 5
- iconView.layer.masksToBounds = true
- nameLabel.font = .systemFont(ofSize: 12)
- nameLabel.textColor = UIColor(hex: "#5B4227")
- let stack = UIStackView(arrangedSubviews: [iconView, nameLabel])
- stack.axis = .horizontal
- stack.alignment = .center
- stack.spacing = 6
- container.addSubview(stack)
- container.translatesAutoresizingMaskIntoConstraints = false
- stack.translatesAutoresizingMaskIntoConstraints = false
- iconView.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- container.topAnchor.constraint(equalTo: topAnchor),
- container.leadingAnchor.constraint(equalTo: leadingAnchor),
- container.trailingAnchor.constraint(equalTo: trailingAnchor),
- container.bottomAnchor.constraint(equalTo: bottomAnchor),
- stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
- stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
- stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
- stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
- iconView.widthAnchor.constraint(equalToConstant: 10),
- iconView.heightAnchor.constraint(equalToConstant: 10)
- ])
- }
- required init?(coder: NSCoder) { fatalError() }
- func set(name: String, icon: UIImage?) {
- nameLabel.text = name
- iconView.image = icon ?? UIImage(named: "peihead")
- }
- }
- fileprivate final class ExpenseCell: UITableViewCell {
- // 手绘边框容器(使用切图)
- private let container = UIImageView()
- // 上半区元素
- private let icon = UIImageView(image: UIImage(named: "peihead"))
- private let titleLabel = UILabel()
- private let amountLabel = UILabel()
- // 中部整条虚线
- private let dashLayer = CAShapeLayer()
- // 右下角宠物名徽章
- private let petBadge = PetBadgeView()
- override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
- super.init(style: style, reuseIdentifier: reuseIdentifier)
- backgroundColor = .clear
- selectionStyle = .none
- // 手绘边框底图
- container.image = UIImage(named: "Home378")
- container.contentMode = .scaleToFill
- contentView.addSubview(container)
- container.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
- container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
- container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
- container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
- container.heightAnchor.constraint(greaterThanOrEqualToConstant: 121)
- ])
- // 上半区
- icon.contentMode = .scaleAspectFill
- icon.layer.cornerRadius = 8
- icon.layer.masksToBounds = true
- titleLabel.font = .systemFont(ofSize: 16, weight: .regular)
- titleLabel.textColor = UIColor(hex: "#2B2B2B")
- amountLabel.font = .systemFont(ofSize: 16, weight: .heavy)
- amountLabel.textColor = UIColor(hex: "#2B2B2B")
- amountLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
- amountLabel.textAlignment = .right
- [icon, titleLabel, amountLabel, petBadge].forEach { v in
- v.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(v)
- }
- NSLayoutConstraint.activate([
- icon.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
- icon.topAnchor.constraint(equalTo: container.topAnchor, constant: 16),
- icon.widthAnchor.constraint(equalToConstant: 32),
- icon.heightAnchor.constraint(equalToConstant: 32),
- titleLabel.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 14),
- titleLabel.topAnchor.constraint(equalTo: icon.topAnchor,constant: 4),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: amountLabel.leadingAnchor, constant: -8),
- amountLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- amountLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
- petBadge.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- petBadge.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
- petBadge.heightAnchor.constraint(greaterThanOrEqualToConstant: 14)
- ])
- // 中部整条虚线(添加在容器上)
- dashLayer.strokeColor = UIColor(hex: "#CFCFCF").cgColor
- dashLayer.fillColor = UIColor.clear.cgColor
- dashLayer.lineDashPattern = [6, 6]
- dashLayer.lineWidth = 1
- container.layer.addSublayer(dashLayer)
- }
- required init?(coder: NSCoder) { fatalError() }
- override func layoutSubviews() {
- super.layoutSubviews()
- // 让中部虚线横穿整个卡片(留左右内边距)
- let left: CGFloat = 16
- let right: CGFloat = bounds.width - 16
- // 取上半区最大底部再往下 16 作为分隔线位置
- let topMax = max(icon.frame.maxY, titleLabel.frame.maxY, amountLabel.frame.maxY)
- let y = topMax + 16
- let path = UIBezierPath()
- path.move(to: CGPoint(x: left, y: y))
- path.addLine(to: CGPoint(x: right, y: y))
- dashLayer.path = path.cgPath
- }
- func configure(title: String, amount: Double, petName: String) {
- titleLabel.text = title
- let prefix = amount < 0 ? "- " : "+ "
- amountLabel.text = String(format: "%@¥ %.2f", prefix, abs(amount))
- petBadge.set(name: petName, icon: UIImage(named: "peihead"))
- }
- }
- // MARK: - Utils
- fileprivate func dayOnly(_ d: Date) -> Date {
- let cal = Calendar.current
- return cal.date(from: cal.dateComponents([.year, .month, .day], from: d))!
- }
- private extension DateFormatter {
- static func with(_ f: String) -> DateFormatter {
- let d = DateFormatter()
- d.dateFormat = f
- return d
- }
- }
- // MARK: - HEX 颜色
- extension UIColor {
- convenience init(hex: String, alpha: CGFloat = 1.0) {
- var hexSanitized = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
- var int: UInt64 = 0
- Scanner(string: hexSanitized).scanHexInt64(&int)
- let a, r, g, b: UInt64
- switch hexSanitized.count {
- case 3: (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
- case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
- case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
- default: (a, r, g, b) = (255, 0, 0, 0)
- }
- self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
- }
- }
|