ソースを参照

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 3 ヶ月 前
コミット
13e7a1b90f
2 ファイル変更232 行追加0 行削除
  1. 128 0
      meetings_app/LaunchSplashView.swift
  2. 104 0
      meetings_app/ViewController.swift

+ 128 - 0
meetings_app/LaunchSplashView.swift

@@ -0,0 +1,128 @@
+//
+//  LaunchSplashView.swift
+//  Assistant for Google Meet
+//
+
+import Cocoa
+
+final class LaunchSplashView: NSView {
+    struct Theme {
+        let background: NSColor
+        let cardBackground: NSColor
+        let cardBorder: NSColor
+        let titleText: NSColor
+        let subtitleText: NSColor
+        let accent: NSColor
+    }
+
+    private let cardContainer = NSVisualEffectView()
+    private let iconView = NSImageView()
+    private let titleLabel = NSTextField(labelWithString: "")
+    private let subtitleLabel = NSTextField(labelWithString: "Preparing your meetings workspace...")
+    private let spinner = NSProgressIndicator()
+    private let accentBar = NSView()
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        setupView()
+    }
+
+    required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        setupView()
+    }
+
+    func configure(appName: String, appIcon: NSImage?, theme: Theme) {
+        titleLabel.stringValue = appName
+        iconView.image = appIcon
+        apply(theme: theme)
+        spinner.startAnimation(nil)
+    }
+
+    func apply(theme: Theme) {
+        wantsLayer = true
+        layer?.backgroundColor = theme.background.cgColor
+
+        cardContainer.wantsLayer = true
+        cardContainer.layer?.backgroundColor = theme.cardBackground.withAlphaComponent(0.85).cgColor
+        cardContainer.layer?.borderWidth = 1
+        cardContainer.layer?.borderColor = theme.cardBorder.withAlphaComponent(0.5).cgColor
+        cardContainer.layer?.cornerRadius = 20
+
+        titleLabel.textColor = theme.titleText
+        subtitleLabel.textColor = theme.subtitleText
+        accentBar.wantsLayer = true
+        accentBar.layer?.backgroundColor = theme.accent.cgColor
+    }
+
+    private func setupView() {
+        translatesAutoresizingMaskIntoConstraints = false
+
+        cardContainer.translatesAutoresizingMaskIntoConstraints = false
+        cardContainer.material = .hudWindow
+        cardContainer.blendingMode = .withinWindow
+        cardContainer.state = .active
+
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+        iconView.imageScaling = .scaleProportionallyUpOrDown
+        iconView.wantsLayer = true
+        iconView.layer?.cornerRadius = 22
+        iconView.layer?.masksToBounds = true
+
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+        titleLabel.alignment = .center
+        titleLabel.font = NSFont.systemFont(ofSize: 30, weight: .bold)
+        titleLabel.maximumNumberOfLines = 1
+        titleLabel.lineBreakMode = .byTruncatingTail
+
+        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
+        subtitleLabel.alignment = .center
+        subtitleLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
+
+        spinner.translatesAutoresizingMaskIntoConstraints = false
+        spinner.style = .spinning
+        spinner.controlSize = .large
+        spinner.isDisplayedWhenStopped = true
+        
+        accentBar.translatesAutoresizingMaskIntoConstraints = false
+        accentBar.wantsLayer = true
+        accentBar.layer?.cornerRadius = 2
+
+        addSubview(cardContainer)
+        cardContainer.addSubview(iconView)
+        cardContainer.addSubview(titleLabel)
+        cardContainer.addSubview(subtitleLabel)
+        cardContainer.addSubview(accentBar)
+        cardContainer.addSubview(spinner)
+
+        NSLayoutConstraint.activate([
+            cardContainer.centerXAnchor.constraint(equalTo: centerXAnchor),
+            cardContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
+            cardContainer.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 24),
+            trailingAnchor.constraint(greaterThanOrEqualTo: cardContainer.trailingAnchor, constant: 24),
+            cardContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
+
+            iconView.topAnchor.constraint(equalTo: cardContainer.topAnchor, constant: 34),
+            iconView.centerXAnchor.constraint(equalTo: cardContainer.centerXAnchor),
+            iconView.widthAnchor.constraint(equalToConstant: 120),
+            iconView.heightAnchor.constraint(equalTo: iconView.widthAnchor),
+
+            titleLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 22),
+            titleLabel.leadingAnchor.constraint(equalTo: cardContainer.leadingAnchor, constant: 24),
+            titleLabel.trailingAnchor.constraint(equalTo: cardContainer.trailingAnchor, constant: -24),
+
+            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
+            subtitleLabel.leadingAnchor.constraint(equalTo: cardContainer.leadingAnchor, constant: 24),
+            subtitleLabel.trailingAnchor.constraint(equalTo: cardContainer.trailingAnchor, constant: -24),
+
+            accentBar.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 18),
+            accentBar.centerXAnchor.constraint(equalTo: cardContainer.centerXAnchor),
+            accentBar.widthAnchor.constraint(equalToConstant: 120),
+            accentBar.heightAnchor.constraint(equalToConstant: 4),
+
+            spinner.topAnchor.constraint(equalTo: accentBar.bottomAnchor, constant: 14),
+            spinner.centerXAnchor.constraint(equalTo: cardContainer.centerXAnchor),
+            spinner.bottomAnchor.constraint(equalTo: cardContainer.bottomAnchor, constant: -32)
+        ])
+    }
+}

+ 104 - 0
meetings_app/ViewController.swift

@@ -245,6 +245,13 @@ final class ViewController: NSViewController {
     private let typography = Typography()
     private let launchContentSize = NSSize(width: 920, height: 690)
     private let launchMinContentSize = NSSize(width: 760, height: 600)
+    private let launchSplashMinimumVisibleDuration: TimeInterval = 2
+    private let launchSplashTimeout: TimeInterval = 12
+    private var launchSplashView: LaunchSplashView?
+    private var launchSplashTimeoutWorkItem: DispatchWorkItem?
+    private var launchSplashMinimumDelayWorkItem: DispatchWorkItem?
+    private var launchSplashShownAt: Date?
+    private var hasDismissedLaunchSplash = false
 
     private var mainContentHost: NSView?
     /// 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 {
         observeAppLifecycleForUsageTrackingIfNeeded()
         setupRootView()
         buildMainLayout()
+        showLaunchSplashIfNeeded()
         startStoreKit()
     }
 
@@ -438,6 +446,7 @@ final class ViewController: NSViewController {
         super.viewDidAppear()
         hasViewAppearedOnce = true
         presentLaunchPaywallIfNeeded()
+        dismissLaunchSplashIfReady()
         applyWindowTitle(for: selectedSidebarPage)
         guard let window = view.window else { return }
         configureMainWindowChrome(window)
@@ -473,6 +482,8 @@ final class ViewController: NSViewController {
     deinit {
         premiumUpgradeRatingPromptWorkItem?.cancel()
         endUsageTrackingSession()
+        launchSplashTimeoutWorkItem?.cancel()
+        launchSplashMinimumDelayWorkItem?.cancel()
         if hasObservedAppLifecycleForUsage {
             NotificationCenter.default.removeObserver(self)
         }
@@ -492,6 +503,97 @@ private extension ViewController {
         view.layer?.backgroundColor = palette.pageBackground.cgColor
     }
 
+    private func makeLaunchSplashTheme() -> LaunchSplashView.Theme {
+        let borderAlpha: CGFloat = darkModeEnabled ? 0.9 : 0.45
+        let cardBackground = darkModeEnabled ? palette.sectionCard : palette.tabBarBackground
+        let cardBorder = darkModeEnabled ? palette.inputBorder : palette.separator
+        return LaunchSplashView.Theme(
+            background: palette.pageBackground,
+            cardBackground: cardBackground,
+            cardBorder: cardBorder.withAlphaComponent(borderAlpha),
+            titleText: palette.textPrimary,
+            subtitleText: palette.textSecondary,
+            accent: palette.primaryBlue
+        )
+    }
+
+    private func showLaunchSplashIfNeeded() {
+        guard launchSplashView == nil else { return }
+        let splash = LaunchSplashView(frame: .zero)
+        splash.alphaValue = 1
+        let displayName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        let fallbackName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
+        let resolvedName = (displayName?.isEmpty == false ? displayName : fallbackName) ?? "Assistant for Google Meet"
+        splash.configure(appName: resolvedName, appIcon: NSApp.applicationIconImage, theme: makeLaunchSplashTheme())
+
+        view.addSubview(splash)
+        NSLayoutConstraint.activate([
+            splash.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+            splash.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+            splash.topAnchor.constraint(equalTo: view.topAnchor),
+            splash.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+        ])
+        view.layoutSubtreeIfNeeded()
+        launchSplashView = splash
+        launchSplashShownAt = Date()
+
+        launchSplashTimeoutWorkItem?.cancel()
+        let timeoutWorkItem = DispatchWorkItem { [weak self] in
+            self?.dismissLaunchSplash(force: true)
+        }
+        launchSplashTimeoutWorkItem = timeoutWorkItem
+        DispatchQueue.main.asyncAfter(deadline: .now() + launchSplashTimeout, execute: timeoutWorkItem)
+    }
+
+    private func dismissLaunchSplashIfReady() {
+        guard hasCompletedInitialStoreKitSync else { return }
+        guard !hasDismissedLaunchSplash else { return }
+        if let shownAt = launchSplashShownAt {
+            let elapsed = Date().timeIntervalSince(shownAt)
+            let remaining = launchSplashMinimumVisibleDuration - elapsed
+            if remaining > 0 {
+                launchSplashMinimumDelayWorkItem?.cancel()
+                let minDelayWorkItem = DispatchWorkItem { [weak self] in
+                    self?.dismissLaunchSplash(force: false)
+                }
+                launchSplashMinimumDelayWorkItem = minDelayWorkItem
+                DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: minDelayWorkItem)
+                return
+            }
+        }
+        dismissLaunchSplash(force: false)
+    }
+
+    private func dismissLaunchSplash(force: Bool) {
+        guard !hasDismissedLaunchSplash else { return }
+        guard let splash = launchSplashView else { return }
+        if !force && !hasCompletedInitialStoreKitSync { return }
+
+        hasDismissedLaunchSplash = true
+        launchSplashTimeoutWorkItem?.cancel()
+        launchSplashTimeoutWorkItem = nil
+        launchSplashMinimumDelayWorkItem?.cancel()
+        launchSplashMinimumDelayWorkItem = nil
+
+        let removeSplash: () -> Void = { [weak self] in
+            splash.removeFromSuperview()
+            self?.launchSplashView = nil
+            self?.launchSplashShownAt = nil
+        }
+
+        if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
+            removeSplash()
+            return
+        }
+
+        NSAnimationContext.runAnimationGroup({ context in
+            context.duration = 0.24
+            context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+            splash.animator().alphaValue = 0
+        }, completionHandler: removeSplash)
+    }
+
     func systemPrefersDarkMode() -> Bool {
         // Use the system-wide appearance setting (not app/window effective appearance).
         // When the key is missing, macOS is in Light mode.
@@ -861,6 +963,7 @@ private extension ViewController {
         NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
         view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
         palette = Palette(isDarkMode: enabled)
+        launchSplashView?.apply(theme: makeLaunchSplashTheme())
         reloadTheme()
     }
 
@@ -1282,6 +1385,7 @@ private extension ViewController {
             self.hasCompletedInitialStoreKitSync = true
             self.refreshPaywallStoreUI()
             self.presentLaunchPaywallIfNeeded()
+            self.dismissLaunchSplashIfReady()
         }
     }