// // LoadingView.swift // App for Indeed // import Cocoa import QuartzCore final class LoadingView: NSView { private enum Theme { static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) static let brandBlueLight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1) static let pageBackgroundTop = NSColor(srgbRed: 252 / 255, green: 253 / 255, blue: 255 / 255, alpha: 1) static let pageBackgroundBottom = NSColor(srgbRed: 241 / 255, green: 245 / 255, blue: 252 / 255, alpha: 1) static let iconWell = NSColor(srgbRed: 239 / 255, green: 246 / 255, blue: 255 / 255, alpha: 1) static let headingBlue = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1) static let subtitleText = NSColor(srgbRed: 51 / 255, green: 65 / 255, blue: 85 / 255, alpha: 1) static let statusText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1) static let waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1) static let badgeFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.1) static let badgeText = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) } private let backgroundGradientHost = NSView() private let heroBackground = LoadingSplashBackgroundView() private let iconWell = NSView() private let iconView = NSImageView() private let aiBadgeHost = NSView() private let aiBadgeLabel = NSTextField(labelWithString: "AI-POWERED") private let titleLabel = NSTextField(labelWithString: AppMarketingLinks.displayName) private let subtitleLabel = NSTextField(labelWithString: "Find your perfect job with the power of AI.") private let statusLabel = NSTextField(labelWithString: "Starting up…") private let progressBar = LoadingProgressBarView() private let thinkingIndicator = ChatThinkingIndicatorView( compact: false, accessibilityLabel: "Loading \(AppMarketingLinks.displayName)" ) override init(frame frameRect: NSRect) { super.init(frame: frameRect) setUp() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layout() { super.layout() backgroundGradientHost.layer?.sublayers? .filter { $0.name == "pageGradient" } .forEach { $0.frame = backgroundGradientHost.bounds } progressBar.layoutProgressFill(animated: false) } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window != nil { startAnimating() } else { stopAnimating() } } func setStatus(_ message: String, progress: CGFloat) { statusLabel.stringValue = message progressBar.setProgress(progress, animated: true) setAccessibilityLabel("Loading \(AppMarketingLinks.displayName). \(message)") } func startAnimating() { thinkingIndicator.startAnimatingIfNeeded() progressBar.startShimmerIfNeeded() heroBackground.startAmbientAnimationIfNeeded() } func stopAnimating() { thinkingIndicator.stopAnimating() progressBar.stopShimmer() heroBackground.stopAmbientAnimation() } private func setUp() { wantsLayer = true backgroundGradientHost.translatesAutoresizingMaskIntoConstraints = false backgroundGradientHost.wantsLayer = true heroBackground.translatesAutoresizingMaskIntoConstraints = false heroBackground.waveTint = Theme.waveTint iconWell.translatesAutoresizingMaskIntoConstraints = false iconWell.wantsLayer = true iconWell.layer?.backgroundColor = Theme.iconWell.cgColor iconWell.layer?.cornerRadius = 28 if #available(macOS 11.0, *) { iconWell.layer?.cornerCurve = .continuous } iconWell.layer?.masksToBounds = true iconWell.layer?.borderColor = NSColor.white.cgColor iconWell.layer?.borderWidth = 3 iconWell.layer?.shadowColor = NSColor.black.cgColor iconWell.layer?.shadowOpacity = 0.08 iconWell.layer?.shadowRadius = 16 iconWell.layer?.shadowOffset = CGSize(width: 0, height: -4) iconView.translatesAutoresizingMaskIntoConstraints = false iconView.imageScaling = .scaleProportionallyUpOrDown iconView.image = NSApp.applicationIconImage aiBadgeHost.translatesAutoresizingMaskIntoConstraints = false aiBadgeHost.wantsLayer = true aiBadgeHost.layer?.backgroundColor = Theme.badgeFill.cgColor aiBadgeHost.layer?.cornerRadius = 10 if #available(macOS 11.0, *) { aiBadgeHost.layer?.cornerCurve = .continuous } aiBadgeLabel.translatesAutoresizingMaskIntoConstraints = false aiBadgeLabel.font = .systemFont(ofSize: 11, weight: .bold) aiBadgeLabel.textColor = Theme.badgeText aiBadgeLabel.alignment = .center aiBadgeLabel.isEditable = false aiBadgeLabel.isSelectable = false aiBadgeLabel.isBezeled = false aiBadgeLabel.drawsBackground = false titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = .systemFont(ofSize: 44, weight: .bold) titleLabel.textColor = Theme.headingBlue titleLabel.alignment = .center subtitleLabel.translatesAutoresizingMaskIntoConstraints = false subtitleLabel.font = .systemFont(ofSize: 18, weight: .regular) subtitleLabel.textColor = Theme.subtitleText subtitleLabel.alignment = .center subtitleLabel.maximumNumberOfLines = 2 subtitleLabel.lineBreakMode = .byWordWrapping statusLabel.translatesAutoresizingMaskIntoConstraints = false statusLabel.font = .systemFont(ofSize: 15, weight: .medium) statusLabel.textColor = Theme.statusText statusLabel.alignment = .center statusLabel.maximumNumberOfLines = 2 statusLabel.lineBreakMode = .byTruncatingTail progressBar.translatesAutoresizingMaskIntoConstraints = false thinkingIndicator.translatesAutoresizingMaskIntoConstraints = false iconWell.addSubview(iconView) aiBadgeHost.addSubview(aiBadgeLabel) let heroStack = NSStackView(views: [ iconWell, aiBadgeHost, titleLabel, subtitleLabel, statusLabel, thinkingIndicator, progressBar ]) heroStack.orientation = .vertical heroStack.alignment = .centerX heroStack.spacing = 18 heroStack.setCustomSpacing(28, after: iconWell) heroStack.setCustomSpacing(12, after: aiBadgeHost) heroStack.setCustomSpacing(10, after: titleLabel) heroStack.setCustomSpacing(28, after: subtitleLabel) heroStack.setCustomSpacing(24, after: statusLabel) heroStack.setCustomSpacing(24, after: thinkingIndicator) heroStack.translatesAutoresizingMaskIntoConstraints = false addSubview(backgroundGradientHost) addSubview(heroBackground) addSubview(heroStack) NSLayoutConstraint.activate([ backgroundGradientHost.leadingAnchor.constraint(equalTo: leadingAnchor), backgroundGradientHost.trailingAnchor.constraint(equalTo: trailingAnchor), backgroundGradientHost.topAnchor.constraint(equalTo: topAnchor), backgroundGradientHost.bottomAnchor.constraint(equalTo: bottomAnchor), heroBackground.leadingAnchor.constraint(equalTo: leadingAnchor), heroBackground.trailingAnchor.constraint(equalTo: trailingAnchor), heroBackground.topAnchor.constraint(equalTo: topAnchor), heroBackground.bottomAnchor.constraint(equalTo: bottomAnchor), iconWell.widthAnchor.constraint(equalToConstant: 128), iconWell.heightAnchor.constraint(equalToConstant: 128), iconView.centerXAnchor.constraint(equalTo: iconWell.centerXAnchor), iconView.centerYAnchor.constraint(equalTo: iconWell.centerYAnchor), iconView.widthAnchor.constraint(equalToConstant: 88), iconView.heightAnchor.constraint(equalToConstant: 88), aiBadgeHost.heightAnchor.constraint(equalToConstant: 26), aiBadgeLabel.leadingAnchor.constraint(equalTo: aiBadgeHost.leadingAnchor, constant: 14), aiBadgeLabel.trailingAnchor.constraint(equalTo: aiBadgeHost.trailingAnchor, constant: -14), aiBadgeLabel.centerYAnchor.constraint(equalTo: aiBadgeHost.centerYAnchor), heroStack.centerXAnchor.constraint(equalTo: centerXAnchor), heroStack.centerYAnchor.constraint(equalTo: centerYAnchor), heroStack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48), heroStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -48), heroStack.widthAnchor.constraint(lessThanOrEqualToConstant: 720), subtitleLabel.widthAnchor.constraint(lessThanOrEqualTo: heroStack.widthAnchor), statusLabel.widthAnchor.constraint(lessThanOrEqualTo: heroStack.widthAnchor), progressBar.widthAnchor.constraint(equalTo: heroStack.widthAnchor, multiplier: 0.78), progressBar.widthAnchor.constraint(lessThanOrEqualToConstant: 560), progressBar.widthAnchor.constraint(greaterThanOrEqualToConstant: 280) ]) installPageGradient() setAccessibilityElement(true) setAccessibilityRole(.group) setAccessibilityLabel("Loading \(AppMarketingLinks.displayName)") } private func installPageGradient() { let gradient = CAGradientLayer() gradient.name = "pageGradient" gradient.colors = [ Theme.pageBackgroundTop.cgColor, Theme.pageBackgroundBottom.cgColor ] gradient.startPoint = CGPoint(x: 0.5, y: 1) gradient.endPoint = CGPoint(x: 0.5, y: 0) gradient.frame = backgroundGradientHost.bounds backgroundGradientHost.layer?.addSublayer(gradient) } } // MARK: - Loading progress bar private final class LoadingProgressBarView: NSView { private enum Theme { static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) static let brandBlueLight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1) static let trackFill = NSColor(srgbRed: 228 / 255, green: 233 / 255, blue: 242 / 255, alpha: 1) static let trackBorder = NSColor(srgbRed: 212 / 255, green: 218 / 255, blue: 230 / 255, alpha: 1) static let percentText = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) } private let track = NSView() private let fill = NSView() private let percentLabel = NSTextField(labelWithString: "0%") private var fillWidthConstraint: NSLayoutConstraint? private var fillGradientLayer: CAGradientLayer? private var shimmerLayer: CAGradientLayer? private var targetProgress: CGFloat = 0.04 override var intrinsicContentSize: NSSize { NSSize(width: 420, height: 44) } override init(frame frameRect: NSRect) { super.init(frame: frameRect) setUp() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layout() { super.layout() layoutProgressFill(animated: false) } func setProgress(_ progress: CGFloat, animated: Bool) { targetProgress = min(max(progress, 0), 1) let percent = Int((targetProgress * 100).rounded()) percentLabel.stringValue = "\(percent)%" layoutProgressFill(animated: animated) } func layoutProgressFill(animated: Bool) { layoutSubtreeIfNeeded() let trackWidth = track.bounds.width guard trackWidth > 1 else { return } let width = max(trackWidth * max(targetProgress, 0.03), 12) fillWidthConstraint?.constant = width guard animated else { fillGradientLayer?.frame = fill.bounds shimmerLayer?.frame = fill.bounds return } NSAnimationContext.runAnimationGroup { context in context.duration = 0.45 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) context.allowsImplicitAnimation = true fill.animator().layoutSubtreeIfNeeded() } completionHandler: { [weak self] in guard let self else { return } self.fillGradientLayer?.frame = self.fill.bounds self.shimmerLayer?.frame = self.fill.bounds } } func startShimmerIfNeeded() { guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, let shimmerLayer else { return } shimmerLayer.removeAnimation(forKey: "shimmer") let move = CABasicAnimation(keyPath: "transform.translation.x") move.fromValue = -fill.bounds.width * 0.6 move.toValue = fill.bounds.width * 0.6 move.duration = 1.35 move.repeatCount = .greatestFiniteMagnitude move.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) shimmerLayer.add(move, forKey: "shimmer") } func stopShimmer() { shimmerLayer?.removeAnimation(forKey: "shimmer") } private func setUp() { translatesAutoresizingMaskIntoConstraints = false track.translatesAutoresizingMaskIntoConstraints = false track.wantsLayer = true track.layer?.backgroundColor = Theme.trackFill.cgColor track.layer?.borderColor = Theme.trackBorder.cgColor track.layer?.borderWidth = 1 track.layer?.cornerRadius = 7 if #available(macOS 11.0, *) { track.layer?.cornerCurve = .continuous } track.layer?.masksToBounds = true track.setContentHuggingPriority(.defaultLow, for: .horizontal) track.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) fill.translatesAutoresizingMaskIntoConstraints = false fill.wantsLayer = true fill.layer?.cornerRadius = 7 if #available(macOS 11.0, *) { fill.layer?.cornerCurve = .continuous } fill.layer?.masksToBounds = true let fillGradient = CAGradientLayer() fillGradient.colors = [ Theme.brandBlueLight.cgColor, Theme.brandBlue.cgColor ] fillGradient.startPoint = CGPoint(x: 0, y: 0.5) fillGradient.endPoint = CGPoint(x: 1, y: 0.5) fillGradient.cornerRadius = 7 fill.layer?.addSublayer(fillGradient) fillGradientLayer = fillGradient let shimmer = CAGradientLayer() shimmer.colors = [ NSColor.white.withAlphaComponent(0).cgColor, NSColor.white.withAlphaComponent(0.45).cgColor, NSColor.white.withAlphaComponent(0).cgColor ] shimmer.startPoint = CGPoint(x: 0, y: 0.5) shimmer.endPoint = CGPoint(x: 1, y: 0.5) shimmer.locations = [0, 0.5, 1] fill.layer?.addSublayer(shimmer) shimmerLayer = shimmer percentLabel.translatesAutoresizingMaskIntoConstraints = false percentLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold) percentLabel.textColor = Theme.percentText percentLabel.alignment = .right percentLabel.setContentHuggingPriority(.required, for: .horizontal) track.addSubview(fill) let row = NSStackView(views: [track, percentLabel]) row.orientation = .horizontal row.spacing = 14 row.alignment = .centerY row.translatesAutoresizingMaskIntoConstraints = false addSubview(row) fillWidthConstraint = fill.widthAnchor.constraint(equalToConstant: 12) fillWidthConstraint?.isActive = true NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: leadingAnchor), row.trailingAnchor.constraint(equalTo: trailingAnchor), row.topAnchor.constraint(equalTo: topAnchor), row.bottomAnchor.constraint(equalTo: bottomAnchor), track.heightAnchor.constraint(equalToConstant: 14), fill.leadingAnchor.constraint(equalTo: track.leadingAnchor), fill.topAnchor.constraint(equalTo: track.topAnchor), fill.bottomAnchor.constraint(equalTo: track.bottomAnchor), percentLabel.widthAnchor.constraint(equalToConstant: 44) ]) setAccessibilityElement(true) setAccessibilityRole(.progressIndicator) setAccessibilityLabel("Loading progress") } } // MARK: - Full-page decorative background (matches dashboard welcome hero) private final class LoadingSplashBackgroundView: NSView { var waveTint = NSColor(srgbRed: 186 / 255, green: 210 / 255, blue: 253 / 255, alpha: 1) override var isFlipped: Bool { true } override func draw(_ dirtyRect: NSRect) { NSColor.clear.setFill() bounds.fill() guard bounds.width > 24, bounds.height > 24 else { return } drawSideWaves(in: bounds, isLeft: true) drawSideWaves(in: bounds, isLeft: false) drawAmbientSparkles(in: bounds) } func startAmbientAnimationIfNeeded() { guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return } let anim = CABasicAnimation(keyPath: "opacity") anim.fromValue = 0.92 anim.toValue = 1 anim.duration = 2.4 anim.autoreverses = true anim.repeatCount = .greatestFiniteMagnitude layer?.add(anim, forKey: "ambientPulse") } func stopAmbientAnimation() { layer?.removeAnimation(forKey: "ambientPulse") } private func drawSideWaves(in bounds: NSRect, isLeft: Bool) { for i in 0..<12 { let path = NSBezierPath() path.lineWidth = 1.2 path.lineCapStyle = .round let phase = CGFloat(i) * 0.88 let base = CGFloat(i + 1) * 14 + 8 var first = true for y in stride(from: CGFloat(0), through: bounds.height, by: 2.4) { let wobble = sin(y * 0.042 + phase) * (5 + CGFloat(i % 5)) let x = isLeft ? (base + wobble) : (bounds.width - base - wobble) let point = NSPoint(x: x, y: y) if first { path.move(to: point) first = false } else { path.line(to: point) } } let fade = 1 - CGFloat(i) / 13 waveTint.withAlphaComponent((0.1 + CGFloat(i % 3) * 0.024) * fade).setStroke() path.stroke() } } private func drawAmbientSparkles(in bounds: NSRect) { let accent = NSColor(srgbRed: 0, green: 82 / 255, blue: 204 / 255, alpha: 1) let specs: [(CGFloat, CGFloat, CGFloat, CGFloat)] = [ (0.06, 0.1, 12, 0.32), (0.94, 0.08, 14, 0.36), (0.12, 0.38, 7, 0.22), (0.88, 0.36, 8, 0.24), (0.5, 0.05, 10, 0.18), (0.22, 0.72, 6, 0.16), (0.78, 0.68, 9, 0.2), (0.04, 0.55, 5, 0.14), (0.96, 0.52, 6, 0.15) ] for (nx, ny, size, a) in specs { let center = NSPoint(x: bounds.width * nx, y: bounds.height * ny) fillFourPointStar(center: center, radius: size, color: accent.withAlphaComponent(a)) } } private func fillFourPointStar(center: NSPoint, radius: CGFloat, color: NSColor) { let path = NSBezierPath() for i in 0..<4 { let angle = CGFloat(i) * .pi / 2 - .pi / 2 let point = NSPoint( x: center.x + cos(angle) * radius, y: center.y + sin(angle) * radius ) if i == 0 { path.move(to: point) } else { path.line(to: point) } } path.close() color.setFill() path.fill() } }