ViewController.swift 33 KB

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