BookkeepingViewController.swift 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093
  1. //
  2. // BookkeepingViewController.swift
  3. // VenusKitto
  4. //
  5. // Created by Neoa on 2025/8/26.
  6. //
  7. import Foundation
  8. import UIKit
  9. // MARK: - 简单模型(Cell 使用)
  10. fileprivate struct BookItem {
  11. let date: Date
  12. let title: String
  13. let amount: Double // 负数为支出,正数为收入
  14. let count: Int // 次数/数量
  15. let petName: String // ✅ 新增:用于 cell 上显示宠物名
  16. }
  17. fileprivate struct DayGroup {
  18. let date: Date
  19. let total: Double
  20. let items: [BookItem]
  21. }
  22. // MARK: - 注入的宠物(带 id)
  23. fileprivate struct BKPet {
  24. let id: String
  25. let name: String
  26. }
  27. // MARK: - 网络返回模型(记账账单)
  28. fileprivate struct BillRecordResponse: Codable {
  29. let code: String
  30. let msg: String?
  31. let data: BillData
  32. }
  33. fileprivate struct BillData: Codable {
  34. let petName: String
  35. let week: String?
  36. let dayExpend: String?
  37. let groupedData: [BillGroupDTO]
  38. let time: String?
  39. let totalCost: String
  40. }
  41. fileprivate struct BillGroupDTO: Codable {
  42. let date: String // yyyy.MM.dd
  43. let week: String?
  44. let dayTotal: String // 当天合计
  45. let items: [BillItemDTO]
  46. }
  47. fileprivate struct BillItemDTO: Codable {
  48. let id: String
  49. let petId: String
  50. let avatar: String?
  51. let petName: String
  52. let recordTypeId: String?
  53. let recordUrl: String?
  54. let title: String
  55. let recordDate: String?
  56. let content: String?
  57. let amount: String? // 金额(正数)
  58. let note: String?
  59. let module: String?
  60. }
  61. // MARK: - BookkeepingViewController (记账页面)
  62. final class BookkeepingViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate, UIPopoverPresentationControllerDelegate {
  63. // 顶部标题
  64. private let titleLabel: UILabel = {
  65. let l = UILabel()
  66. l.text = "记账"
  67. l.font = .systemFont(ofSize: 28, weight: .semibold)
  68. l.textColor = UIColor(hex: "#2B2B2B")
  69. return l
  70. }()
  71. private lazy var settingsButton: UIButton = {
  72. let b = UIButton(type: .system)
  73. if let img = UIImage(systemName: "gearshape") {
  74. b.setImage(img, for: .normal)
  75. } else {
  76. b.setTitle("⚙️", for: .normal)
  77. }
  78. b.tintColor = UIColor(hex: "#2B2B2B")
  79. b.addTarget(self, action: #selector(tapSettings), for: .touchUpInside)
  80. return b
  81. }()
  82. private lazy var addBarItem: UIBarButtonItem = {
  83. UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(tapAdd))
  84. }()
  85. // Header 子模块
  86. private let petsRow = UIStackView()
  87. private let periodRow = UIStackView()
  88. private let monthRow = UIStackView()
  89. private let monthLeft = UIButton(type: .system)
  90. private let monthRight = UIButton(type: .system)
  91. private let monthLabel = UILabel()
  92. private let summaryCard = SummaryCardView()
  93. // 分段:总 / 年 / 月
  94. private let totalChip = SegmentChipButton(title: "总",
  95. maskedCorners: [.layerMinXMinYCorner, .layerMinXMaxYCorner],
  96. cornerRadius: 12)
  97. private let yearChip = SegmentChipButton(title: "年",
  98. maskedCorners: [],
  99. cornerRadius: 0)
  100. private let monthChip = SegmentChipButton(title: "月",
  101. maskedCorners: [.layerMaxXMinYCorner, .layerMaxXMaxYCorner],
  102. cornerRadius: 12)
  103. // 列表
  104. private let tableView = UITableView(frame: .zero, style: .grouped)
  105. // tableHeader 容器,用于在布局变化时重算高度
  106. private var headerContainer: UIView?
  107. // 数据(由外部注入,带 id)
  108. private var pets: [BKPet] = []
  109. private var selectedPetIndex: Int = 0
  110. // 期间:0=总 1=年 2=月(目前服务端以月为主,我们都传当前年+月)
  111. private var periodIndex: Int = 2
  112. private var currentMonth: Date = Date()
  113. private var dayGroups: [DayGroup] = []
  114. // 年份选择支持
  115. private var yearOptions: [Int] = []
  116. private var tempSelectedYear: Int = Calendar.current.component(.year, from: Date())
  117. private var monthOptions: [Int] = Array(1...12)
  118. private var tempSelectedMonth: Int = Calendar.current.component(.month, from: Date())
  119. /// 清空并填充宠物 Chips(避免重复添加)
  120. private func populatePetsRow() {
  121. // 先移除旧的
  122. petsRow.arrangedSubviews.forEach { v in
  123. petsRow.removeArrangedSubview(v)
  124. v.removeFromSuperview()
  125. }
  126. // 再添加新的
  127. for (i, p) in pets.enumerated() {
  128. let btn = ChipButton(title: p.name)
  129. // 设置强 hugging/压缩优先级,使按钮不被拉伸
  130. btn.setContentHuggingPriority(.required, for: .horizontal)
  131. btn.setContentCompressionResistancePriority(.required, for: .horizontal)
  132. btn.tag = i
  133. btn.addTarget(self, action: #selector(tapPet(_:)), for: .touchUpInside)
  134. petsRow.addArrangedSubview(btn)
  135. }
  136. // 在末尾加一个弹性占位视图,使宠物名称始终靠左
  137. let spacer = UIView()
  138. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  139. spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  140. petsRow.addArrangedSubview(spacer)
  141. updatePetChips()
  142. }
  143. override func viewDidLoad() {
  144. super.viewDidLoad()
  145. view.backgroundColor = UIColor(hex: "#FFFEFC")
  146. navigationItem.rightBarButtonItem = addBarItem
  147. buildTopBar()
  148. buildHeader()
  149. buildTable()
  150. // 若外部尚未注入宠物,提供一个占位按钮
  151. if pets.isEmpty { pets = [BKPet(id: "", name: "当前宠物")] }
  152. refetch() // 优先拉接口;无 petId 时回落到本地 demo
  153. // ✅ 监听“记账创建成功”的通知
  154. NotificationCenter.default.addObserver(
  155. self,
  156. selector: #selector(handleBookkeepingRecordCreated(_:)),
  157. name: .bookkeepingRecordDidCreate,
  158. object: nil
  159. )
  160. }
  161. @objc private func handleBookkeepingRecordCreated(_ note: Notification) {
  162. // 如果通知里带了 petId,仅当前选中的宠物匹配时刷新;否则直接刷新
  163. if let pid = note.userInfo?["petId"] as? String, let cur = currentPetId() {
  164. if pid == cur {
  165. refetch()
  166. }
  167. } else {
  168. refetch()
  169. }
  170. }
  171. deinit {
  172. NotificationCenter.default.removeObserver(self)
  173. }
  174. @objc private func tapSettings() {
  175. print("Settings tapped")
  176. let vc = SettingsViewController()
  177. navigationController?.pushViewController(vc, animated: true)
  178. }
  179. // 外部注入宠物列表(带 id)
  180. func setPets(_ list: [(id: String, name: String)], selectedIndex: Int = 0) {
  181. self.pets = list.map { BKPet(id: $0.id, name: $0.name) }
  182. self.selectedPetIndex = max(0, min(selectedIndex, pets.count - 1))
  183. // 重建宠物 chips(统一入口,避免重复)
  184. populatePetsRow()
  185. refetch()
  186. }
  187. private func currentPetId() -> String? {
  188. guard !pets.isEmpty else { return nil }
  189. let idx = max(0, min(selectedPetIndex, pets.count - 1))
  190. let pid = pets[idx].id.trimmingCharacters(in: .whitespacesAndNewlines)
  191. return pid.isEmpty ? nil : pid
  192. }
  193. private func buildTopBar() {
  194. view.addSubview(titleLabel)
  195. view.addSubview(settingsButton)
  196. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  197. settingsButton.translatesAutoresizingMaskIntoConstraints = false
  198. NSLayoutConstraint.activate([
  199. titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
  200. titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
  201. settingsButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
  202. settingsButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
  203. settingsButton.heightAnchor.constraint(equalToConstant: 28),
  204. settingsButton.widthAnchor.constraint(equalToConstant: 28),
  205. ])
  206. }
  207. private func buildHeader() {
  208. // 宠物行
  209. petsRow.axis = .horizontal
  210. petsRow.spacing = 12
  211. petsRow.alignment = .center
  212. petsRow.distribution = .fill // 让内容按自身尺寸布局,配合尾部 spacer 左对齐
  213. // 如果还没填充,才初始化一次,避免在 setPets 之后重复追加
  214. if petsRow.arrangedSubviews.isEmpty {
  215. if pets.isEmpty { pets = [BKPet(id: "", name: "当前宠物")] }
  216. populatePetsRow()
  217. }
  218. // 期间行(总 / 年 / 月)—— 左对齐、0 间距;“年”仅左侧圆角,“月”仅右侧圆角
  219. periodRow.axis = .horizontal
  220. periodRow.spacing = 0
  221. periodRow.alignment = .fill
  222. periodRow.distribution = .fill
  223. // 处理点击
  224. totalChip.tag = 100
  225. totalChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside)
  226. yearChip.tag = 101
  227. yearChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside)
  228. monthChip.tag = 102
  229. monthChip.addTarget(self, action: #selector(tapPeriod(_:)), for: .touchUpInside)
  230. // "总 年 月" 作为一个零间距的分段组
  231. let segmentGroup = UIStackView(arrangedSubviews: [totalChip, yearChip, monthChip])
  232. segmentGroup.axis = .horizontal
  233. segmentGroup.spacing = 0
  234. segmentGroup.alignment = .fill
  235. segmentGroup.distribution = .fill
  236. segmentGroup.setContentHuggingPriority(.required, for: .horizontal)
  237. periodRow.addArrangedSubview(segmentGroup)
  238. // 不再在 periodRow 内添加 spacer;让外层容器控制左右分布
  239. updatePeriodChips()
  240. // 月份选择
  241. monthRow.axis = .horizontal
  242. monthRow.alignment = .center
  243. monthRow.spacing = 8
  244. monthLeft.setImage(UIImage(systemName: "chevron.left"), for: .normal)
  245. monthRight.setImage(UIImage(systemName: "chevron.right"), for: .normal)
  246. [monthLeft, monthRight].forEach { $0.tintColor = UIColor(hex: "#2B2B2B") }
  247. monthLeft.addTarget(self, action: #selector(prevMonth), for: .touchUpInside)
  248. monthRight.addTarget(self, action: #selector(nextMonth), for: .touchUpInside)
  249. monthLabel.font = .systemFont(ofSize: 16, weight: .semibold)
  250. monthLabel.textColor = UIColor(hex: "#2B2B2B")
  251. monthLabel.isUserInteractionEnabled = true
  252. let _tapMonth = UITapGestureRecognizer(target: self, action: #selector(tapMonthLabel))
  253. monthLabel.addGestureRecognizer(_tapMonth)
  254. // 推荐:hugging/压缩优先级
  255. monthLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
  256. monthLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  257. monthRow.addArrangedSubview(monthLeft)
  258. monthRow.addArrangedSubview(monthLabel)
  259. monthRow.addArrangedSubview(monthRight)
  260. updateMonthLabel()
  261. // periodRow + monthRow 合并到同一行:左“总/年/月”,右“月份选择”
  262. let flexibleSpacer = UIView()
  263. flexibleSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  264. flexibleSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  265. // 将 periodRow 只承载分段(无内部 spacer),monthRow 放右侧并保持自身大小
  266. monthRow.setContentHuggingPriority(.required, for: .horizontal)
  267. monthRow.setContentCompressionResistancePriority(.required, for: .horizontal)
  268. let periodMonthRow = UIStackView(arrangedSubviews: [periodRow, flexibleSpacer, monthRow])
  269. periodMonthRow.axis = .horizontal
  270. periodMonthRow.alignment = .center
  271. periodMonthRow.spacing = 8
  272. periodMonthRow.distribution = .fill
  273. // header 内容栈
  274. let headerStack = UIStackView(arrangedSubviews: [petsRow, periodMonthRow, summaryCard])
  275. headerStack.axis = .vertical
  276. headerStack.spacing = 14
  277. headerStack.alignment = .fill
  278. headerStack.isLayoutMarginsRelativeArrangement = true
  279. headerStack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
  280. // 新实现:不挂到 tableHeaderView,等 viewDidLayoutSubviews
  281. let container = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
  282. container.autoresizingMask = [.flexibleWidth]
  283. container.addSubview(headerStack)
  284. headerStack.translatesAutoresizingMaskIntoConstraints = false
  285. NSLayoutConstraint.activate([
  286. headerStack.topAnchor.constraint(equalTo: container.topAnchor),
  287. headerStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  288. headerStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  289. headerStack.bottomAnchor.constraint(equalTo: container.bottomAnchor)
  290. ])
  291. // 仅缓存,不在此时挂到 tableHeaderView,等待尺寸可用后在 viewDidLayoutSubviews 里再挂
  292. self.headerContainer = container
  293. }
  294. private func buildTable() {
  295. tableView.backgroundColor = .clear
  296. tableView.separatorStyle = .none
  297. tableView.dataSource = self
  298. tableView.delegate = self
  299. tableView.register(ExpenseCell.self, forCellReuseIdentifier: "ExpenseCell")
  300. view.addSubview(tableView)
  301. tableView.translatesAutoresizingMaskIntoConstraints = false
  302. NSLayoutConstraint.activate([
  303. tableView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
  304. tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  305. tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  306. tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  307. ])
  308. tableView.estimatedRowHeight = 121
  309. tableView.rowHeight = UITableView.automaticDimension
  310. }
  311. // 让 tableHeader 自适应高度与宽度(用于旋转/尺寸变化)
  312. override func viewDidLayoutSubviews() {
  313. super.viewDidLayoutSubviews()
  314. guard let container = headerContainer,
  315. let headerStack = container.subviews.first as? UIStackView else { return }
  316. // 只有当 tableView 已经有了确定的宽度时才挂载/更新 header
  317. let width = tableView.bounds.width
  318. guard width > 0 else { return }
  319. if container.frame.width != width {
  320. container.frame.size.width = width
  321. }
  322. let target = CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)
  323. let height = headerStack.systemLayoutSizeFitting(
  324. target,
  325. withHorizontalFittingPriority: .required,
  326. verticalFittingPriority: .fittingSizeLevel
  327. ).height
  328. if tableView.tableHeaderView == nil {
  329. // 首次挂载
  330. container.frame.size.height = height
  331. tableView.tableHeaderView = container
  332. } else {
  333. // 尺寸变化时更新
  334. if container.frame.height != height {
  335. container.frame.size.height = height
  336. tableView.tableHeaderView = container // 重新赋值以使变更生效
  337. }
  338. }
  339. // 避免底部与外层 TabBar 重叠,留出滚动区域
  340. let extraBottom: CGFloat = 24
  341. if tableView.contentInset.bottom != extraBottom {
  342. tableView.contentInset.bottom = extraBottom
  343. tableView.scrollIndicatorInsets.bottom = extraBottom
  344. }
  345. }
  346. // MARK: - 触发刷新 & 网络请求
  347. private func refetch() {
  348. guard let pid = currentPetId() else {
  349. // 无 petId(占位宠物),展示本地 demo
  350. reloadDemoData()
  351. return
  352. }
  353. fetchBillRecords(petId: pid, month: currentMonth)
  354. }
  355. private func fetchBillRecords(petId: String, month: Date) {
  356. let yFmt = DateFormatter(); yFmt.dateFormat = "yyyy"
  357. let ymFmt = DateFormatter(); ymFmt.dateFormat = "yyyy.MM"
  358. let yearStr: String
  359. let yearMonthStr: String
  360. switch periodIndex {
  361. case 0: // 总:都传空
  362. yearStr = ""
  363. yearMonthStr = ""
  364. case 1: // 年:只传 year
  365. yearStr = yFmt.string(from: month)
  366. yearMonthStr = ""
  367. default: // 月:同时传 year 和 yearMonth
  368. yearStr = yFmt.string(from: month)
  369. yearMonthStr = ymFmt.string(from: month)
  370. }
  371. var comps = URLComponents(string: "\(baseURL)/petRecordInfo/queryBillRecordInfoList")
  372. comps?.queryItems = [
  373. URLQueryItem(name: "petId", value: petId),
  374. URLQueryItem(name: "year", value: yearStr),
  375. URLQueryItem(name: "yearMonth", value: yearMonthStr)
  376. ]
  377. guard let url = comps?.url else { return }
  378. var req = URLRequest(url: url)
  379. req.httpMethod = "GET"
  380. req.setValue("application/json", forHTTPHeaderField: "Content-Type")
  381. if let token = UserDefaults.standard.string(forKey: "userToken") {
  382. req.setValue(token, forHTTPHeaderField: "Authorization")
  383. }
  384. URLSession.shared.dataTask(with: req) { [weak self] data, resp, err in
  385. if let http = resp as? HTTPURLResponse { print("📊 bill status: \(http.statusCode)") }
  386. if let err = err { print("❌ bill error: \(err)"); return }
  387. guard let data = data else { print("❌ bill empty data"); return }
  388. do {
  389. let decoder = JSONDecoder()
  390. let rsp = try decoder.decode(BillRecordResponse.self, from: data)
  391. if rsp.code == "200" {
  392. DispatchQueue.main.async { self?.applyBillData(rsp.data) }
  393. } else {
  394. print("📊 bill server msg: \(rsp.msg ?? "-") code=\(rsp.code)")
  395. }
  396. } catch {
  397. print("❌ bill decode error: \(error)")
  398. }
  399. }.resume()
  400. }
  401. private func applyBillData(_ data: BillData) {
  402. // 顶部汇总卡
  403. let yF = DateFormatter(); yF.dateFormat = "yyyy"
  404. let ymF = DateFormatter(); ymF.dateFormat = "yyyy.MM"
  405. let total = Double(data.totalCost) ?? 0
  406. let suffix: String
  407. switch periodIndex {
  408. case 0: suffix = "合计花费"
  409. case 1: suffix = "\(yF.string(from: currentMonth))花费"
  410. default: suffix = "\(ymF.string(from: currentMonth))花费"
  411. }
  412. summaryCard.configure(title: "\(data.petName) \(suffix)", amount: -total)
  413. // 分组 -> dayGroups
  414. let df = DateFormatter(); df.dateFormat = "yyyy.MM.dd"; df.locale = Locale(identifier: "zh_CN")
  415. var groups: [DayGroup] = []
  416. for g in data.groupedData {
  417. let date = df.date(from: g.date) ?? Date()
  418. var items: [BookItem] = []
  419. for it in g.items {
  420. let amt = Double(it.amount ?? "0") ?? 0
  421. items.append(BookItem(date: date, title: it.title, amount: -amt, count: 1, petName: it.petName))
  422. }
  423. let dayTotal = -(Double(g.dayTotal) ?? items.reduce(0) { $0 + $1.amount })
  424. groups.append(DayGroup(date: date, total: dayTotal, items: items))
  425. }
  426. groups.sort { $0.date > $1.date }
  427. self.dayGroups = groups
  428. self.tableView.reloadData()
  429. }
  430. // MARK: - Demo 数据(本地兜底)
  431. private func reloadDemoData() {
  432. let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
  433. let d1 = fmt.date(from: "2025-08-25")!
  434. let demoPetName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name
  435. let items = [
  436. BookItem(date: d1, title: "指甲剪", amount: -10, count: 1, petName: demoPetName),
  437. BookItem(date: d1, title: "厕所", amount: -50, count: 1, petName: demoPetName)
  438. ]
  439. // 分组
  440. let groups = Dictionary(grouping: items, by: { dayOnly($0.date) })
  441. dayGroups = groups.keys.sorted(by: >).map { day in
  442. let its = groups[day]!.sorted { $0.title < $1.title }
  443. let total = its.reduce(0) { $0 + $1.amount }
  444. return DayGroup(date: day, total: total, items: its)
  445. }
  446. // 顶部汇总卡
  447. let monthTotal = items.reduce(0) { $0 + $1.amount }
  448. let petName = pets.isEmpty ? "宠物" : pets[selectedPetIndex].name
  449. summaryCard.configure(title: "\(petName) \(DateFormatter.with("yyyy.MM").string(from: currentMonth))花费", amount: monthTotal)
  450. tableView.reloadData()
  451. }
  452. @objc private func tapAdd() {
  453. // TODO: push 新增账目
  454. print("➕ Add bookkeeping")
  455. }
  456. @objc private func tapPet(_ sender: UIButton) {
  457. selectedPetIndex = sender.tag
  458. updatePetChips()
  459. refetch()
  460. }
  461. @objc private func tapPeriod(_ sender: UIButton) {
  462. periodIndex = sender.tag - 100
  463. updatePeriodChips()
  464. updateMonthLabel() // ← 新增:切换分段后立刻刷新“月份选择”区
  465. refetch()
  466. }
  467. @objc private func prevMonth() {
  468. guard periodIndex != 0 else { return } // “总”下不允许切换
  469. if periodIndex == 1 { // 年视图:按年切
  470. currentMonth = Calendar.current.date(byAdding: .year, value: -1, to: currentMonth)!
  471. } else { // 月视图:按月切
  472. currentMonth = Calendar.current.date(byAdding: .month, value: -1, to: currentMonth)!
  473. }
  474. updateMonthLabel()
  475. refetch()
  476. }
  477. @objc private func nextMonth() {
  478. guard periodIndex != 0 else { return }
  479. if periodIndex == 1 {
  480. currentMonth = Calendar.current.date(byAdding: .year, value: 1, to: currentMonth)!
  481. } else {
  482. currentMonth = Calendar.current.date(byAdding: .month, value: 1, to: currentMonth)!
  483. }
  484. updateMonthLabel()
  485. refetch()
  486. }
  487. @objc private func tapMonthLabel() {
  488. switch periodIndex {
  489. case 1:
  490. showYearPickerSheet()
  491. case 2:
  492. showYearMonthPickerSheet()
  493. default:
  494. break
  495. }
  496. }
  497. private func showYearMonthPickerSheet() {
  498. // 年月两列选择(不显示日)
  499. let cur = currentMonth
  500. let curY = Calendar.current.component(.year, from: cur)
  501. let curM = Calendar.current.component(.month, from: cur)
  502. // 准备年份与默认选中
  503. yearOptions = Array((curY - 10)...(curY + 10))
  504. tempSelectedYear = curY
  505. tempSelectedMonth = curM
  506. let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet)
  507. if let p = ac.popoverPresentationController {
  508. p.sourceView = monthLabel
  509. p.sourceRect = monthLabel.bounds
  510. p.permittedArrowDirections = .up
  511. p.delegate = self
  512. }
  513. let pv = UIPickerView()
  514. pv.tag = 99 // 99 表示“年月”两列
  515. pv.dataSource = self
  516. pv.delegate = self
  517. ac.view.addSubview(pv)
  518. pv.translatesAutoresizingMaskIntoConstraints = false
  519. NSLayoutConstraint.activate([
  520. pv.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor),
  521. pv.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8),
  522. pv.widthAnchor.constraint(equalTo: ac.view.widthAnchor)
  523. ])
  524. if let yIdx = yearOptions.firstIndex(of: curY) { pv.selectRow(yIdx, inComponent: 0, animated: false) }
  525. pv.selectRow(curM - 1, inComponent: 1, animated: false)
  526. ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { [weak self] _ in
  527. guard let self = self else { return }
  528. let newDate = Calendar.current.date(from: DateComponents(year: self.tempSelectedYear, month: self.tempSelectedMonth, day: 1)) ?? self.currentMonth
  529. self.currentMonth = newDate
  530. self.updateMonthLabel()
  531. self.refetch()
  532. }))
  533. ac.addAction(UIAlertAction(title: "取消", style: .cancel))
  534. present(ac, animated: true)
  535. }
  536. private func showYearPickerSheet() {
  537. // 备选年份(当前年±10年)
  538. let curY = Calendar.current.component(.year, from: currentMonth)
  539. let start = curY - 10
  540. let end = curY + 10
  541. yearOptions = Array(start...end)
  542. tempSelectedYear = curY
  543. let ac = UIAlertController(title: "\n\n\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet)
  544. if let p = ac.popoverPresentationController {
  545. p.sourceView = monthLabel
  546. p.sourceRect = monthLabel.bounds
  547. p.permittedArrowDirections = .up
  548. p.delegate = self
  549. }
  550. let pv = UIPickerView()
  551. pv.tag = 98 // 98 表示“仅年份”一列
  552. pv.dataSource = self
  553. pv.delegate = self
  554. if let idx = yearOptions.firstIndex(of: curY) { pv.selectRow(idx, inComponent: 0, animated: false) }
  555. ac.view.addSubview(pv)
  556. pv.translatesAutoresizingMaskIntoConstraints = false
  557. NSLayoutConstraint.activate([
  558. pv.centerXAnchor.constraint(equalTo: ac.view.centerXAnchor),
  559. pv.topAnchor.constraint(equalTo: ac.view.topAnchor, constant: 8),
  560. pv.widthAnchor.constraint(equalTo: ac.view.widthAnchor)
  561. ])
  562. ac.addAction(UIAlertAction(title: "确定", style: .default, handler: { [weak self] _ in
  563. guard let self = self else { return }
  564. let comps = Calendar.current.dateComponents([.month], from: self.currentMonth)
  565. let new = Calendar.current.date(from: DateComponents(year: self.tempSelectedYear, month: comps.month ?? 1, day: 1)) ?? self.currentMonth
  566. self.currentMonth = new
  567. self.updateMonthLabel()
  568. self.refetch()
  569. }))
  570. ac.addAction(UIAlertAction(title: "取消", style: .cancel))
  571. present(ac, animated: true)
  572. }
  573. private func updateMonthLabel() {
  574. switch periodIndex {
  575. case 0: // 总
  576. monthLabel.text = "总"
  577. monthLeft.isHidden = true
  578. monthRight.isHidden = true
  579. case 1: // 年
  580. let y = DateFormatter(); y.dateFormat = "yyyy"
  581. monthLabel.text = y.string(from: currentMonth)
  582. monthLeft.isHidden = false
  583. monthRight.isHidden = false
  584. default: // 月
  585. let f = DateFormatter(); f.dateFormat = "yyyy.MM"
  586. monthLabel.text = f.string(from: currentMonth)
  587. monthLeft.isHidden = false
  588. monthRight.isHidden = false
  589. }
  590. }
  591. private func updatePetChips() {
  592. for (i, v) in petsRow.arrangedSubviews.enumerated() {
  593. (v as? ChipButton)?.isOn = (i == selectedPetIndex)
  594. }
  595. }
  596. private func updatePeriodChips() {
  597. totalChip.isOn = (periodIndex == 0)
  598. yearChip.isOn = (periodIndex == 1)
  599. monthChip.isOn = (periodIndex == 2)
  600. }
  601. // MARK: - Table
  602. func numberOfSections(in tableView: UITableView) -> Int { dayGroups.count }
  603. func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { dayGroups[section].items.count }
  604. func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
  605. let v = DayHeaderView()
  606. let g = dayGroups[section]
  607. v.configure(date: g.date, total: g.total)
  608. return v
  609. }
  610. func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { 50 }
  611. func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  612. let cell = tableView.dequeueReusableCell(withIdentifier: "ExpenseCell", for: indexPath) as! ExpenseCell
  613. let item = dayGroups[indexPath.section].items[indexPath.row]
  614. cell.configure(title: item.title, amount: item.amount, petName: item.petName)
  615. return cell
  616. }
  617. func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  618. tableView.deselectRow(at: indexPath, animated: true)
  619. }
  620. func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
  621. return 121
  622. }
  623. func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
  624. return 121
  625. }
  626. // MARK: - UIPickerView DataSource & Delegate (Year/Year-Month)
  627. func numberOfComponents(in pickerView: UIPickerView) -> Int {
  628. return pickerView.tag == 99 ? 2 : 1
  629. }
  630. func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
  631. if pickerView.tag == 99 {
  632. return component == 0 ? yearOptions.count : monthOptions.count
  633. } else {
  634. return yearOptions.count
  635. }
  636. }
  637. func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
  638. if pickerView.tag == 99 {
  639. if component == 0 {
  640. return "\(yearOptions[row])年"
  641. } else {
  642. return String(format: "%02d月", monthOptions[row])
  643. }
  644. } else {
  645. return "\(yearOptions[row])年"
  646. }
  647. }
  648. func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
  649. if pickerView.tag == 99 {
  650. if component == 0, row >= 0, row < yearOptions.count {
  651. tempSelectedYear = yearOptions[row]
  652. } else if component == 1, row >= 0, row < monthOptions.count {
  653. tempSelectedMonth = monthOptions[row]
  654. }
  655. } else {
  656. if row >= 0 && row < yearOptions.count { tempSelectedYear = yearOptions[row] }
  657. }
  658. }
  659. // MARK: - UIPopoverPresentationControllerDelegate fallback for iPad popover
  660. func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) {
  661. if popoverPresentationController.sourceView == nil && popoverPresentationController.barButtonItem == nil {
  662. popoverPresentationController.sourceView = monthLabel.superview ?? view
  663. let base = popoverPresentationController.sourceView!
  664. popoverPresentationController.sourceRect = base.bounds
  665. popoverPresentationController.permittedArrowDirections = .up
  666. }
  667. }
  668. }
  669. // MARK: - 小组件
  670. fileprivate final class ChipButton: UIButton {
  671. var isOn: Bool = false { didSet { refresh() } }
  672. private let inset = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10)
  673. init(title: String) {
  674. super.init(frame: .zero)
  675. setTitle(title, for: .normal)
  676. titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
  677. setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  678. layer.cornerRadius = 12
  679. contentEdgeInsets = inset
  680. backgroundColor = .clear
  681. refresh()
  682. }
  683. required init?(coder: NSCoder) { fatalError() }
  684. private func refresh() {
  685. layer.cornerRadius = 12
  686. if isOn {
  687. backgroundColor = UIColor(hex: "#FFE059")
  688. setTitleColor(.black, for: .normal)
  689. } else {
  690. backgroundColor = .clear
  691. setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  692. }
  693. }
  694. }
  695. fileprivate final class SegmentChipButton: UIButton {
  696. var isOn: Bool = false { didSet { refresh() } }
  697. private let inset = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
  698. private let masked: CACornerMask
  699. private let cornerRadius: CGFloat
  700. init(title: String, maskedCorners: CACornerMask, cornerRadius: CGFloat = 12) {
  701. self.masked = maskedCorners
  702. self.cornerRadius = cornerRadius
  703. super.init(frame: .zero)
  704. setTitle(title, for: .normal)
  705. titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
  706. setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  707. contentEdgeInsets = inset
  708. // 仅指定的角为圆角,其余为直角
  709. layer.cornerRadius = cornerRadius
  710. layer.maskedCorners = masked
  711. layer.masksToBounds = true
  712. backgroundColor = .clear
  713. refresh()
  714. }
  715. required init?(coder: NSCoder) { fatalError() }
  716. private func refresh() {
  717. layer.cornerRadius = cornerRadius
  718. if isOn {
  719. backgroundColor = UIColor(hex: "#FFE059")
  720. setTitleColor(.black, for: .normal)
  721. } else {
  722. backgroundColor = UIColor(hex: "#FEF3C1")
  723. setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  724. }
  725. }
  726. }
  727. fileprivate final class SummaryCardView: UIView {
  728. private let container = UIImageView()
  729. private let titleLabel = UILabel()
  730. private let amountLabel = UILabel()
  731. override init(frame: CGRect) {
  732. super.init(frame: frame)
  733. translatesAutoresizingMaskIntoConstraints = false
  734. container.backgroundColor = .white
  735. container.image = UIImage(named: "Home378")
  736. container.layer.cornerRadius = 14
  737. // container.layer.borderWidth = 1
  738. // container.layer.borderColor = UIColor(hex: "#2B2B2B").withAlphaComponent(0.15).cgColor
  739. container.layer.shadowColor = UIColor.black.cgColor
  740. container.layer.shadowOpacity = 0.06
  741. container.layer.shadowRadius = 6
  742. container.layer.shadowOffset = .init(width: 0, height: 2)
  743. addSubview(container)
  744. [titleLabel, amountLabel].forEach { container.addSubview($0); $0.translatesAutoresizingMaskIntoConstraints = false }
  745. titleLabel.font = .systemFont(ofSize: 14)
  746. titleLabel.textColor = UIColor(hex: "#2B2B2B")
  747. amountLabel.font = .systemFont(ofSize: 22, weight: .semibold)
  748. amountLabel.textColor = UIColor(hex: "#2B2B2B")
  749. amountLabel.textAlignment = .right
  750. container.translatesAutoresizingMaskIntoConstraints = false
  751. NSLayoutConstraint.activate([
  752. container.heightAnchor.constraint(equalToConstant: 100),
  753. container.leadingAnchor.constraint(equalTo: leadingAnchor),
  754. container.trailingAnchor.constraint(equalTo: trailingAnchor),
  755. container.topAnchor.constraint(equalTo: topAnchor),
  756. container.bottomAnchor.constraint(equalTo: bottomAnchor),
  757. titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  758. titleLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  759. amountLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  760. amountLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  761. ])
  762. }
  763. required init?(coder: NSCoder) { fatalError() }
  764. func configure(title: String, amount: Double) {
  765. titleLabel.text = title
  766. amountLabel.text = String(format: "¥ %.2f", abs(amount))
  767. }
  768. }
  769. fileprivate final class DayHeaderView: UIView {
  770. private let dateLabel = UILabel()
  771. private let totalLabel = UILabel()
  772. private let under = UIImageView()
  773. override init(frame: CGRect) {
  774. super.init(frame: frame)
  775. backgroundColor = .clear
  776. dateLabel.font = .systemFont(ofSize: 13, weight: .semibold)
  777. dateLabel.textColor = UIColor(hex: "#2B2B2B")
  778. totalLabel.font = .systemFont(ofSize: 13)
  779. totalLabel.textColor = UIColor(hex: "#2B2B2B")
  780. // under.backgroundColor = UIColor(hex: "#FFE059")
  781. // under.layer.cornerRadius = 2
  782. under.image = UIImage(named: "Bookkeep395")
  783. addSubview(dateLabel); addSubview(totalLabel); addSubview(under)
  784. dateLabel.translatesAutoresizingMaskIntoConstraints = false
  785. totalLabel.translatesAutoresizingMaskIntoConstraints = false
  786. under.translatesAutoresizingMaskIntoConstraints = false
  787. NSLayoutConstraint.activate([
  788. dateLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  789. dateLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  790. totalLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  791. totalLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  792. under.heightAnchor.constraint(equalToConstant: 12),
  793. // under.widthAnchor.constraint(equalToConstant: 60),
  794. under.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: -5),
  795. under.leadingAnchor.constraint(equalTo: dateLabel.leadingAnchor),
  796. under.trailingAnchor.constraint(equalTo: dateLabel.trailingAnchor)
  797. ])
  798. }
  799. required init?(coder: NSCoder) { fatalError() }
  800. func configure(date: Date, total: Double) {
  801. let f1 = DateFormatter(); f1.dateFormat = "yyyy.MM.dd EEEE"; f1.locale = Locale(identifier: "zh_CN")
  802. dateLabel.text = f1.string(from: date)
  803. totalLabel.text = String(format: "支出: ¥ %.2f", abs(total))
  804. }
  805. }
  806. fileprivate final class PetBadgeView: UIView {
  807. private let iconView = UIImageView()
  808. private let nameLabel = UILabel()
  809. private let container = UIView()
  810. override init(frame: CGRect) {
  811. super.init(frame: frame)
  812. // 带描边的圆角白底
  813. container.backgroundColor = .white
  814. container.layer.cornerRadius = 7
  815. container.layer.masksToBounds = true
  816. container.layer.borderWidth = 1
  817. container.layer.borderColor = UIColor(hex: "#5B4227").cgColor
  818. addSubview(container)
  819. iconView.contentMode = .scaleAspectFit
  820. iconView.layer.cornerRadius = 5
  821. iconView.layer.masksToBounds = true
  822. nameLabel.font = .systemFont(ofSize: 12)
  823. nameLabel.textColor = UIColor(hex: "#5B4227")
  824. let stack = UIStackView(arrangedSubviews: [iconView, nameLabel])
  825. stack.axis = .horizontal
  826. stack.alignment = .center
  827. stack.spacing = 6
  828. container.addSubview(stack)
  829. container.translatesAutoresizingMaskIntoConstraints = false
  830. stack.translatesAutoresizingMaskIntoConstraints = false
  831. iconView.translatesAutoresizingMaskIntoConstraints = false
  832. NSLayoutConstraint.activate([
  833. container.topAnchor.constraint(equalTo: topAnchor),
  834. container.leadingAnchor.constraint(equalTo: leadingAnchor),
  835. container.trailingAnchor.constraint(equalTo: trailingAnchor),
  836. container.bottomAnchor.constraint(equalTo: bottomAnchor),
  837. stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
  838. stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
  839. stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
  840. stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
  841. iconView.widthAnchor.constraint(equalToConstant: 10),
  842. iconView.heightAnchor.constraint(equalToConstant: 10)
  843. ])
  844. }
  845. required init?(coder: NSCoder) { fatalError() }
  846. func set(name: String, icon: UIImage?) {
  847. nameLabel.text = name
  848. iconView.image = icon ?? UIImage(named: "peihead")
  849. }
  850. }
  851. fileprivate final class ExpenseCell: UITableViewCell {
  852. // 手绘边框容器(使用切图)
  853. private let container = UIImageView()
  854. // 上半区元素
  855. private let icon = UIImageView(image: UIImage(named: "peihead"))
  856. private let titleLabel = UILabel()
  857. private let amountLabel = UILabel()
  858. // 中部整条虚线
  859. private let dashLayer = CAShapeLayer()
  860. // 右下角宠物名徽章
  861. private let petBadge = PetBadgeView()
  862. override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
  863. super.init(style: style, reuseIdentifier: reuseIdentifier)
  864. backgroundColor = .clear
  865. selectionStyle = .none
  866. // 手绘边框底图
  867. container.image = UIImage(named: "Home378")
  868. container.contentMode = .scaleToFill
  869. contentView.addSubview(container)
  870. container.translatesAutoresizingMaskIntoConstraints = false
  871. NSLayoutConstraint.activate([
  872. container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
  873. container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
  874. container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
  875. container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
  876. container.heightAnchor.constraint(greaterThanOrEqualToConstant: 121)
  877. ])
  878. // 上半区
  879. icon.contentMode = .scaleAspectFill
  880. icon.layer.cornerRadius = 8
  881. icon.layer.masksToBounds = true
  882. titleLabel.font = .systemFont(ofSize: 16, weight: .regular)
  883. titleLabel.textColor = UIColor(hex: "#2B2B2B")
  884. amountLabel.font = .systemFont(ofSize: 16, weight: .heavy)
  885. amountLabel.textColor = UIColor(hex: "#2B2B2B")
  886. amountLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
  887. amountLabel.textAlignment = .right
  888. [icon, titleLabel, amountLabel, petBadge].forEach { v in
  889. v.translatesAutoresizingMaskIntoConstraints = false
  890. container.addSubview(v)
  891. }
  892. NSLayoutConstraint.activate([
  893. icon.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  894. icon.topAnchor.constraint(equalTo: container.topAnchor, constant: 16),
  895. icon.widthAnchor.constraint(equalToConstant: 32),
  896. icon.heightAnchor.constraint(equalToConstant: 32),
  897. titleLabel.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 14),
  898. titleLabel.topAnchor.constraint(equalTo: icon.topAnchor,constant: 4),
  899. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: amountLabel.leadingAnchor, constant: -8),
  900. amountLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  901. amountLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
  902. petBadge.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  903. petBadge.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  904. petBadge.heightAnchor.constraint(greaterThanOrEqualToConstant: 14)
  905. ])
  906. // 中部整条虚线(添加在容器上)
  907. dashLayer.strokeColor = UIColor(hex: "#CFCFCF").cgColor
  908. dashLayer.fillColor = UIColor.clear.cgColor
  909. dashLayer.lineDashPattern = [6, 6]
  910. dashLayer.lineWidth = 1
  911. container.layer.addSublayer(dashLayer)
  912. }
  913. required init?(coder: NSCoder) { fatalError() }
  914. override func layoutSubviews() {
  915. super.layoutSubviews()
  916. // 让中部虚线横穿整个卡片(留左右内边距)
  917. let left: CGFloat = 16
  918. let right: CGFloat = bounds.width - 16
  919. // 取上半区最大底部再往下 16 作为分隔线位置
  920. let topMax = max(icon.frame.maxY, titleLabel.frame.maxY, amountLabel.frame.maxY)
  921. let y = topMax + 16
  922. let path = UIBezierPath()
  923. path.move(to: CGPoint(x: left, y: y))
  924. path.addLine(to: CGPoint(x: right, y: y))
  925. dashLayer.path = path.cgPath
  926. }
  927. func configure(title: String, amount: Double, petName: String) {
  928. titleLabel.text = title
  929. let prefix = amount < 0 ? "- " : "+ "
  930. amountLabel.text = String(format: "%@¥ %.2f", prefix, abs(amount))
  931. petBadge.set(name: petName, icon: UIImage(named: "peihead"))
  932. }
  933. }
  934. // MARK: - Utils
  935. fileprivate func dayOnly(_ d: Date) -> Date {
  936. let cal = Calendar.current
  937. return cal.date(from: cal.dateComponents([.year, .month, .day], from: d))!
  938. }
  939. private extension DateFormatter {
  940. static func with(_ f: String) -> DateFormatter {
  941. let d = DateFormatter()
  942. d.dateFormat = f
  943. return d
  944. }
  945. }
  946. // MARK: - HEX 颜色
  947. extension UIColor {
  948. convenience init(hex: String, alpha: CGFloat = 1.0) {
  949. var hexSanitized = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
  950. var int: UInt64 = 0
  951. Scanner(string: hexSanitized).scanHexInt64(&int)
  952. let a, r, g, b: UInt64
  953. switch hexSanitized.count {
  954. case 3: (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
  955. case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
  956. case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
  957. default: (a, r, g, b) = (255, 0, 0, 0)
  958. }
  959. self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
  960. }
  961. }