Geen omschrijving

LoadingView.swift 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. //
  2. // LoadingView.swift
  3. // App for Indeed
  4. //
  5. import Cocoa
  6. import QuartzCore
  7. final class LoadingView: NSView {
  8. private enum Theme {
  9. static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  10. static let brandBlueLight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
  11. static let pageBackgroundTop = NSColor(srgbRed: 252 / 255, green: 253 / 255, blue: 255 / 255, alpha: 1)
  12. static let pageBackgroundBottom = NSColor(srgbRed: 241 / 255, green: 245 / 255, blue: 252 / 255, alpha: 1)
  13. static let iconWell = NSColor(srgbRed: 239 / 255, green: 246 / 255, blue: 255 / 255, alpha: 1)
  14. static let headingBlue = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
  15. static let subtitleText = NSColor(srgbRed: 51 / 255, green: 65 / 255, blue: 85 / 255, alpha: 1)
  16. static let statusText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
  17. static let waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1)
  18. static let badgeFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.1)
  19. static let badgeText = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  20. }
  21. private let backgroundGradientHost = NSView()
  22. private let heroBackground = LoadingSplashBackgroundView()
  23. private let iconWell = NSView()
  24. private let logoView = IndeedLogoView(displayHeight: 40, variant: .compact)
  25. private let aiBadgeHost = NSView()
  26. private let aiBadgeLabel = NSTextField(labelWithString: "AI-POWERED")
  27. private let titleLabel = NSTextField(labelWithString: AppMarketingLinks.displayName)
  28. private let subtitleLabel = NSTextField(labelWithString: "Find your perfect job with the power of AI.")
  29. private let statusLabel = NSTextField(labelWithString: "Starting up…")
  30. private let progressBar = LoadingProgressBarView()
  31. private let thinkingIndicator = ChatThinkingIndicatorView(
  32. compact: false,
  33. accessibilityLabel: "Loading \(AppMarketingLinks.displayName)"
  34. )
  35. override init(frame frameRect: NSRect) {
  36. super.init(frame: frameRect)
  37. setUp()
  38. }
  39. @available(*, unavailable)
  40. required init?(coder: NSCoder) {
  41. fatalError("init(coder:) has not been implemented")
  42. }
  43. override func layout() {
  44. super.layout()
  45. backgroundGradientHost.layer?.sublayers?
  46. .filter { $0.name == "pageGradient" }
  47. .forEach { $0.frame = backgroundGradientHost.bounds }
  48. progressBar.layoutProgressFill(animated: false)
  49. }
  50. override func viewDidMoveToWindow() {
  51. super.viewDidMoveToWindow()
  52. if window != nil {
  53. startAnimating()
  54. } else {
  55. stopAnimating()
  56. }
  57. }
  58. func setStatus(_ message: String, progress: CGFloat) {
  59. statusLabel.stringValue = message
  60. progressBar.setProgress(progress, animated: true)
  61. setAccessibilityLabel("Loading \(AppMarketingLinks.displayName). \(message)")
  62. }
  63. func startAnimating() {
  64. thinkingIndicator.startAnimatingIfNeeded()
  65. progressBar.startShimmerIfNeeded()
  66. heroBackground.startAmbientAnimationIfNeeded()
  67. }
  68. func stopAnimating() {
  69. thinkingIndicator.stopAnimating()
  70. progressBar.stopShimmer()
  71. heroBackground.stopAmbientAnimation()
  72. }
  73. private func setUp() {
  74. wantsLayer = true
  75. backgroundGradientHost.translatesAutoresizingMaskIntoConstraints = false
  76. backgroundGradientHost.wantsLayer = true
  77. heroBackground.translatesAutoresizingMaskIntoConstraints = false
  78. heroBackground.waveTint = Theme.waveTint
  79. iconWell.translatesAutoresizingMaskIntoConstraints = false
  80. iconWell.wantsLayer = true
  81. iconWell.layer?.backgroundColor = Theme.iconWell.cgColor
  82. iconWell.layer?.cornerRadius = 28
  83. if #available(macOS 11.0, *) {
  84. iconWell.layer?.cornerCurve = .continuous
  85. }
  86. iconWell.layer?.masksToBounds = true
  87. iconWell.layer?.borderColor = NSColor.white.cgColor
  88. iconWell.layer?.borderWidth = 3
  89. iconWell.layer?.shadowColor = NSColor.black.cgColor
  90. iconWell.layer?.shadowOpacity = 0.08
  91. iconWell.layer?.shadowRadius = 16
  92. iconWell.layer?.shadowOffset = CGSize(width: 0, height: -4)
  93. logoView.translatesAutoresizingMaskIntoConstraints = false
  94. aiBadgeHost.translatesAutoresizingMaskIntoConstraints = false
  95. aiBadgeHost.wantsLayer = true
  96. aiBadgeHost.layer?.backgroundColor = Theme.badgeFill.cgColor
  97. aiBadgeHost.layer?.cornerRadius = 10
  98. if #available(macOS 11.0, *) {
  99. aiBadgeHost.layer?.cornerCurve = .continuous
  100. }
  101. aiBadgeLabel.translatesAutoresizingMaskIntoConstraints = false
  102. aiBadgeLabel.font = .systemFont(ofSize: 11, weight: .bold)
  103. aiBadgeLabel.textColor = Theme.badgeText
  104. aiBadgeLabel.alignment = .center
  105. aiBadgeLabel.isEditable = false
  106. aiBadgeLabel.isSelectable = false
  107. aiBadgeLabel.isBezeled = false
  108. aiBadgeLabel.drawsBackground = false
  109. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  110. titleLabel.font = .systemFont(ofSize: 44, weight: .bold)
  111. titleLabel.textColor = Theme.headingBlue
  112. titleLabel.alignment = .center
  113. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  114. subtitleLabel.font = .systemFont(ofSize: 18, weight: .regular)
  115. subtitleLabel.textColor = Theme.subtitleText
  116. subtitleLabel.alignment = .center
  117. subtitleLabel.maximumNumberOfLines = 2
  118. subtitleLabel.lineBreakMode = .byWordWrapping
  119. statusLabel.translatesAutoresizingMaskIntoConstraints = false
  120. statusLabel.font = .systemFont(ofSize: 15, weight: .medium)
  121. statusLabel.textColor = Theme.statusText
  122. statusLabel.alignment = .center
  123. statusLabel.maximumNumberOfLines = 2
  124. statusLabel.lineBreakMode = .byTruncatingTail
  125. progressBar.translatesAutoresizingMaskIntoConstraints = false
  126. thinkingIndicator.translatesAutoresizingMaskIntoConstraints = false
  127. iconWell.addSubview(logoView)
  128. aiBadgeHost.addSubview(aiBadgeLabel)
  129. let heroStack = NSStackView(views: [
  130. iconWell,
  131. aiBadgeHost,
  132. titleLabel,
  133. subtitleLabel,
  134. statusLabel,
  135. thinkingIndicator,
  136. progressBar
  137. ])
  138. heroStack.orientation = .vertical
  139. heroStack.alignment = .centerX
  140. heroStack.spacing = 18
  141. heroStack.setCustomSpacing(28, after: iconWell)
  142. heroStack.setCustomSpacing(12, after: aiBadgeHost)
  143. heroStack.setCustomSpacing(10, after: titleLabel)
  144. heroStack.setCustomSpacing(28, after: subtitleLabel)
  145. heroStack.setCustomSpacing(24, after: statusLabel)
  146. heroStack.setCustomSpacing(24, after: thinkingIndicator)
  147. heroStack.translatesAutoresizingMaskIntoConstraints = false
  148. addSubview(backgroundGradientHost)
  149. addSubview(heroBackground)
  150. addSubview(heroStack)
  151. NSLayoutConstraint.activate([
  152. backgroundGradientHost.leadingAnchor.constraint(equalTo: leadingAnchor),
  153. backgroundGradientHost.trailingAnchor.constraint(equalTo: trailingAnchor),
  154. backgroundGradientHost.topAnchor.constraint(equalTo: topAnchor),
  155. backgroundGradientHost.bottomAnchor.constraint(equalTo: bottomAnchor),
  156. heroBackground.leadingAnchor.constraint(equalTo: leadingAnchor),
  157. heroBackground.trailingAnchor.constraint(equalTo: trailingAnchor),
  158. heroBackground.topAnchor.constraint(equalTo: topAnchor),
  159. heroBackground.bottomAnchor.constraint(equalTo: bottomAnchor),
  160. iconWell.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
  161. iconWell.heightAnchor.constraint(equalToConstant: 80),
  162. logoView.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor),
  163. logoView.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor),
  164. logoView.leadingAnchor.constraint(greaterThanOrEqualTo: iconWell.leadingAnchor, constant: 24),
  165. logoView.trailingAnchor.constraint(lessThanOrEqualTo: iconWell.trailingAnchor, constant: -24),
  166. aiBadgeHost.heightAnchor.constraint(equalToConstant: 26),
  167. aiBadgeLabel.leadingAnchor.constraint(equalTo: aiBadgeHost.leadingAnchor, constant: 14),
  168. aiBadgeLabel.trailingAnchor.constraint(equalTo: aiBadgeHost.trailingAnchor, constant: -14),
  169. aiBadgeLabel.centerYAnchor.constraint(equalTo: aiBadgeHost.centerYAnchor),
  170. heroStack.centerXAnchor.constraint(equalTo: centerXAnchor),
  171. heroStack.centerYAnchor.constraint(equalTo: centerYAnchor),
  172. heroStack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48),
  173. heroStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -48),
  174. heroStack.widthAnchor.constraint(lessThanOrEqualToConstant: 720),
  175. subtitleLabel.widthAnchor.constraint(lessThanOrEqualTo: heroStack.widthAnchor),
  176. statusLabel.widthAnchor.constraint(lessThanOrEqualTo: heroStack.widthAnchor),
  177. progressBar.widthAnchor.constraint(equalTo: heroStack.widthAnchor, multiplier: 0.78),
  178. progressBar.widthAnchor.constraint(lessThanOrEqualToConstant: 560),
  179. progressBar.widthAnchor.constraint(greaterThanOrEqualToConstant: 280)
  180. ])
  181. installPageGradient()
  182. setAccessibilityElement(true)
  183. setAccessibilityRole(.group)
  184. setAccessibilityLabel("Loading \(AppMarketingLinks.displayName)")
  185. }
  186. private func installPageGradient() {
  187. let gradient = CAGradientLayer()
  188. gradient.name = "pageGradient"
  189. gradient.colors = [
  190. Theme.pageBackgroundTop.cgColor,
  191. Theme.pageBackgroundBottom.cgColor
  192. ]
  193. gradient.startPoint = CGPoint(x: 0.5, y: 1)
  194. gradient.endPoint = CGPoint(x: 0.5, y: 0)
  195. gradient.frame = backgroundGradientHost.bounds
  196. backgroundGradientHost.layer?.addSublayer(gradient)
  197. }
  198. }
  199. // MARK: - Loading progress bar
  200. private final class LoadingProgressBarView: NSView {
  201. private enum Theme {
  202. static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  203. static let brandBlueLight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
  204. static let trackFill = NSColor(srgbRed: 228 / 255, green: 233 / 255, blue: 242 / 255, alpha: 1)
  205. static let trackBorder = NSColor(srgbRed: 212 / 255, green: 218 / 255, blue: 230 / 255, alpha: 1)
  206. static let percentText = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  207. }
  208. private let track = NSView()
  209. private let fill = NSView()
  210. private let percentLabel = NSTextField(labelWithString: "0%")
  211. private var fillWidthConstraint: NSLayoutConstraint?
  212. private var fillGradientLayer: CAGradientLayer?
  213. private var shimmerLayer: CAGradientLayer?
  214. private var targetProgress: CGFloat = 0.04
  215. override var intrinsicContentSize: NSSize {
  216. NSSize(width: 420, height: 44)
  217. }
  218. override init(frame frameRect: NSRect) {
  219. super.init(frame: frameRect)
  220. setUp()
  221. }
  222. @available(*, unavailable)
  223. required init?(coder: NSCoder) {
  224. fatalError("init(coder:) has not been implemented")
  225. }
  226. override func layout() {
  227. super.layout()
  228. layoutProgressFill(animated: false)
  229. }
  230. func setProgress(_ progress: CGFloat, animated: Bool) {
  231. targetProgress = min(max(progress, 0), 1)
  232. let percent = Int((targetProgress * 100).rounded())
  233. percentLabel.stringValue = "\(percent)%"
  234. layoutProgressFill(animated: animated)
  235. }
  236. func layoutProgressFill(animated: Bool) {
  237. layoutSubtreeIfNeeded()
  238. let trackWidth = track.bounds.width
  239. guard trackWidth > 1 else { return }
  240. let width = max(trackWidth * max(targetProgress, 0.03), 12)
  241. fillWidthConstraint?.constant = width
  242. guard animated else {
  243. fillGradientLayer?.frame = fill.bounds
  244. shimmerLayer?.frame = fill.bounds
  245. return
  246. }
  247. NSAnimationContext.runAnimationGroup { context in
  248. context.duration = 0.45
  249. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  250. context.allowsImplicitAnimation = true
  251. fill.animator().layoutSubtreeIfNeeded()
  252. } completionHandler: { [weak self] in
  253. guard let self else { return }
  254. self.fillGradientLayer?.frame = self.fill.bounds
  255. self.shimmerLayer?.frame = self.fill.bounds
  256. }
  257. }
  258. func startShimmerIfNeeded() {
  259. guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
  260. let shimmerLayer else { return }
  261. shimmerLayer.removeAnimation(forKey: "shimmer")
  262. let move = CABasicAnimation(keyPath: "transform.translation.x")
  263. move.fromValue = -fill.bounds.width * 0.6
  264. move.toValue = fill.bounds.width * 0.6
  265. move.duration = 1.35
  266. move.repeatCount = .greatestFiniteMagnitude
  267. move.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  268. shimmerLayer.add(move, forKey: "shimmer")
  269. }
  270. func stopShimmer() {
  271. shimmerLayer?.removeAnimation(forKey: "shimmer")
  272. }
  273. private func setUp() {
  274. translatesAutoresizingMaskIntoConstraints = false
  275. track.translatesAutoresizingMaskIntoConstraints = false
  276. track.wantsLayer = true
  277. track.layer?.backgroundColor = Theme.trackFill.cgColor
  278. track.layer?.borderColor = Theme.trackBorder.cgColor
  279. track.layer?.borderWidth = 1
  280. track.layer?.cornerRadius = 7
  281. if #available(macOS 11.0, *) {
  282. track.layer?.cornerCurve = .continuous
  283. }
  284. track.layer?.masksToBounds = true
  285. track.setContentHuggingPriority(.defaultLow, for: .horizontal)
  286. track.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  287. fill.translatesAutoresizingMaskIntoConstraints = false
  288. fill.wantsLayer = true
  289. fill.layer?.cornerRadius = 7
  290. if #available(macOS 11.0, *) {
  291. fill.layer?.cornerCurve = .continuous
  292. }
  293. fill.layer?.masksToBounds = true
  294. let fillGradient = CAGradientLayer()
  295. fillGradient.colors = [
  296. Theme.brandBlueLight.cgColor,
  297. Theme.brandBlue.cgColor
  298. ]
  299. fillGradient.startPoint = CGPoint(x: 0, y: 0.5)
  300. fillGradient.endPoint = CGPoint(x: 1, y: 0.5)
  301. fillGradient.cornerRadius = 7
  302. fill.layer?.addSublayer(fillGradient)
  303. fillGradientLayer = fillGradient
  304. let shimmer = CAGradientLayer()
  305. shimmer.colors = [
  306. NSColor.white.withAlphaComponent(0).cgColor,
  307. NSColor.white.withAlphaComponent(0.45).cgColor,
  308. NSColor.white.withAlphaComponent(0).cgColor
  309. ]
  310. shimmer.startPoint = CGPoint(x: 0, y: 0.5)
  311. shimmer.endPoint = CGPoint(x: 1, y: 0.5)
  312. shimmer.locations = [0, 0.5, 1]
  313. fill.layer?.addSublayer(shimmer)
  314. shimmerLayer = shimmer
  315. percentLabel.translatesAutoresizingMaskIntoConstraints = false
  316. percentLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold)
  317. percentLabel.textColor = Theme.percentText
  318. percentLabel.alignment = .right
  319. percentLabel.setContentHuggingPriority(.required, for: .horizontal)
  320. track.addSubview(fill)
  321. let row = NSStackView(views: [track, percentLabel])
  322. row.orientation = .horizontal
  323. row.spacing = 14
  324. row.alignment = .centerY
  325. row.translatesAutoresizingMaskIntoConstraints = false
  326. addSubview(row)
  327. fillWidthConstraint = fill.widthAnchor.constraint(equalToConstant: 12)
  328. fillWidthConstraint?.isActive = true
  329. NSLayoutConstraint.activate([
  330. row.leadingAnchor.constraint(equalTo: leadingAnchor),
  331. row.trailingAnchor.constraint(equalTo: trailingAnchor),
  332. row.topAnchor.constraint(equalTo: topAnchor),
  333. row.bottomAnchor.constraint(equalTo: bottomAnchor),
  334. track.heightAnchor.constraint(equalToConstant: 14),
  335. fill.leadingAnchor.constraint(equalTo: track.leadingAnchor),
  336. fill.topAnchor.constraint(equalTo: track.topAnchor),
  337. fill.bottomAnchor.constraint(equalTo: track.bottomAnchor),
  338. percentLabel.widthAnchor.constraint(equalToConstant: 44)
  339. ])
  340. setAccessibilityElement(true)
  341. setAccessibilityRole(.progressIndicator)
  342. setAccessibilityLabel("Loading progress")
  343. }
  344. }
  345. // MARK: - Full-page decorative background (matches dashboard welcome hero)
  346. private final class LoadingSplashBackgroundView: NSView {
  347. var waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1)
  348. override var isFlipped: Bool { true }
  349. override func draw(_ dirtyRect: NSRect) {
  350. NSColor.clear.setFill()
  351. bounds.fill()
  352. guard bounds.width > 24, bounds.height > 24 else { return }
  353. drawSideWaves(in: bounds, isLeft: true)
  354. drawSideWaves(in: bounds, isLeft: false)
  355. drawAmbientSparkles(in: bounds)
  356. }
  357. func startAmbientAnimationIfNeeded() {
  358. guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return }
  359. let anim = CABasicAnimation(keyPath: "opacity")
  360. anim.fromValue = 0.92
  361. anim.toValue = 1
  362. anim.duration = 2.4
  363. anim.autoreverses = true
  364. anim.repeatCount = .greatestFiniteMagnitude
  365. layer?.add(anim, forKey: "ambientPulse")
  366. }
  367. func stopAmbientAnimation() {
  368. layer?.removeAnimation(forKey: "ambientPulse")
  369. }
  370. private func drawSideWaves(in bounds: NSRect, isLeft: Bool) {
  371. for i in 0..<12 {
  372. let path = NSBezierPath()
  373. path.lineWidth = 1.2
  374. path.lineCapStyle = .round
  375. let phase = CGFloat(i) * 0.88
  376. let base = CGFloat(i + 1) * 14 + 8
  377. var first = true
  378. for y in stride(from: CGFloat(0), through: bounds.height, by: 2.4) {
  379. let wobble = sin(y * 0.042 + phase) * (5 + CGFloat(i % 5))
  380. let x = isLeft ? (base + wobble) : (bounds.width - base - wobble)
  381. let point = NSPoint(x: x, y: y)
  382. if first {
  383. path.move(to: point)
  384. first = false
  385. } else {
  386. path.line(to: point)
  387. }
  388. }
  389. let fade = 1 - CGFloat(i) / 13
  390. waveTint.withAlphaComponent((0.1 + CGFloat(i % 3) * 0.024) * fade).setStroke()
  391. path.stroke()
  392. }
  393. }
  394. private func drawAmbientSparkles(in bounds: NSRect) {
  395. let accent = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1)
  396. let specs: [(CGFloat, CGFloat, CGFloat, CGFloat)] = [
  397. (0.06, 0.1, 12, 0.32),
  398. (0.94, 0.08, 14, 0.36),
  399. (0.12, 0.38, 7, 0.22),
  400. (0.88, 0.36, 8, 0.24),
  401. (0.5, 0.05, 10, 0.18),
  402. (0.22, 0.72, 6, 0.16),
  403. (0.78, 0.68, 9, 0.2),
  404. (0.04, 0.55, 5, 0.14),
  405. (0.96, 0.52, 6, 0.15)
  406. ]
  407. for (nx, ny, size, a) in specs {
  408. let center = NSPoint(x: bounds.width * nx, y: bounds.height * ny)
  409. fillFourPointStar(center: center, radius: size, color: accent.withAlphaComponent(a))
  410. }
  411. }
  412. private func fillFourPointStar(center: NSPoint, radius: CGFloat, color: NSColor) {
  413. let path = NSBezierPath()
  414. for i in 0..<4 {
  415. let angle = CGFloat(i) * .pi / 2 - .pi / 2
  416. let point = NSPoint(
  417. x: center.x + cos(angle) * radius,
  418. y: center.y + sin(angle) * radius
  419. )
  420. if i == 0 {
  421. path.move(to: point)
  422. } else {
  423. path.line(to: point)
  424. }
  425. }
  426. path.close()
  427. color.setFill()
  428. path.fill()
  429. }
  430. }