SettingsView.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import Cocoa
  2. // MARK: - Root
  3. final class SettingsView: NSView, AppearanceRefreshable {
  4. private var aboutCard: SettingsGroupCard!
  5. private var observers: [NSObjectProtocol] = []
  6. init() {
  7. super.init(frame: .zero)
  8. wantsLayer = true
  9. layer?.backgroundColor = AppTheme.background.cgColor
  10. translatesAutoresizingMaskIntoConstraints = false
  11. setup()
  12. }
  13. @available(*, unavailable)
  14. required init?(coder: NSCoder) { nil }
  15. deinit {
  16. observers.forEach { NotificationCenter.default.removeObserver($0) }
  17. }
  18. func refreshAppearance() {
  19. layer?.backgroundColor = AppTheme.background.cgColor
  20. }
  21. func refreshPremiumState() {
  22. refreshAboutCard()
  23. }
  24. private func setup() {
  25. let scrollView = NSScrollView()
  26. scrollView.translatesAutoresizingMaskIntoConstraints = false
  27. scrollView.hasVerticalScroller = true
  28. scrollView.autohidesScrollers = true
  29. scrollView.drawsBackground = false
  30. scrollView.borderType = .noBorder
  31. let document = FlippedSettingsDocumentView()
  32. document.translatesAutoresizingMaskIntoConstraints = false
  33. let panel = ContentPanelView(cornerRadius: 22)
  34. panel.translatesAutoresizingMaskIntoConstraints = false
  35. let stack = NSStackView()
  36. stack.orientation = .vertical
  37. stack.alignment = .leading
  38. stack.spacing = 28
  39. stack.translatesAutoresizingMaskIntoConstraints = false
  40. let appSection = makeSection(title: "App", card: makeAppCard())
  41. aboutCard = makeAboutCard()
  42. let aboutSection = makeSection(title: "About", card: aboutCard)
  43. stack.addArrangedSubview(appSection)
  44. stack.addArrangedSubview(aboutSection)
  45. observers.append(
  46. NotificationCenter.default.addObserver(
  47. forName: .premiumStatusDidChange,
  48. object: nil,
  49. queue: .main
  50. ) { [weak self] _ in
  51. self?.refreshAboutCard()
  52. }
  53. )
  54. [appSection, aboutSection].forEach { section in
  55. section.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  56. }
  57. panel.addSubview(stack)
  58. document.addSubview(panel)
  59. scrollView.documentView = document
  60. addSubview(scrollView)
  61. let guide = scrollView.contentView
  62. NSLayoutConstraint.activate([
  63. scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
  64. scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
  65. scrollView.topAnchor.constraint(equalTo: topAnchor),
  66. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  67. document.topAnchor.constraint(equalTo: guide.topAnchor),
  68. document.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
  69. document.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
  70. document.widthAnchor.constraint(equalTo: guide.widthAnchor),
  71. panel.topAnchor.constraint(equalTo: document.topAnchor, constant: 8),
  72. panel.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 24),
  73. panel.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -24),
  74. panel.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -32),
  75. panel.widthAnchor.constraint(equalTo: document.widthAnchor, constant: -48),
  76. stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  77. stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  78. stack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 28),
  79. stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -28),
  80. stack.widthAnchor.constraint(equalTo: panel.widthAnchor, constant: -56),
  81. ])
  82. }
  83. private func makeSection(title: String, card: NSView) -> NSView {
  84. let container = NSStackView()
  85. container.orientation = .vertical
  86. container.alignment = .leading
  87. container.spacing = 12
  88. container.translatesAutoresizingMaskIntoConstraints = false
  89. let label = NSTextField.themeLabel(title, style: .primary, font: AppTheme.semiboldFont(size: 18))
  90. label.translatesAutoresizingMaskIntoConstraints = false
  91. container.addArrangedSubview(label)
  92. container.addArrangedSubview(card)
  93. card.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  94. return container
  95. }
  96. private func makeAppCard() -> NSView {
  97. let card = SettingsGroupCard()
  98. card.addRow(SettingsToggleRow(
  99. symbolName: "moon.fill",
  100. title: "Dark Mode",
  101. isOn: AppSettings.darkModeEnabled,
  102. isLast: true
  103. ) { enabled in
  104. AppSettings.darkModeEnabled = enabled
  105. AppSettings.applyAppearance()
  106. })
  107. return card
  108. }
  109. private func makeAboutCard() -> SettingsGroupCard {
  110. let card = SettingsGroupCard()
  111. card.setRows(aboutCardRows())
  112. return card
  113. }
  114. private func refreshAboutCard() {
  115. aboutCard?.setRows(aboutCardRows())
  116. }
  117. private func aboutCardRows() -> [NSView] {
  118. var rows: [NSView] = []
  119. if StoreManager.shared.isPremium {
  120. rows.append(SettingsActionRow(symbolName: "star.fill", title: "Rate App") {
  121. AppRatingManager.shared.requestReviewFromUser(on: NSApp.keyWindow)
  122. })
  123. }
  124. let config = PaywallConfigService.shared.config
  125. rows.append(SettingsActionRow(symbolName: "arrow.clockwise", title: config.footer.restorePurchase) {
  126. Task { @MainActor in
  127. let store = StoreManager.shared
  128. store.clearPurchaseError()
  129. let restored = await store.restorePurchases()
  130. if restored {
  131. store.showAlert(
  132. title: config.messages.restoreSuccessTitle,
  133. message: config.messages.restoreSuccessMessage,
  134. on: NSApp.keyWindow
  135. )
  136. } else if let error = store.purchaseError {
  137. store.showPurchaseError(error, on: NSApp.keyWindow)
  138. }
  139. }
  140. })
  141. rows.append(SettingsActionRow(symbolName: "link", title: "Website") {
  142. NSWorkspace.shared.open(URL(string: "https://sites.google.com/view/smartprinterappmacos/home")!)
  143. })
  144. rows.append(SettingsActionRow(symbolName: "questionmark.circle", title: "Support") {
  145. NSWorkspace.shared.open(URL(string: "https://sites.google.com/view/smartprinterappmacos/get-support")!)
  146. })
  147. rows.append(SettingsActionRow(symbolName: "doc.text", title: "Terms of Use") {
  148. NSWorkspace.shared.open(URL(string: "https://sites.google.com/view/smartprinterappmacos/terms-and-condition")!)
  149. })
  150. rows.append(SettingsActionRow(symbolName: "shield", title: "Privacy Policy", isLast: true) {
  151. NSWorkspace.shared.open(URL(string: "https://sites.google.com/view/smartprinterappmacos/privacy-policy")!)
  152. })
  153. return rows
  154. }
  155. }
  156. private final class SettingsGroupCard: NSView, AppearanceRefreshable {
  157. private let stack = NSStackView()
  158. init() {
  159. super.init(frame: .zero)
  160. translatesAutoresizingMaskIntoConstraints = false
  161. wantsLayer = true
  162. layer?.cornerRadius = 16
  163. layer?.borderWidth = 1.5
  164. layer?.masksToBounds = true
  165. refreshAppearance()
  166. stack.orientation = .vertical
  167. stack.spacing = 0
  168. stack.translatesAutoresizingMaskIntoConstraints = false
  169. addSubview(stack)
  170. NSLayoutConstraint.activate([
  171. stack.leadingAnchor.constraint(equalTo: leadingAnchor),
  172. stack.trailingAnchor.constraint(equalTo: trailingAnchor),
  173. stack.topAnchor.constraint(equalTo: topAnchor),
  174. stack.bottomAnchor.constraint(equalTo: bottomAnchor),
  175. ])
  176. }
  177. @available(*, unavailable)
  178. required init?(coder: NSCoder) { nil }
  179. func addRow(_ row: NSView) {
  180. stack.addArrangedSubview(row)
  181. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  182. }
  183. func setRows(_ rows: [NSView]) {
  184. stack.arrangedSubviews.forEach { row in
  185. stack.removeArrangedSubview(row)
  186. row.removeFromSuperview()
  187. }
  188. rows.forEach { addRow($0) }
  189. }
  190. func refreshAppearance() {
  191. layer?.backgroundColor = AppTheme.groupCardBackground.cgColor
  192. layer?.borderColor = AppTheme.paywallBorder.cgColor
  193. }
  194. }
  195. // MARK: - Icon
  196. private final class SettingsIconBadge: NSView, AppearanceRefreshable {
  197. init(symbolName: String) {
  198. super.init(frame: .zero)
  199. translatesAutoresizingMaskIntoConstraints = false
  200. wantsLayer = true
  201. layer?.cornerRadius = 10
  202. refreshAppearance()
  203. let icon = NSImageView()
  204. icon.translatesAutoresizingMaskIntoConstraints = false
  205. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
  206. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
  207. icon.image = image.withSymbolConfiguration(config)
  208. }
  209. icon.contentTintColor = AppTheme.blue
  210. addSubview(icon)
  211. NSLayoutConstraint.activate([
  212. widthAnchor.constraint(equalToConstant: 36),
  213. heightAnchor.constraint(equalToConstant: 36),
  214. icon.centerXAnchor.constraint(equalTo: centerXAnchor),
  215. icon.centerYAnchor.constraint(equalTo: centerYAnchor),
  216. icon.widthAnchor.constraint(equalToConstant: 18),
  217. icon.heightAnchor.constraint(equalToConstant: 18),
  218. ])
  219. }
  220. @available(*, unavailable)
  221. required init?(coder: NSCoder) { nil }
  222. func refreshAppearance() {
  223. layer?.backgroundColor = AppTheme.blueLight.cgColor
  224. }
  225. }
  226. // MARK: - Rows
  227. private class SettingsRowBase: NSView, AppearanceRefreshable {
  228. private var hoverTracker: HoverTracker?
  229. private var isHovered = false
  230. private let isInteractive: Bool
  231. init(isLast: Bool, isInteractive: Bool = false) {
  232. self.isInteractive = isInteractive
  233. super.init(frame: .zero)
  234. translatesAutoresizingMaskIntoConstraints = false
  235. heightAnchor.constraint(equalToConstant: 56).isActive = true
  236. if !isLast { addDivider() }
  237. if isInteractive {
  238. wantsLayer = true
  239. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  240. self?.setHovered(hovering)
  241. }
  242. }
  243. }
  244. @available(*, unavailable)
  245. required init?(coder: NSCoder) { nil }
  246. func refreshAppearance() {
  247. updateHoverAppearance()
  248. }
  249. private func setHovered(_ hovering: Bool) {
  250. isHovered = hovering
  251. updateHoverAppearance()
  252. }
  253. private func updateHoverAppearance() {
  254. guard isInteractive else { return }
  255. animateHover {
  256. layer?.backgroundColor = (isHovered ? AppTheme.sidebarHoverBackground : .clear).cgColor
  257. }
  258. }
  259. func addDivider() {
  260. let divider = NSBox()
  261. divider.boxType = .separator
  262. divider.translatesAutoresizingMaskIntoConstraints = false
  263. addSubview(divider)
  264. NSLayoutConstraint.activate([
  265. divider.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 60),
  266. divider.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  267. divider.bottomAnchor.constraint(equalTo: bottomAnchor),
  268. divider.heightAnchor.constraint(equalToConstant: 1),
  269. ])
  270. }
  271. func install(icon symbolName: String, title: String, trailing: NSView) -> NSTextField {
  272. let badge = SettingsIconBadge(symbolName: symbolName)
  273. let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
  274. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  275. trailing.translatesAutoresizingMaskIntoConstraints = false
  276. addSubview(badge)
  277. addSubview(titleLabel)
  278. addSubview(trailing)
  279. NSLayoutConstraint.activate([
  280. badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  281. badge.centerYAnchor.constraint(equalTo: centerYAnchor),
  282. titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
  283. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  284. trailing.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
  285. trailing.centerYAnchor.constraint(equalTo: centerYAnchor),
  286. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailing.leadingAnchor, constant: -12),
  287. ])
  288. return titleLabel
  289. }
  290. }
  291. private final class SettingsActionRow: SettingsRowBase {
  292. init(symbolName: String, title: String, isLast: Bool = false, action: @escaping () -> Void) {
  293. super.init(isLast: isLast, isInteractive: true)
  294. let badge = SettingsIconBadge(symbolName: symbolName)
  295. let titleLabel = NSTextField.themeLabel(title, style: .primary, font: AppTheme.mediumFont(size: 15))
  296. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  297. addSubview(badge)
  298. addSubview(titleLabel)
  299. NSLayoutConstraint.activate([
  300. badge.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  301. badge.centerYAnchor.constraint(equalTo: centerYAnchor),
  302. titleLabel.leadingAnchor.constraint(equalTo: badge.trailingAnchor, constant: 14),
  303. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  304. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
  305. ])
  306. let control = LinkControl(onActivate: action)
  307. control.translatesAutoresizingMaskIntoConstraints = false
  308. addSubview(control)
  309. NSLayoutConstraint.activate([
  310. control.leadingAnchor.constraint(equalTo: leadingAnchor),
  311. control.trailingAnchor.constraint(equalTo: trailingAnchor),
  312. control.topAnchor.constraint(equalTo: topAnchor),
  313. control.bottomAnchor.constraint(equalTo: bottomAnchor),
  314. ])
  315. }
  316. @available(*, unavailable)
  317. required init?(coder: NSCoder) { nil }
  318. }
  319. private final class SettingsPopupRow: SettingsRowBase {
  320. private let popupTarget: PopupTarget
  321. init(symbolName: String, title: String, options: [String], selection: String, isLast: Bool = false, onChange: @escaping (String) -> Void) {
  322. popupTarget = PopupTarget(handler: onChange)
  323. super.init(isLast: isLast, isInteractive: true)
  324. let popup = NSPopUpButton()
  325. popup.bezelStyle = .rounded
  326. popup.addItems(withTitles: options)
  327. popup.selectItem(withTitle: selection)
  328. popup.font = AppTheme.regularFont(size: 13)
  329. popup.target = popupTarget
  330. popup.action = #selector(PopupTarget.changed(_:))
  331. _ = install(icon: symbolName, title: title, trailing: popup)
  332. }
  333. @available(*, unavailable)
  334. required init?(coder: NSCoder) { nil }
  335. }
  336. private final class SettingsToggleRow: SettingsRowBase {
  337. private let toggleTarget: ToggleTarget
  338. init(symbolName: String, title: String, isOn: Bool, isLast: Bool = false, onChange: @escaping (Bool) -> Void) {
  339. toggleTarget = ToggleTarget(handler: onChange)
  340. super.init(isLast: isLast, isInteractive: true)
  341. let toggle = NSSwitch()
  342. toggle.state = isOn ? .on : .off
  343. toggle.target = toggleTarget
  344. toggle.action = #selector(ToggleTarget.changed(_:))
  345. _ = install(icon: symbolName, title: title, trailing: toggle)
  346. }
  347. @available(*, unavailable)
  348. required init?(coder: NSCoder) { nil }
  349. }
  350. // MARK: - Helpers
  351. private final class LinkControl: NSControl {
  352. private let onActivate: () -> Void
  353. init(onActivate: @escaping () -> Void) {
  354. self.onActivate = onActivate
  355. super.init(frame: .zero)
  356. }
  357. @available(*, unavailable)
  358. required init?(coder: NSCoder) { nil }
  359. override func mouseUp(with event: NSEvent) {
  360. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  361. onActivate()
  362. }
  363. override func resetCursorRects() {
  364. addCursorRect(bounds, cursor: .pointingHand)
  365. }
  366. }
  367. private final class PopupTarget: NSObject {
  368. private let handler: (String) -> Void
  369. init(handler: @escaping (String) -> Void) {
  370. self.handler = handler
  371. }
  372. @objc func changed(_ sender: NSPopUpButton) {
  373. handler(sender.titleOfSelectedItem ?? "")
  374. }
  375. }
  376. private final class ToggleTarget: NSObject {
  377. private let handler: (Bool) -> Void
  378. init(handler: @escaping (Bool) -> Void) {
  379. self.handler = handler
  380. }
  381. @objc func changed(_ sender: NSSwitch) {
  382. handler(sender.state == .on)
  383. }
  384. }
  385. private final class FlippedSettingsDocumentView: NSView {
  386. override var isFlipped: Bool { true }
  387. }