ViewController.swift 37 KB

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