Brak opisu

LoadingView.swift 20KB

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