BookkeepingAmountSheet.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. //
  2. // BookkeepingAmountSheet.swift
  3. // VenusKitto
  4. //
  5. // Created by Neoa on 2025/8/26.
  6. //
  7. import Foundation
  8. import UIKit
  9. // MARK: - BookkeepingAmountSheet: 记账输入弹窗(上图下字 + 数字键盘)
  10. final class BookkeepingAmountSheet: UIView{
  11. // 外部交互
  12. var onDismiss: (() -> Void)?
  13. /// (分类名, 日期, 选中宠物, 备注, 金额字符串[带正负/小数])
  14. var onSave: ((String, Date, HomeViewController.PetSummary?, String, String) -> Void)?
  15. /// 让宿主控制选择日期(可用你项目里的 DatePickerSheetController)
  16. var onPickDate: ((_ current: Date, _ handler: @escaping (Date) -> Void) -> Void)?
  17. /// 让宿主控制选择宠物(ActionSheet/自定义弹窗都行)
  18. var onPickPet: ((_ handler: @escaping (HomeViewController.PetSummary) -> Void) -> Void)?
  19. // 内部状态
  20. private var categoryName: String = ""
  21. private var selectedDate: Date = Date()
  22. private var selectedPet: HomeViewController.PetSummary?
  23. private var amountText: String = "" { didSet { amountField.text = amountText } }
  24. // UI
  25. private let overlay = UIControl()
  26. private let sheet = UIView()
  27. private let handleBar = UIView()
  28. private let dateButton = UIButton(type: .system)
  29. private let petButton = UIControl()
  30. private let petAvatar = UIImageView()
  31. private let petNameLabel = UILabel()
  32. private let noteField = UITextField()
  33. private let amountField = UITextField()
  34. private let grid = UIStackView()
  35. // MARK: - LifeCycle
  36. override init(frame: CGRect) {
  37. super.init(frame: frame)
  38. buildUI()
  39. isHidden = true
  40. }
  41. required init?(coder: NSCoder) { fatalError() }
  42. // 对外:配置分类、当前宠物
  43. func configure(category: String, currentPet: HomeViewController.PetSummary?) {
  44. self.categoryName = category
  45. self.selectedPet = currentPet
  46. petNameLabel.text = currentPet?.name ?? "宠物名字"
  47. if let urlStr = currentPet?.avatar, let u = URL(string: urlStr) {
  48. // 简单加载头像(可替换为你的统一加载逻辑)
  49. URLSession.shared.dataTask(with: u) { data, _, _ in
  50. let img = data.flatMap { UIImage(data: $0) }
  51. DispatchQueue.main.async { self.petAvatar.image = img ?? UIImage(named: "peihead") }
  52. }.resume()
  53. } else {
  54. petAvatar.image = UIImage(named: "peihead")
  55. }
  56. dateButton.setTitle(format(selectedDate), for: .normal)
  57. noteField.text = ""
  58. amountText = ""
  59. }
  60. // 显示/隐藏
  61. func show() {
  62. isHidden = false
  63. overlay.alpha = 0
  64. sheet.alpha = 0
  65. sheet.transform = CGAffineTransform(translationX: 0, y: 24)
  66. layoutIfNeeded()
  67. UIView.animate(withDuration: 0.22) {
  68. self.overlay.alpha = 1
  69. self.sheet.alpha = 1
  70. self.sheet.transform = .identity
  71. }
  72. }
  73. @objc private func hide() {
  74. endEditing(true) // 收起键盘
  75. UIView.animate(withDuration: 0.2, animations: {
  76. self.overlay.alpha = 0
  77. self.sheet.alpha = 0
  78. self.sheet.transform = CGAffineTransform(translationX: 0, y: 16)
  79. }, completion: { _ in
  80. self.isHidden = true
  81. self.onDismiss?()
  82. })
  83. }
  84. // MARK: - UI
  85. private func buildUI() {
  86. backgroundColor = .clear
  87. // 遮罩
  88. overlay.backgroundColor = UIColor.black.withAlphaComponent(0.5)
  89. overlay.addTarget(self, action: #selector(hide), for: .touchUpInside)
  90. addSubview(overlay)
  91. overlay.translatesAutoresizingMaskIntoConstraints = false
  92. // 底板
  93. sheet.backgroundColor = .white
  94. sheet.layer.cornerRadius = 16
  95. sheet.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
  96. sheet.layer.masksToBounds = true
  97. addSubview(sheet)
  98. sheet.translatesAutoresizingMaskIntoConstraints = false
  99. // 顶部小横条
  100. handleBar.backgroundColor = UIColor(hex: "#E9E5E1")
  101. handleBar.layer.cornerRadius = 2
  102. handleBar.translatesAutoresizingMaskIntoConstraints = false
  103. sheet.addSubview(handleBar)
  104. // 顶部:日期 & 宠物
  105. let topRow = UIStackView()
  106. topRow.axis = .horizontal
  107. topRow.spacing = 12
  108. topRow.distribution = .fillEqually
  109. topRow.translatesAutoresizingMaskIntoConstraints = false
  110. sheet.addSubview(topRow)
  111. // 日期按钮(黄底,左侧)
  112. dateButton.setTitleColor(.black, for: .normal)
  113. dateButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
  114. dateButton.backgroundColor = UIColor(hex: "#FFE059")
  115. dateButton.layer.cornerRadius = 10
  116. dateButton.contentEdgeInsets = .init(top: 0, left: 12, bottom: 0, right: 12)
  117. dateButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
  118. dateButton.addTarget(self, action: #selector(tapDate), for: .touchUpInside)
  119. // 宠物选择(白底描边,右侧)
  120. let petContainer = UIView()
  121. petContainer.backgroundColor = UIColor(hex: "#FEFCF2")
  122. petContainer.layer.cornerRadius = 10
  123. petContainer.layer.borderWidth = 1
  124. petContainer.layer.borderColor = UIColor(hex: "#E9E5E1").cgColor
  125. petContainer.translatesAutoresizingMaskIntoConstraints = false
  126. petButton.translatesAutoresizingMaskIntoConstraints = false
  127. petContainer.addSubview(petButton)
  128. let petStack = UIStackView()
  129. petStack.axis = .horizontal
  130. petStack.alignment = .center
  131. petStack.spacing = 8
  132. petStack.translatesAutoresizingMaskIntoConstraints = false
  133. petContainer.addSubview(petStack)
  134. petAvatar.contentMode = .scaleAspectFill
  135. petAvatar.layer.cornerRadius = 14
  136. petAvatar.layer.masksToBounds = true
  137. petAvatar.widthAnchor.constraint(equalToConstant: 28).isActive = true
  138. petAvatar.heightAnchor.constraint(equalToConstant: 28).isActive = true
  139. petNameLabel.text = "宠物名字"
  140. petNameLabel.font = .systemFont(ofSize: 15)
  141. petStack.addArrangedSubview(petAvatar)
  142. petStack.addArrangedSubview(petNameLabel)
  143. NSLayoutConstraint.activate([
  144. petButton.topAnchor.constraint(equalTo: petContainer.topAnchor),
  145. petButton.bottomAnchor.constraint(equalTo: petContainer.bottomAnchor),
  146. petButton.leadingAnchor.constraint(equalTo: petContainer.leadingAnchor),
  147. petButton.trailingAnchor.constraint(equalTo: petContainer.trailingAnchor),
  148. petStack.centerYAnchor.constraint(equalTo: petContainer.centerYAnchor),
  149. petStack.leadingAnchor.constraint(equalTo: petContainer.leadingAnchor, constant: 12),
  150. petStack.trailingAnchor.constraint(lessThanOrEqualTo: petContainer.trailingAnchor, constant: -12),
  151. petContainer.heightAnchor.constraint(equalToConstant: 44)
  152. ])
  153. petButton.addTarget(self, action: #selector(tapPet), for: .touchUpInside)
  154. topRow.addArrangedSubview(dateButton)
  155. topRow.addArrangedSubview(petContainer)
  156. // 第二行:备注/金额
  157. let midRow = UIStackView()
  158. midRow.axis = .horizontal
  159. midRow.spacing = 12
  160. midRow.distribution = .fillEqually
  161. midRow.translatesAutoresizingMaskIntoConstraints = false
  162. sheet.addSubview(midRow)
  163. styleField(noteField, placeholder: "备注")
  164. noteField.returnKeyType = .done
  165. noteField.delegate = self
  166. noteField.inputAccessoryView = makeKeyboardToolbar() // 键盘上方“完成”按钮
  167. styleField(amountField, placeholder: "请输入金额")
  168. amountField.textAlignment = .center
  169. amountField.isUserInteractionEnabled = false // 交给数字键盘输入
  170. midRow.addArrangedSubview(noteField)
  171. midRow.addArrangedSubview(amountField)
  172. // 数字键盘 4×4
  173. grid.axis = .vertical
  174. grid.spacing = 12
  175. grid.translatesAutoresizingMaskIntoConstraints = false
  176. sheet.addSubview(grid)
  177. let rows: [[String]] = [
  178. ["1","2","3","C"],
  179. ["4","5","6","+"],
  180. ["7","8","9","-"],
  181. ["再记","0",".","保存"]
  182. ]
  183. for r in rows {
  184. let h = UIStackView()
  185. h.axis = .horizontal
  186. h.spacing = 12
  187. h.distribution = .fillEqually
  188. r.forEach { h.addArrangedSubview(makeKey($0)) }
  189. grid.addArrangedSubview(h)
  190. }
  191. // 点击任意空白处收起键盘(不影响其它控件点击)
  192. let tapDismiss = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
  193. tapDismiss.cancelsTouchesInView = false
  194. sheet.addGestureRecognizer(tapDismiss)
  195. // 约束
  196. NSLayoutConstraint.activate([
  197. overlay.topAnchor.constraint(equalTo: topAnchor),
  198. overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
  199. overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
  200. overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
  201. sheet.leadingAnchor.constraint(equalTo: leadingAnchor),
  202. sheet.trailingAnchor.constraint(equalTo: trailingAnchor),
  203. sheet.bottomAnchor.constraint(equalTo: bottomAnchor),
  204. // sheet.heightAnchor.constraint(equalTo: heightAnchor, multiplier: (traitCollection.userInterfaceIdiom == .pad ? 0.30 : 0.44)),
  205. sheet.heightAnchor.constraint(equalToConstant: 421),
  206. handleBar.topAnchor.constraint(equalTo: sheet.topAnchor, constant: 8),
  207. handleBar.centerXAnchor.constraint(equalTo: sheet.centerXAnchor),
  208. handleBar.widthAnchor.constraint(equalToConstant: 44),
  209. handleBar.heightAnchor.constraint(equalToConstant: 4),
  210. topRow.topAnchor.constraint(equalTo: handleBar.bottomAnchor, constant: 16),
  211. topRow.leadingAnchor.constraint(equalTo: sheet.leadingAnchor, constant: 16),
  212. topRow.trailingAnchor.constraint(equalTo: sheet.trailingAnchor, constant: -16),
  213. midRow.topAnchor.constraint(equalTo: topRow.bottomAnchor, constant: 12),
  214. midRow.leadingAnchor.constraint(equalTo: topRow.leadingAnchor),
  215. midRow.trailingAnchor.constraint(equalTo: topRow.trailingAnchor),
  216. noteField.heightAnchor.constraint(equalToConstant: 44),
  217. amountField.heightAnchor.constraint(equalTo: noteField.heightAnchor),
  218. grid.topAnchor.constraint(equalTo: midRow.bottomAnchor, constant: 16),
  219. grid.leadingAnchor.constraint(equalTo: topRow.leadingAnchor),
  220. grid.trailingAnchor.constraint(equalTo: topRow.trailingAnchor),
  221. grid.bottomAnchor.constraint(equalTo: sheet.safeAreaLayoutGuide.bottomAnchor, constant: -12)
  222. ])
  223. }
  224. private func styleField(_ tf: UITextField, placeholder: String) {
  225. tf.placeholder = placeholder
  226. tf.font = .systemFont(ofSize: 15)
  227. tf.backgroundColor = UIColor(hex: "#FEFCF2")
  228. tf.layer.cornerRadius = 10
  229. tf.layer.borderWidth = 1
  230. tf.layer.borderColor = UIColor(hex: "#E9E5E1").cgColor
  231. tf.leftView = UIView(frame: .init(x: 0, y: 0, width: 12, height: 1))
  232. tf.leftViewMode = .always
  233. tf.heightAnchor.constraint(equalToConstant: 44).isActive = true
  234. }
  235. private func makeKey(_ title: String) -> UIView {
  236. let btn = UIButton(type: .system)
  237. btn.setTitle(title, for: .normal)
  238. btn.titleLabel?.font = .systemFont(ofSize: 18, weight: (title == "保存" ? .semibold : .regular))
  239. btn.layer.cornerRadius = 10
  240. btn.heightAnchor.constraint(equalToConstant: 48).isActive = true
  241. if title == "保存" || title == "再记" {
  242. btn.backgroundColor = (title == "保存") ? UIColor(hex: "#FFE059") : UIColor(hex: "#FFE059")
  243. btn.setTitleColor(.black, for: .normal)
  244. } else {
  245. btn.backgroundColor = UIColor(hex: "#FEFCF2")
  246. btn.setTitleColor(UIColor(hex: "#2B2B2B"), for: .normal)
  247. btn.layer.borderWidth = 1
  248. btn.layer.borderColor = UIColor(hex: "#E9E5E1").cgColor
  249. }
  250. btn.addTarget(self, action: #selector(tapKey(_:)), for: .touchUpInside)
  251. return btn
  252. }
  253. @objc private func dismissKeyboard() {
  254. endEditing(true)
  255. }
  256. private func makeKeyboardToolbar() -> UIToolbar {
  257. let bar = UIToolbar()
  258. bar.sizeToFit()
  259. let flex = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
  260. let done = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(dismissKeyboard))
  261. bar.items = [flex, done]
  262. return bar
  263. }
  264. // MARK: - Actions
  265. @objc private func tapDate() {
  266. onPickDate?(selectedDate) { [weak self] newDate in
  267. guard let self = self else { return }
  268. self.selectedDate = newDate
  269. self.dateButton.setTitle(self.format(newDate), for: .normal)
  270. }
  271. }
  272. @objc private func tapPet() {
  273. onPickPet? { [weak self] pet in
  274. guard let self = self else { return }
  275. self.selectedPet = pet
  276. self.petNameLabel.text = pet.name
  277. if let avatar = pet.avatar, let url = URL(string: avatar) {
  278. URLSession.shared.dataTask(with: url) { data, _, _ in
  279. let img = data.flatMap { UIImage(data: $0) }
  280. DispatchQueue.main.async { self.petAvatar.image = img ?? UIImage(named: "peihead") }
  281. }.resume()
  282. }
  283. }
  284. }
  285. @objc private func tapKey(_ sender: UIButton) {
  286. guard let t = sender.title(for: .normal) else { return }
  287. switch t {
  288. case "0"..."9":
  289. amountText.append(t)
  290. case ".":
  291. if !amountText.contains(".") { amountText.append(".") }
  292. case "C":
  293. if !amountText.isEmpty { amountText.removeLast() }
  294. case "+":
  295. if amountText.hasPrefix("-") { amountText.removeFirst() }
  296. case "-":
  297. if !amountText.hasPrefix("-"), !amountText.isEmpty { amountText = "-" + amountText }
  298. case "再记":
  299. onSave?(categoryName, selectedDate, selectedPet, noteField.text ?? "", amountText)
  300. noteField.text = ""
  301. amountText = ""
  302. case "保存":
  303. onSave?(categoryName, selectedDate, selectedPet, noteField.text ?? "", amountText)
  304. hide()
  305. default: break
  306. }
  307. }
  308. private func format(_ d: Date) -> String {
  309. let f = DateFormatter()
  310. f.locale = Locale(identifier: "zh_CN")
  311. f.dateFormat = "yyyy.MM.dd"
  312. return f.string(from: d)
  313. }
  314. }
  315. extension BookkeepingAmountSheet: UITextFieldDelegate {
  316. func textFieldShouldReturn(_ textField: UITextField) -> Bool {
  317. textField.resignFirstResponder()
  318. return true
  319. }
  320. }