ViewController.swift 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021
  1. //
  2. // ViewController.swift
  3. // smart_printer
  4. //
  5. import Cocoa
  6. class ViewController: NSViewController {
  7. private var sidebar: SidebarView!
  8. private var mainContentView: NSView!
  9. private var homeContentView: NSView!
  10. private var scanContentView: NSView!
  11. private var printContentView: NSView!
  12. private var scanAndHomeContentView: NSView!
  13. private var settingsContentView: NSView!
  14. private weak var settingsView: SettingsView?
  15. private var contentContainer: NSView!
  16. private var paywallOverlay: PaywallOverlayView?
  17. private var photoPreviewOverlay: PhotoPreviewOverlayView?
  18. private var filePreviewOverlay: FilePreviewOverlayView?
  19. private var printTextOverlay: PrintTextOverlayView?
  20. private var printContactsOverlay: PrintContactsOverlayView?
  21. private var drawPrintOverlay: DrawPrintOverlayView?
  22. private var ocrFileOverlay: OCRFileOverlayView?
  23. private var scanFileOverlay: ScanFileOverlayView?
  24. private var scannerOverlay: ScannerOverlayView?
  25. private var scannerDidComplete = false
  26. private var scannerCancelled = false
  27. private var scannerOutcomeDelivered = false
  28. private var paywallReturnToHomeOnDismiss = true
  29. private var headerView: NSView!
  30. private var headerFreeUsesBanner: FreeUsesBannerView!
  31. private var contentTopBelowHeader: NSLayoutConstraint!
  32. private var contentTopBelowWindow: NSLayoutConstraint!
  33. override func loadView() {
  34. let container = NSView(frame: NSRect(x: 0, y: 0, width: AppTheme.windowWidth, height: AppTheme.windowHeight))
  35. container.autoresizingMask = [.width, .height]
  36. container.wantsLayer = true
  37. container.layer?.backgroundColor = AppTheme.background.cgColor
  38. view = container
  39. }
  40. override func viewDidLoad() {
  41. super.viewDidLoad()
  42. setupLayout()
  43. NotificationCenter.default.addObserver(
  44. self,
  45. selector: #selector(showSettingsFromMenu),
  46. name: .showSettings,
  47. object: nil
  48. )
  49. NotificationCenter.default.addObserver(
  50. self,
  51. selector: #selector(appearanceDidChange),
  52. name: .appearanceDidChange,
  53. object: nil
  54. )
  55. NotificationCenter.default.addObserver(
  56. self,
  57. selector: #selector(premiumStatusDidChange),
  58. name: .premiumStatusDidChange,
  59. object: nil
  60. )
  61. NotificationCenter.default.addObserver(
  62. self,
  63. selector: #selector(entitlementsDidResolve),
  64. name: .entitlementsDidResolve,
  65. object: nil
  66. )
  67. }
  68. deinit {
  69. NotificationCenter.default.removeObserver(self)
  70. }
  71. @objc private func showSettingsFromMenu() {
  72. navigateToSettings()
  73. }
  74. @objc private func appearanceDidChange() {
  75. refreshAppearance()
  76. }
  77. @objc private func premiumStatusDidChange() {
  78. refreshPremiumUI()
  79. }
  80. @objc private func entitlementsDidResolve() {
  81. refreshPremiumUI()
  82. }
  83. private func refreshPremiumUI() {
  84. Task { @MainActor in
  85. paywallOverlay?.refreshStoreState()
  86. sidebar?.refreshPremiumState()
  87. settingsView?.refreshPremiumState()
  88. if StoreManager.shared.isPremium {
  89. navigateHomeAfterPremiumUnlock()
  90. }
  91. }
  92. }
  93. private func navigateHomeAfterPremiumUnlock() {
  94. guard paywallOverlay != nil else { return }
  95. paywallReturnToHomeOnDismiss = true
  96. paywallOverlay?.onDismiss = { [weak self] in
  97. self?.handlePaywallDismiss()
  98. }
  99. paywallOverlay?.dismiss()
  100. }
  101. private func refreshAppearance() {
  102. view.layer?.backgroundColor = AppTheme.background.cgColor
  103. mainContentView?.layer?.backgroundColor = AppTheme.background.cgColor
  104. view.window?.backgroundColor = AppTheme.background
  105. sidebar?.refreshAppearance()
  106. view.refreshAppearanceRecursively()
  107. paywallOverlay?.refreshAppearance()
  108. headerFreeUsesBanner?.refreshAppearance()
  109. photoPreviewOverlay?.refreshAppearance()
  110. filePreviewOverlay?.refreshAppearance()
  111. printTextOverlay?.refreshAppearance()
  112. printContactsOverlay?.refreshAppearance()
  113. drawPrintOverlay?.refreshAppearance()
  114. ocrFileOverlay?.refreshAppearance()
  115. scanFileOverlay?.refreshAppearance()
  116. scannerOverlay?.refreshAppearance()
  117. }
  118. func presentScanner(showScanPreview: Bool = false, onComplete: @escaping (Result<NSImage, Error>) -> Void) {
  119. guard guardPremiumFeature(.scannerScan) else { return }
  120. photoPreviewOverlay?.dismiss(animated: false)
  121. filePreviewOverlay?.dismiss(animated: false)
  122. printTextOverlay?.dismiss(animated: false)
  123. printContactsOverlay?.dismiss(animated: false)
  124. drawPrintOverlay?.dismiss(animated: false)
  125. ocrFileOverlay?.dismiss(animated: false)
  126. scanFileOverlay?.dismiss(animated: false)
  127. scannerDidComplete = false
  128. scannerCancelled = false
  129. scannerOutcomeDelivered = false
  130. if let existing = scannerOverlay, existing.superview != nil {
  131. configureScannerOverlay(existing, showScanPreview: showScanPreview, onComplete: onComplete)
  132. Task { await existing.activate() }
  133. return
  134. }
  135. dismissScannerOverlay()
  136. let overlay = ScannerOverlayView()
  137. configureScannerOverlay(overlay, showScanPreview: showScanPreview, onComplete: onComplete)
  138. scannerOverlay = overlay
  139. overlay.present(in: mainContentView)
  140. Task { await overlay.activate() }
  141. }
  142. private func configureScannerOverlay(
  143. _ overlay: ScannerOverlayView,
  144. showScanPreview: Bool,
  145. onComplete: @escaping (Result<NSImage, Error>) -> Void
  146. ) {
  147. overlay.onDismiss = { [weak self] in
  148. guard let self else { return }
  149. self.scannerCancelled = true
  150. let cancelled = !self.scannerOutcomeDelivered
  151. && !self.scannerDidComplete
  152. && self.scannerOverlay?.hasResult != true
  153. self.scannerOverlay = nil
  154. if cancelled {
  155. self.scannerOutcomeDelivered = true
  156. onComplete(.failure(ScanError.cancelled))
  157. }
  158. }
  159. overlay.onScanComplete = { [weak self] image in
  160. Task { @MainActor [weak self] in
  161. guard let self, !self.scannerCancelled else { return }
  162. let processed: NSImage
  163. if let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
  164. processed = await ScanDocumentProcessor.process(cgImage) ?? image
  165. } else {
  166. processed = image
  167. }
  168. guard !self.scannerCancelled else { return }
  169. let displayImage = processed.normalizedForDisplay()
  170. if showScanPreview {
  171. overlay.updateScanPreview(displayImage)
  172. } else {
  173. guard !self.scannerOutcomeDelivered else { return }
  174. self.scannerDidComplete = true
  175. self.scannerOutcomeDelivered = true
  176. onComplete(.success(displayImage))
  177. overlay.dismiss()
  178. }
  179. }
  180. }
  181. }
  182. func presentPrintText() {
  183. guard guardPremiumFeature(.printText) else { return }
  184. photoPreviewOverlay?.dismiss(animated: false)
  185. filePreviewOverlay?.dismiss(animated: false)
  186. printContactsOverlay?.dismiss(animated: false)
  187. drawPrintOverlay?.dismiss(animated: false)
  188. ocrFileOverlay?.dismiss(animated: false)
  189. scanFileOverlay?.dismiss(animated: false)
  190. dismissScannerOverlay()
  191. let overlay = PrintTextOverlayView()
  192. overlay.onDismiss = { [weak self] in
  193. self?.printTextOverlay = nil
  194. }
  195. printTextOverlay = overlay
  196. overlay.present(in: mainContentView)
  197. }
  198. func presentPrintContacts() {
  199. guard guardPremiumFeature(.printContacts) else { return }
  200. photoPreviewOverlay?.dismiss(animated: false)
  201. filePreviewOverlay?.dismiss(animated: false)
  202. printTextOverlay?.dismiss(animated: false)
  203. drawPrintOverlay?.dismiss(animated: false)
  204. ocrFileOverlay?.dismiss(animated: false)
  205. scanFileOverlay?.dismiss(animated: false)
  206. dismissScannerOverlay()
  207. contentContainer.isHidden = true
  208. if let overlay = printContactsOverlay {
  209. contentContainer.isHidden = true
  210. overlay.present(in: mainContentView)
  211. return
  212. }
  213. let overlay = PrintContactsOverlayView()
  214. overlay.onDismiss = { [weak self] in
  215. self?.printContactsOverlay = nil
  216. self?.restoreContentAfterOverlay()
  217. }
  218. printContactsOverlay = overlay
  219. overlay.present(in: mainContentView)
  220. }
  221. func presentOCRFile(imageURL: URL) {
  222. guard guardPremiumFeature(.ocrFile) else { return }
  223. photoPreviewOverlay?.dismiss(animated: false)
  224. filePreviewOverlay?.dismiss(animated: false)
  225. printTextOverlay?.dismiss(animated: false)
  226. printContactsOverlay?.dismiss(animated: false)
  227. drawPrintOverlay?.dismiss(animated: false)
  228. scanFileOverlay?.dismiss(animated: false)
  229. dismissScannerOverlay()
  230. let overlay = OCRFileOverlayView(imageURL: imageURL)
  231. overlay.onDismiss = { [weak self] in
  232. self?.ocrFileOverlay = nil
  233. }
  234. ocrFileOverlay = overlay
  235. overlay.present(in: mainContentView)
  236. }
  237. func presentScanFile(pages: [ScanPage]) {
  238. guard guardPremiumFeature(.scannerScan) else { return }
  239. photoPreviewOverlay?.dismiss(animated: false)
  240. filePreviewOverlay?.dismiss(animated: false)
  241. printTextOverlay?.dismiss(animated: false)
  242. printContactsOverlay?.dismiss(animated: false)
  243. drawPrintOverlay?.dismiss(animated: false)
  244. ocrFileOverlay?.dismiss(animated: false)
  245. dismissScannerOverlay()
  246. if let overlay = scanFileOverlay {
  247. overlay.appendPages(pages)
  248. overlay.present(in: mainContentView)
  249. return
  250. }
  251. let overlay = ScanFileOverlayView(pages: pages)
  252. overlay.onDismiss = { [weak self] in
  253. self?.scanFileOverlay = nil
  254. Task { @MainActor in
  255. await ScannerService.shared.releaseScanner()
  256. }
  257. }
  258. scanFileOverlay = overlay
  259. overlay.present(in: mainContentView)
  260. }
  261. func presentDrawPrint() {
  262. guard guardPremiumFeature(.drawPrint) else { return }
  263. photoPreviewOverlay?.dismiss(animated: false)
  264. filePreviewOverlay?.dismiss(animated: false)
  265. printTextOverlay?.dismiss(animated: false)
  266. printContactsOverlay?.dismiss(animated: false)
  267. ocrFileOverlay?.dismiss(animated: false)
  268. scanFileOverlay?.dismiss(animated: false)
  269. dismissScannerOverlay()
  270. let overlay = DrawPrintOverlayView()
  271. overlay.onDismiss = { [weak self] in
  272. self?.drawPrintOverlay = nil
  273. }
  274. drawPrintOverlay = overlay
  275. overlay.present(in: mainContentView)
  276. }
  277. func presentPhotoPreview(urls: [URL]) {
  278. guard guardPremiumFeature(.photoPreview) else { return }
  279. photoPreviewOverlay?.dismiss(animated: false)
  280. filePreviewOverlay?.dismiss(animated: false)
  281. printTextOverlay?.dismiss(animated: false)
  282. printContactsOverlay?.dismiss(animated: false)
  283. drawPrintOverlay?.dismiss(animated: false)
  284. ocrFileOverlay?.dismiss(animated: false)
  285. scanFileOverlay?.dismiss(animated: false)
  286. dismissScannerOverlay()
  287. contentContainer.isHidden = true
  288. let overlay = PhotoPreviewOverlayView(urls: urls)
  289. overlay.onDismiss = { [weak self] in
  290. self?.photoPreviewOverlay = nil
  291. self?.restoreContentAfterOverlay()
  292. }
  293. photoPreviewOverlay = overlay
  294. overlay.present(in: mainContentView)
  295. }
  296. func presentFilePreview(urls: [URL]) {
  297. guard guardPremiumFeature(.filePreview) else { return }
  298. photoPreviewOverlay?.dismiss(animated: false)
  299. filePreviewOverlay?.dismiss(animated: false)
  300. printTextOverlay?.dismiss(animated: false)
  301. printContactsOverlay?.dismiss(animated: false)
  302. drawPrintOverlay?.dismiss(animated: false)
  303. ocrFileOverlay?.dismiss(animated: false)
  304. scanFileOverlay?.dismiss(animated: false)
  305. dismissScannerOverlay()
  306. contentContainer.isHidden = true
  307. let overlay = FilePreviewOverlayView(urls: urls)
  308. overlay.onDismiss = { [weak self] in
  309. self?.filePreviewOverlay = nil
  310. self?.restoreContentAfterOverlay()
  311. }
  312. filePreviewOverlay = overlay
  313. overlay.present(in: mainContentView)
  314. }
  315. private func setupLayout() {
  316. sidebar = SidebarView()
  317. mainContentView = NSView()
  318. mainContentView.translatesAutoresizingMaskIntoConstraints = false
  319. mainContentView.wantsLayer = true
  320. mainContentView.layer?.backgroundColor = AppTheme.background.cgColor
  321. headerView = makeHeader()
  322. contentContainer = NSView()
  323. contentContainer.translatesAutoresizingMaskIntoConstraints = false
  324. homeContentView = makeHomeContentView()
  325. scanContentView = makeScanContentView()
  326. printContentView = makePrintContentView()
  327. scanAndHomeContentView = makeScanAndHomeContentView()
  328. settingsContentView = makeSettingsContentView()
  329. contentContainer.addSubview(homeContentView)
  330. contentContainer.addSubview(scanContentView)
  331. contentContainer.addSubview(printContentView)
  332. contentContainer.addSubview(scanAndHomeContentView)
  333. contentContainer.addSubview(settingsContentView)
  334. view.addSubview(sidebar)
  335. view.addSubview(mainContentView)
  336. mainContentView.addSubview(contentContainer)
  337. mainContentView.addSubview(headerView, positioned: .above, relativeTo: contentContainer)
  338. sidebar.onDestinationSelected = { [weak self] destination in
  339. self?.showDestination(destination)
  340. }
  341. sidebar.onPremiumUpgradeTapped = { [weak self] in
  342. self?.showDestination(.scanAndHome)
  343. }
  344. sidebar.onManageSubscriptionTapped = {
  345. StoreManager.shared.showManageSubscriptions()
  346. }
  347. sidebar.refreshPremiumState()
  348. NSLayoutConstraint.activate([
  349. sidebar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  350. sidebar.topAnchor.constraint(equalTo: view.topAnchor),
  351. sidebar.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  352. mainContentView.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor),
  353. mainContentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  354. mainContentView.topAnchor.constraint(equalTo: view.topAnchor),
  355. mainContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  356. headerView.leadingAnchor.constraint(equalTo: mainContentView.leadingAnchor),
  357. headerView.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
  358. headerView.topAnchor.constraint(equalTo: mainContentView.topAnchor, constant: AppTheme.headerTopInset),
  359. headerView.heightAnchor.constraint(equalToConstant: AppTheme.headerHeight),
  360. contentContainer.leadingAnchor.constraint(equalTo: mainContentView.leadingAnchor),
  361. contentContainer.trailingAnchor.constraint(equalTo: mainContentView.trailingAnchor),
  362. contentContainer.bottomAnchor.constraint(equalTo: mainContentView.bottomAnchor),
  363. ])
  364. pinContentView(homeContentView)
  365. pinContentView(scanContentView)
  366. pinContentView(printContentView)
  367. pinContentView(scanAndHomeContentView)
  368. pinContentView(settingsContentView)
  369. contentTopBelowHeader = contentContainer.topAnchor.constraint(
  370. equalTo: headerView.bottomAnchor,
  371. constant: AppTheme.contentBelowHeaderGap
  372. )
  373. contentTopBelowWindow = contentContainer.topAnchor.constraint(equalTo: mainContentView.topAnchor, constant: 12)
  374. contentTopBelowHeader.isActive = true
  375. showDestination(.home)
  376. }
  377. private func pinContentView(_ contentView: NSView) {
  378. NSLayoutConstraint.activate([
  379. contentView.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor),
  380. contentView.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor),
  381. contentView.topAnchor.constraint(equalTo: contentContainer.topAnchor),
  382. contentView.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor),
  383. ])
  384. }
  385. private func dismissScannerOverlay() {
  386. guard let scanner = scannerOverlay else { return }
  387. scanner.onDismiss = nil
  388. scanner.teardown()
  389. scanner.removeFromSuperview()
  390. scannerOverlay = nil
  391. }
  392. private func dismissFeatureOverlays() {
  393. photoPreviewOverlay?.dismiss(animated: false)
  394. filePreviewOverlay?.dismiss(animated: false)
  395. printTextOverlay?.dismiss(animated: false)
  396. printContactsOverlay?.dismiss(animated: false)
  397. drawPrintOverlay?.dismiss(animated: false)
  398. ocrFileOverlay?.dismiss(animated: false)
  399. scanFileOverlay?.dismiss(animated: false)
  400. dismissScannerOverlay()
  401. }
  402. private func hasActiveFeatureOverlay() -> Bool {
  403. [
  404. photoPreviewOverlay,
  405. filePreviewOverlay,
  406. printTextOverlay,
  407. printContactsOverlay,
  408. drawPrintOverlay,
  409. ocrFileOverlay,
  410. scanFileOverlay,
  411. scannerOverlay,
  412. ].contains { $0?.superview != nil }
  413. }
  414. private func restoreContentAfterOverlay() {
  415. guard !hasActiveFeatureOverlay() else { return }
  416. contentContainer.isHidden = false
  417. }
  418. private func showDestination(_ destination: SidebarDestination) {
  419. if destination == .scan || destination == .print || destination == .scanAndHome {
  420. Task { @MainActor in
  421. await StoreManager.shared.ensureEntitlementsResolved()
  422. self.showDestinationAfterEntitlementCheck(destination)
  423. }
  424. return
  425. }
  426. showDestinationAfterEntitlementCheck(destination)
  427. }
  428. private func showDestinationAfterEntitlementCheck(_ destination: SidebarDestination) {
  429. if destination == .scanAndHome {
  430. if StoreManager.shared.isPremium || FreeTierManager.remainingUses(isPremium: false) > 0 {
  431. sidebar.select(.scan)
  432. showDestinationAfterEntitlementCheck(.scan)
  433. } else {
  434. presentPaywall(returnToHomeOnDismiss: true)
  435. }
  436. return
  437. }
  438. if destination == .scan, !PremiumAccess.canAccess(.scannerScan) {
  439. sidebar.select(.home)
  440. showDestinationContent(.home)
  441. presentPaywall(returnToHomeOnDismiss: true)
  442. return
  443. }
  444. if destination == .print, !PremiumAccess.canAccess(.printText) {
  445. sidebar.select(.home)
  446. showDestinationContent(.home)
  447. presentPaywall(returnToHomeOnDismiss: true)
  448. return
  449. }
  450. showDestinationContent(destination)
  451. dismissPaywall()
  452. }
  453. private func showDestinationContent(_ destination: SidebarDestination) {
  454. dismissFeatureOverlays()
  455. contentContainer.isHidden = false
  456. homeContentView.isHidden = destination != .home && destination != .scanAndHome
  457. scanContentView.isHidden = destination != .scan
  458. printContentView.isHidden = destination != .print
  459. scanAndHomeContentView.isHidden = true
  460. settingsContentView.isHidden = destination != .settings
  461. let isSettings = destination == .settings
  462. headerView.isHidden = isSettings
  463. contentTopBelowHeader.isActive = !isSettings
  464. contentTopBelowWindow.isActive = isSettings
  465. }
  466. func presentPaywall(returnToHomeOnDismiss: Bool = true) {
  467. paywallReturnToHomeOnDismiss = returnToHomeOnDismiss
  468. if let paywallOverlay {
  469. paywallOverlay.onDismiss = { [weak self] in
  470. self?.handlePaywallDismiss()
  471. }
  472. Task { @MainActor in
  473. await StoreManager.shared.ensureEntitlementsResolved()
  474. paywallOverlay.refreshStoreState()
  475. }
  476. return
  477. }
  478. setTrafficLightsHidden(true)
  479. let overlay = PaywallOverlayView()
  480. overlay.onDismiss = { [weak self] in
  481. self?.handlePaywallDismiss()
  482. }
  483. overlay.present(in: view)
  484. paywallOverlay = overlay
  485. }
  486. private func handlePaywallDismiss() {
  487. setTrafficLightsHidden(false)
  488. paywallOverlay = nil
  489. if paywallReturnToHomeOnDismiss {
  490. sidebar.select(.home)
  491. showDestination(.home)
  492. }
  493. }
  494. private func guardPremiumFeature(_ feature: AppFeature) -> Bool {
  495. PremiumAccess.require(feature: feature, from: view.window)
  496. }
  497. private func dismissPaywall() {
  498. paywallOverlay?.removeFromSuperview()
  499. paywallOverlay = nil
  500. setTrafficLightsHidden(false)
  501. }
  502. private func setTrafficLightsHidden(_ isHidden: Bool) {
  503. guard let window = view.window else { return }
  504. let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton]
  505. for buttonType in buttons {
  506. window.standardWindowButton(buttonType)?.isHidden = isHidden
  507. }
  508. }
  509. private func makeHomeContentView() -> NSView {
  510. let container = NSView()
  511. container.translatesAutoresizingMaskIntoConstraints = false
  512. let panel = ContentPanelView()
  513. panel.translatesAutoresizingMaskIntoConstraints = false
  514. let scrollView = makeScrollView()
  515. panel.addSubview(scrollView)
  516. container.addSubview(panel)
  517. let inset = AppTheme.contentPanelInset
  518. NSLayoutConstraint.activate([
  519. panel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: inset),
  520. panel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -inset),
  521. panel.topAnchor.constraint(equalTo: container.topAnchor),
  522. panel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -inset),
  523. scrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  524. scrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  525. scrollView.topAnchor.constraint(equalTo: panel.topAnchor),
  526. scrollView.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  527. ])
  528. return container
  529. }
  530. private func makeScanContentView() -> NSView {
  531. makeDestinationScrollView(
  532. title: "Scan",
  533. features: featureCards(
  534. FeatureCardData(title: "Scan File", subtitle: "Scan any document", iconKind: .scanFile),
  535. FeatureCardData(title: "OCR File", subtitle: "Scan and print text from images", iconKind: .ocrFile)
  536. )
  537. )
  538. }
  539. private func makePrintContentView() -> NSView {
  540. makeDestinationScrollView(
  541. title: "Print",
  542. features: featureCards(
  543. FeatureCardData(title: "Print Text", subtitle: "Type text and print", iconKind: .printText),
  544. FeatureCardData(title: "Print Contacts", subtitle: "Print your contacts", iconKind: .printContacts),
  545. FeatureCardData(title: "Draw & Print", subtitle: "Add drawings, text and more", iconKind: .drawPrint)
  546. )
  547. )
  548. }
  549. private func makeScanAndHomeContentView() -> NSView {
  550. let container = NSView()
  551. container.translatesAutoresizingMaskIntoConstraints = false
  552. container.isHidden = true
  553. return container
  554. }
  555. private func makeDestinationScrollView(title: String, features: [FeatureCardData]) -> NSView {
  556. let container = NSView()
  557. container.translatesAutoresizingMaskIntoConstraints = false
  558. container.isHidden = true
  559. let scrollView = NSScrollView()
  560. scrollView.translatesAutoresizingMaskIntoConstraints = false
  561. scrollView.hasVerticalScroller = true
  562. scrollView.hasHorizontalScroller = false
  563. scrollView.autohidesScrollers = true
  564. scrollView.drawsBackground = false
  565. scrollView.borderType = .noBorder
  566. let documentView = FlippedDocumentView()
  567. documentView.translatesAutoresizingMaskIntoConstraints = false
  568. let sectionTitle = makeSectionTitle(title, icon: .grid)
  569. let freeUsesBanner = FreeUsesBannerView()
  570. let grid = makeFeatureGrid(features: features, columns: AppTheme.featureGridColumns)
  571. documentView.addSubview(sectionTitle)
  572. documentView.addSubview(freeUsesBanner)
  573. documentView.addSubview(grid)
  574. scrollView.documentView = documentView
  575. container.addSubview(scrollView)
  576. let contentGuide = scrollView.contentView
  577. NSLayoutConstraint.activate([
  578. scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  579. scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  580. scrollView.topAnchor.constraint(equalTo: container.topAnchor),
  581. scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  582. documentView.topAnchor.constraint(equalTo: contentGuide.topAnchor),
  583. documentView.leadingAnchor.constraint(equalTo: contentGuide.leadingAnchor),
  584. documentView.trailingAnchor.constraint(equalTo: contentGuide.trailingAnchor),
  585. documentView.widthAnchor.constraint(equalTo: contentGuide.widthAnchor),
  586. sectionTitle.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
  587. sectionTitle.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
  588. sectionTitle.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 8),
  589. freeUsesBanner.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
  590. freeUsesBanner.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
  591. freeUsesBanner.topAnchor.constraint(equalTo: sectionTitle.bottomAnchor, constant: 12),
  592. grid.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
  593. grid.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
  594. grid.topAnchor.constraint(equalTo: freeUsesBanner.bottomAnchor, constant: 16),
  595. grid.bottomAnchor.constraint(equalTo: documentView.bottomAnchor, constant: -40),
  596. ])
  597. return container
  598. }
  599. private func makeHeader() -> NSView {
  600. let header = NSView()
  601. header.translatesAutoresizingMaskIntoConstraints = false
  602. let titleLabel = NSTextField.themeLabel(AppStoreConfig.displayName, style: .primary, font: AppTheme.semiboldFont(size: 18))
  603. titleLabel.alignment = .center
  604. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  605. headerFreeUsesBanner = FreeUsesBannerView()
  606. headerFreeUsesBanner.translatesAutoresizingMaskIntoConstraints = false
  607. header.addSubview(titleLabel)
  608. header.addSubview(headerFreeUsesBanner)
  609. NSLayoutConstraint.activate([
  610. titleLabel.centerXAnchor.constraint(equalTo: header.centerXAnchor),
  611. titleLabel.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  612. headerFreeUsesBanner.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -AppTheme.contentPadding),
  613. headerFreeUsesBanner.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  614. ])
  615. return header
  616. }
  617. private func makeSettingsContentView() -> NSView {
  618. let container = NSView()
  619. container.translatesAutoresizingMaskIntoConstraints = false
  620. let settingsView = SettingsView()
  621. self.settingsView = settingsView
  622. container.addSubview(settingsView)
  623. NSLayoutConstraint.activate([
  624. settingsView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  625. settingsView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  626. settingsView.topAnchor.constraint(equalTo: container.topAnchor),
  627. settingsView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  628. ])
  629. return container
  630. }
  631. private func navigateToSettings() {
  632. if paywallOverlay != nil {
  633. dismissPaywall()
  634. }
  635. sidebar.select(.settings)
  636. showDestination(.settings)
  637. }
  638. private func makeScrollView() -> NSScrollView {
  639. let scrollView = NSScrollView()
  640. scrollView.translatesAutoresizingMaskIntoConstraints = false
  641. scrollView.hasVerticalScroller = true
  642. scrollView.hasHorizontalScroller = false
  643. scrollView.autohidesScrollers = true
  644. scrollView.drawsBackground = false
  645. scrollView.borderType = .noBorder
  646. let documentView = FlippedDocumentView()
  647. documentView.translatesAutoresizingMaskIntoConstraints = false
  648. let freeUsesBanner = FreeUsesBannerView()
  649. let quickStartSection = makeQuickStartSection()
  650. let createPrintSection = makeCreatePrintSection()
  651. documentView.addSubview(freeUsesBanner)
  652. documentView.addSubview(quickStartSection)
  653. documentView.addSubview(createPrintSection)
  654. scrollView.documentView = documentView
  655. let contentGuide = scrollView.contentView
  656. NSLayoutConstraint.activate([
  657. documentView.topAnchor.constraint(equalTo: contentGuide.topAnchor),
  658. documentView.leadingAnchor.constraint(equalTo: contentGuide.leadingAnchor),
  659. documentView.trailingAnchor.constraint(equalTo: contentGuide.trailingAnchor),
  660. documentView.widthAnchor.constraint(equalTo: contentGuide.widthAnchor),
  661. freeUsesBanner.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
  662. freeUsesBanner.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
  663. freeUsesBanner.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 12),
  664. quickStartSection.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
  665. quickStartSection.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
  666. quickStartSection.topAnchor.constraint(equalTo: freeUsesBanner.bottomAnchor, constant: 16),
  667. createPrintSection.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: AppTheme.contentPadding),
  668. createPrintSection.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -AppTheme.contentPadding),
  669. createPrintSection.topAnchor.constraint(equalTo: quickStartSection.bottomAnchor, constant: 36),
  670. createPrintSection.bottomAnchor.constraint(equalTo: documentView.bottomAnchor, constant: -40),
  671. ])
  672. return scrollView
  673. }
  674. private enum SectionTitleIcon {
  675. case none
  676. case sparkle
  677. case grid
  678. }
  679. private func makeSectionTitle(_ text: String, icon: SectionTitleIcon = .none) -> NSView {
  680. let container = NSView()
  681. container.translatesAutoresizingMaskIntoConstraints = false
  682. var leadingAnchor = container.leadingAnchor
  683. var iconSpacing: CGFloat = 0
  684. if icon != .none {
  685. let titleIcon: NSView
  686. switch icon {
  687. case .sparkle:
  688. titleIcon = SparkleIconView()
  689. case .grid:
  690. let gridIcon = NSImageView()
  691. if let image = NSImage(systemSymbolName: "square.grid.2x2", accessibilityDescription: nil) {
  692. let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium)
  693. gridIcon.image = image.withSymbolConfiguration(config)
  694. }
  695. gridIcon.contentTintColor = AppTheme.blue
  696. titleIcon = gridIcon
  697. case .none:
  698. titleIcon = NSView()
  699. }
  700. titleIcon.translatesAutoresizingMaskIntoConstraints = false
  701. container.addSubview(titleIcon)
  702. NSLayoutConstraint.activate([
  703. titleIcon.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  704. titleIcon.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  705. titleIcon.widthAnchor.constraint(equalToConstant: 24),
  706. titleIcon.heightAnchor.constraint(equalToConstant: 24),
  707. ])
  708. leadingAnchor = titleIcon.trailingAnchor
  709. iconSpacing = 8
  710. }
  711. let label = NSTextField.themeLabel(text, style: .primary, font: .systemFont(ofSize: 20, weight: .bold))
  712. label.translatesAutoresizingMaskIntoConstraints = false
  713. container.addSubview(label)
  714. NSLayoutConstraint.activate([
  715. container.heightAnchor.constraint(equalToConstant: 28),
  716. label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: iconSpacing),
  717. label.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  718. label.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  719. ])
  720. return container
  721. }
  722. private func makeQuickStartSection() -> NSView {
  723. let section = NSView()
  724. section.translatesAutoresizingMaskIntoConstraints = false
  725. let title = makeSectionTitle("Quick Start", icon: .sparkle)
  726. section.addSubview(title)
  727. let cardsStack = NSStackView()
  728. cardsStack.orientation = .horizontal
  729. cardsStack.spacing = AppTheme.quickStartSpacing
  730. cardsStack.distribution = .fillEqually
  731. cardsStack.translatesAutoresizingMaskIntoConstraints = false
  732. let cards: [QuickStartCardData] = [
  733. QuickStartCardData(
  734. title: "From Photos",
  735. subtitle: "Select photos from gallery",
  736. buttonTitle: "Open Gallery",
  737. accentColor: AppTheme.blue,
  738. gradientColors: [AppTheme.quickStartBlueLight, NSColor(red: 0.82, green: 0.90, blue: 1.0, alpha: 1)],
  739. iconKind: .photos
  740. ),
  741. QuickStartCardData(
  742. title: "From Files",
  743. subtitle: "Select Photos from file manager",
  744. buttonTitle: "Browse Files",
  745. accentColor: AppTheme.green,
  746. gradientColors: [AppTheme.greenLight, NSColor(red: 0.78, green: 0.94, blue: 0.84, alpha: 1)],
  747. iconKind: .files
  748. ),
  749. QuickStartCardData(
  750. title: "Import File",
  751. subtitle: "Add files from storage",
  752. buttonTitle: "Import Now",
  753. accentColor: AppTheme.orange,
  754. gradientColors: [AppTheme.orangeLight, NSColor(red: 1.0, green: 0.88, blue: 0.76, alpha: 1)],
  755. iconKind: .importFile
  756. ),
  757. ]
  758. let pickerKinds: [DocumentPickerKind] = [.photos, .files, .importFile]
  759. for (index, cardData) in cards.enumerated() {
  760. let kind = pickerKinds[index]
  761. let card = QuickStartCardView(data: cardData) { [weak self] in
  762. DocumentPickerService.present(kind, from: self?.view.window)
  763. }
  764. cardsStack.addArrangedSubview(card)
  765. }
  766. section.addSubview(cardsStack)
  767. NSLayoutConstraint.activate([
  768. title.leadingAnchor.constraint(equalTo: section.leadingAnchor),
  769. title.topAnchor.constraint(equalTo: section.topAnchor),
  770. cardsStack.leadingAnchor.constraint(equalTo: section.leadingAnchor),
  771. cardsStack.trailingAnchor.constraint(equalTo: section.trailingAnchor),
  772. cardsStack.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
  773. cardsStack.bottomAnchor.constraint(equalTo: section.bottomAnchor),
  774. ])
  775. return section
  776. }
  777. private func makeCreatePrintSection() -> NSView {
  778. let section = NSView()
  779. section.translatesAutoresizingMaskIntoConstraints = false
  780. let title = makeSectionTitle("Create & Print", icon: .grid)
  781. section.addSubview(title)
  782. let features = featureCards(
  783. FeatureCardData(title: "Scan File", subtitle: "Scan any document", iconKind: .scanFile),
  784. FeatureCardData(title: "Print Text", subtitle: "Type text and print", iconKind: .printText),
  785. FeatureCardData(title: "Print Contacts", subtitle: "Print your contacts", iconKind: .printContacts),
  786. FeatureCardData(title: "Draw & Print", subtitle: "Add drawings, text and more", iconKind: .drawPrint),
  787. FeatureCardData(title: "OCR File", subtitle: "Scan and print text from images", iconKind: .ocrFile)
  788. )
  789. let grid = makeFeatureGrid(features: features, columns: AppTheme.featureGridColumns)
  790. section.addSubview(grid)
  791. NSLayoutConstraint.activate([
  792. title.leadingAnchor.constraint(equalTo: section.leadingAnchor),
  793. title.topAnchor.constraint(equalTo: section.topAnchor),
  794. grid.leadingAnchor.constraint(equalTo: section.leadingAnchor),
  795. grid.trailingAnchor.constraint(equalTo: section.trailingAnchor),
  796. grid.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
  797. grid.bottomAnchor.constraint(equalTo: section.bottomAnchor),
  798. ])
  799. return section
  800. }
  801. private func featureCards(_ cards: FeatureCardData...) -> [FeatureCardData] {
  802. cards.map { card in
  803. var updated = card
  804. if card.iconKind == .printText {
  805. updated.onActivate = { [weak self] in
  806. PrintTextService.present(from: self?.view.window)
  807. }
  808. } else if card.iconKind == .printContacts {
  809. updated.onActivate = { [weak self] in
  810. PrintContactsService.present(from: self?.view.window)
  811. }
  812. } else if card.iconKind == .drawPrint {
  813. updated.onActivate = { [weak self] in
  814. DrawPrintService.present(from: self?.view.window)
  815. }
  816. } else if card.iconKind == .ocrFile {
  817. updated.onActivate = { [weak self] in
  818. OCRFileService.present(from: self?.view.window)
  819. }
  820. } else if card.iconKind == .scanFile {
  821. if card.title == "From Photos" {
  822. updated.onActivate = { [weak self] in
  823. DocumentPickerService.present(.photos, from: self?.view.window)
  824. }
  825. } else {
  826. updated.onActivate = { [weak self] in
  827. ScanFileService.present(from: self?.view.window)
  828. }
  829. }
  830. }
  831. return updated
  832. }
  833. }
  834. private func makeFeatureGrid(features: [FeatureCardData], columns: Int) -> NSView {
  835. let grid = NSStackView()
  836. grid.orientation = .vertical
  837. grid.spacing = AppTheme.featureGridSpacing
  838. grid.distribution = .fill
  839. grid.alignment = .leading
  840. grid.translatesAutoresizingMaskIntoConstraints = false
  841. var index = 0
  842. while index < features.count {
  843. let row = NSStackView()
  844. row.orientation = .horizontal
  845. row.spacing = AppTheme.featureGridSpacing
  846. row.distribution = .fillEqually
  847. row.translatesAutoresizingMaskIntoConstraints = false
  848. let rowCount = min(columns, features.count - index)
  849. for offset in 0..<rowCount {
  850. row.addArrangedSubview(FeatureCardView(data: features[index + offset]))
  851. }
  852. for _ in 0..<(columns - rowCount) {
  853. let spacer = NSView()
  854. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  855. spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  856. row.addArrangedSubview(spacer)
  857. }
  858. grid.addArrangedSubview(row)
  859. row.leadingAnchor.constraint(equalTo: grid.leadingAnchor).isActive = true
  860. row.trailingAnchor.constraint(equalTo: grid.trailingAnchor).isActive = true
  861. index += rowCount
  862. }
  863. return grid
  864. }
  865. }
  866. private final class FlippedDocumentView: NSView {
  867. override var isFlipped: Bool { true }
  868. }
  869. extension Notification.Name {
  870. static let showSettings = Notification.Name("showSettings")
  871. }