Kaynağa Gözat

Add a professional startup splash screen with theme-aware branding.

Show a full-window launch experience on app start, keep it visible until initialization is ready with a minimum 2-second display, and add smooth accessibility-aware dismissal with timeout fallback.

Made-with: Cursor
huzaifahayat12 1 ay önce
ebeveyn
işleme
13e7a1b90f

+ 128 - 0
meetings_app/LaunchSplashView.swift

@@ -0,0 +1,128 @@
1
+//
2
+//  LaunchSplashView.swift
3
+//  Assistant for Google Meet
4
+//
5
+
6
+import Cocoa
7
+
8
+final class LaunchSplashView: NSView {
9
+    struct Theme {
10
+        let background: NSColor
11
+        let cardBackground: NSColor
12
+        let cardBorder: NSColor
13
+        let titleText: NSColor
14
+        let subtitleText: NSColor
15
+        let accent: NSColor
16
+    }
17
+
18
+    private let cardContainer = NSVisualEffectView()
19
+    private let iconView = NSImageView()
20
+    private let titleLabel = NSTextField(labelWithString: "")
21
+    private let subtitleLabel = NSTextField(labelWithString: "Preparing your meetings workspace...")
22
+    private let spinner = NSProgressIndicator()
23
+    private let accentBar = NSView()
24
+
25
+    override init(frame frameRect: NSRect) {
26
+        super.init(frame: frameRect)
27
+        setupView()
28
+    }
29
+
30
+    required init?(coder: NSCoder) {
31
+        super.init(coder: coder)
32
+        setupView()
33
+    }
34
+
35
+    func configure(appName: String, appIcon: NSImage?, theme: Theme) {
36
+        titleLabel.stringValue = appName
37
+        iconView.image = appIcon
38
+        apply(theme: theme)
39
+        spinner.startAnimation(nil)
40
+    }
41
+
42
+    func apply(theme: Theme) {
43
+        wantsLayer = true
44
+        layer?.backgroundColor = theme.background.cgColor
45
+
46
+        cardContainer.wantsLayer = true
47
+        cardContainer.layer?.backgroundColor = theme.cardBackground.withAlphaComponent(0.85).cgColor
48
+        cardContainer.layer?.borderWidth = 1
49
+        cardContainer.layer?.borderColor = theme.cardBorder.withAlphaComponent(0.5).cgColor
50
+        cardContainer.layer?.cornerRadius = 20
51
+
52
+        titleLabel.textColor = theme.titleText
53
+        subtitleLabel.textColor = theme.subtitleText
54
+        accentBar.wantsLayer = true
55
+        accentBar.layer?.backgroundColor = theme.accent.cgColor
56
+    }
57
+
58
+    private func setupView() {
59
+        translatesAutoresizingMaskIntoConstraints = false
60
+
61
+        cardContainer.translatesAutoresizingMaskIntoConstraints = false
62
+        cardContainer.material = .hudWindow
63
+        cardContainer.blendingMode = .withinWindow
64
+        cardContainer.state = .active
65
+
66
+        iconView.translatesAutoresizingMaskIntoConstraints = false
67
+        iconView.imageScaling = .scaleProportionallyUpOrDown
68
+        iconView.wantsLayer = true
69
+        iconView.layer?.cornerRadius = 22
70
+        iconView.layer?.masksToBounds = true
71
+
72
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
73
+        titleLabel.alignment = .center
74
+        titleLabel.font = NSFont.systemFont(ofSize: 30, weight: .bold)
75
+        titleLabel.maximumNumberOfLines = 1
76
+        titleLabel.lineBreakMode = .byTruncatingTail
77
+
78
+        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
79
+        subtitleLabel.alignment = .center
80
+        subtitleLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
81
+
82
+        spinner.translatesAutoresizingMaskIntoConstraints = false
83
+        spinner.style = .spinning
84
+        spinner.controlSize = .large
85
+        spinner.isDisplayedWhenStopped = true
86
+        
87
+        accentBar.translatesAutoresizingMaskIntoConstraints = false
88
+        accentBar.wantsLayer = true
89
+        accentBar.layer?.cornerRadius = 2
90
+
91
+        addSubview(cardContainer)
92
+        cardContainer.addSubview(iconView)
93
+        cardContainer.addSubview(titleLabel)
94
+        cardContainer.addSubview(subtitleLabel)
95
+        cardContainer.addSubview(accentBar)
96
+        cardContainer.addSubview(spinner)
97
+
98
+        NSLayoutConstraint.activate([
99
+            cardContainer.centerXAnchor.constraint(equalTo: centerXAnchor),
100
+            cardContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
101
+            cardContainer.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 24),
102
+            trailingAnchor.constraint(greaterThanOrEqualTo: cardContainer.trailingAnchor, constant: 24),
103
+            cardContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
104
+
105
+            iconView.topAnchor.constraint(equalTo: cardContainer.topAnchor, constant: 34),
106
+            iconView.centerXAnchor.constraint(equalTo: cardContainer.centerXAnchor),
107
+            iconView.widthAnchor.constraint(equalToConstant: 120),
108
+            iconView.heightAnchor.constraint(equalTo: iconView.widthAnchor),
109
+
110
+            titleLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 22),
111
+            titleLabel.leadingAnchor.constraint(equalTo: cardContainer.leadingAnchor, constant: 24),
112
+            titleLabel.trailingAnchor.constraint(equalTo: cardContainer.trailingAnchor, constant: -24),
113
+
114
+            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
115
+            subtitleLabel.leadingAnchor.constraint(equalTo: cardContainer.leadingAnchor, constant: 24),
116
+            subtitleLabel.trailingAnchor.constraint(equalTo: cardContainer.trailingAnchor, constant: -24),
117
+
118
+            accentBar.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 18),
119
+            accentBar.centerXAnchor.constraint(equalTo: cardContainer.centerXAnchor),
120
+            accentBar.widthAnchor.constraint(equalToConstant: 120),
121
+            accentBar.heightAnchor.constraint(equalToConstant: 4),
122
+
123
+            spinner.topAnchor.constraint(equalTo: accentBar.bottomAnchor, constant: 14),
124
+            spinner.centerXAnchor.constraint(equalTo: cardContainer.centerXAnchor),
125
+            spinner.bottomAnchor.constraint(equalTo: cardContainer.bottomAnchor, constant: -32)
126
+        ])
127
+    }
128
+}

+ 104 - 0
meetings_app/ViewController.swift

@@ -245,6 +245,13 @@ final class ViewController: NSViewController {
245 245
     private let typography = Typography()
246 246
     private let launchContentSize = NSSize(width: 920, height: 690)
247 247
     private let launchMinContentSize = NSSize(width: 760, height: 600)
248
+    private let launchSplashMinimumVisibleDuration: TimeInterval = 2
249
+    private let launchSplashTimeout: TimeInterval = 12
250
+    private var launchSplashView: LaunchSplashView?
251
+    private var launchSplashTimeoutWorkItem: DispatchWorkItem?
252
+    private var launchSplashMinimumDelayWorkItem: DispatchWorkItem?
253
+    private var launchSplashShownAt: Date?
254
+    private var hasDismissedLaunchSplash = false
248 255
 
249 256
     private var mainContentHost: NSView?
250 257
     /// Pin constraints for the current page inside `mainContentHost`; deactivated before each swap so relayout never stacks duplicates.
@@ -431,6 +438,7 @@ final class ViewController: NSViewController {
431 438
         observeAppLifecycleForUsageTrackingIfNeeded()
432 439
         setupRootView()
433 440
         buildMainLayout()
441
+        showLaunchSplashIfNeeded()
434 442
         startStoreKit()
435 443
     }
436 444
 
@@ -438,6 +446,7 @@ final class ViewController: NSViewController {
438 446
         super.viewDidAppear()
439 447
         hasViewAppearedOnce = true
440 448
         presentLaunchPaywallIfNeeded()
449
+        dismissLaunchSplashIfReady()
441 450
         applyWindowTitle(for: selectedSidebarPage)
442 451
         guard let window = view.window else { return }
443 452
         configureMainWindowChrome(window)
@@ -473,6 +482,8 @@ final class ViewController: NSViewController {
473 482
     deinit {
474 483
         premiumUpgradeRatingPromptWorkItem?.cancel()
475 484
         endUsageTrackingSession()
485
+        launchSplashTimeoutWorkItem?.cancel()
486
+        launchSplashMinimumDelayWorkItem?.cancel()
476 487
         if hasObservedAppLifecycleForUsage {
477 488
             NotificationCenter.default.removeObserver(self)
478 489
         }
@@ -492,6 +503,97 @@ private extension ViewController {
492 503
         view.layer?.backgroundColor = palette.pageBackground.cgColor
493 504
     }
494 505
 
506
+    private func makeLaunchSplashTheme() -> LaunchSplashView.Theme {
507
+        let borderAlpha: CGFloat = darkModeEnabled ? 0.9 : 0.45
508
+        let cardBackground = darkModeEnabled ? palette.sectionCard : palette.tabBarBackground
509
+        let cardBorder = darkModeEnabled ? palette.inputBorder : palette.separator
510
+        return LaunchSplashView.Theme(
511
+            background: palette.pageBackground,
512
+            cardBackground: cardBackground,
513
+            cardBorder: cardBorder.withAlphaComponent(borderAlpha),
514
+            titleText: palette.textPrimary,
515
+            subtitleText: palette.textSecondary,
516
+            accent: palette.primaryBlue
517
+        )
518
+    }
519
+
520
+    private func showLaunchSplashIfNeeded() {
521
+        guard launchSplashView == nil else { return }
522
+        let splash = LaunchSplashView(frame: .zero)
523
+        splash.alphaValue = 1
524
+        let displayName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)?
525
+            .trimmingCharacters(in: .whitespacesAndNewlines)
526
+        let fallbackName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
527
+        let resolvedName = (displayName?.isEmpty == false ? displayName : fallbackName) ?? "Assistant for Google Meet"
528
+        splash.configure(appName: resolvedName, appIcon: NSApp.applicationIconImage, theme: makeLaunchSplashTheme())
529
+
530
+        view.addSubview(splash)
531
+        NSLayoutConstraint.activate([
532
+            splash.leadingAnchor.constraint(equalTo: view.leadingAnchor),
533
+            splash.trailingAnchor.constraint(equalTo: view.trailingAnchor),
534
+            splash.topAnchor.constraint(equalTo: view.topAnchor),
535
+            splash.bottomAnchor.constraint(equalTo: view.bottomAnchor)
536
+        ])
537
+        view.layoutSubtreeIfNeeded()
538
+        launchSplashView = splash
539
+        launchSplashShownAt = Date()
540
+
541
+        launchSplashTimeoutWorkItem?.cancel()
542
+        let timeoutWorkItem = DispatchWorkItem { [weak self] in
543
+            self?.dismissLaunchSplash(force: true)
544
+        }
545
+        launchSplashTimeoutWorkItem = timeoutWorkItem
546
+        DispatchQueue.main.asyncAfter(deadline: .now() + launchSplashTimeout, execute: timeoutWorkItem)
547
+    }
548
+
549
+    private func dismissLaunchSplashIfReady() {
550
+        guard hasCompletedInitialStoreKitSync else { return }
551
+        guard !hasDismissedLaunchSplash else { return }
552
+        if let shownAt = launchSplashShownAt {
553
+            let elapsed = Date().timeIntervalSince(shownAt)
554
+            let remaining = launchSplashMinimumVisibleDuration - elapsed
555
+            if remaining > 0 {
556
+                launchSplashMinimumDelayWorkItem?.cancel()
557
+                let minDelayWorkItem = DispatchWorkItem { [weak self] in
558
+                    self?.dismissLaunchSplash(force: false)
559
+                }
560
+                launchSplashMinimumDelayWorkItem = minDelayWorkItem
561
+                DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: minDelayWorkItem)
562
+                return
563
+            }
564
+        }
565
+        dismissLaunchSplash(force: false)
566
+    }
567
+
568
+    private func dismissLaunchSplash(force: Bool) {
569
+        guard !hasDismissedLaunchSplash else { return }
570
+        guard let splash = launchSplashView else { return }
571
+        if !force && !hasCompletedInitialStoreKitSync { return }
572
+
573
+        hasDismissedLaunchSplash = true
574
+        launchSplashTimeoutWorkItem?.cancel()
575
+        launchSplashTimeoutWorkItem = nil
576
+        launchSplashMinimumDelayWorkItem?.cancel()
577
+        launchSplashMinimumDelayWorkItem = nil
578
+
579
+        let removeSplash: () -> Void = { [weak self] in
580
+            splash.removeFromSuperview()
581
+            self?.launchSplashView = nil
582
+            self?.launchSplashShownAt = nil
583
+        }
584
+
585
+        if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
586
+            removeSplash()
587
+            return
588
+        }
589
+
590
+        NSAnimationContext.runAnimationGroup({ context in
591
+            context.duration = 0.24
592
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
593
+            splash.animator().alphaValue = 0
594
+        }, completionHandler: removeSplash)
595
+    }
596
+
495 597
     func systemPrefersDarkMode() -> Bool {
496 598
         // Use the system-wide appearance setting (not app/window effective appearance).
497 599
         // When the key is missing, macOS is in Light mode.
@@ -861,6 +963,7 @@ private extension ViewController {
861 963
         NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
862 964
         view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
863 965
         palette = Palette(isDarkMode: enabled)
966
+        launchSplashView?.apply(theme: makeLaunchSplashTheme())
864 967
         reloadTheme()
865 968
     }
866 969
 
@@ -1282,6 +1385,7 @@ private extension ViewController {
1282 1385
             self.hasCompletedInitialStoreKitSync = true
1283 1386
             self.refreshPaywallStoreUI()
1284 1387
             self.presentLaunchPaywallIfNeeded()
1388
+            self.dismissLaunchSplashIfReady()
1285 1389
         }
1286 1390
     }
1287 1391