Browse Source

Add launch splash screen and preserve app state when the window is hidden.

Show a branded splash on cold start, hide the main window instead of destroying it on close, and keep session plus Reddit WebView state alive across dock reopen.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 month ago
parent
commit
915dcbbb1c

+ 83 - 0
Reddit App/App/AppDelegate.swift

@@ -0,0 +1,83 @@
+import AppKit
+
+@MainActor
+final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
+    private var windowObservers: [NSObjectProtocol] = []
+
+    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+        false
+    }
+
+    func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+        if !flag {
+            showMainWindow()
+        }
+        return true
+    }
+
+    func applicationDidFinishLaunching(_ notification: Notification) {
+        installWindowObservers()
+        configureMainWindows()
+    }
+
+    func applicationDidBecomeActive(_ notification: Notification) {
+        configureMainWindows()
+        showMainWindowIfNeeded()
+    }
+
+    func windowShouldClose(_ sender: NSWindow) -> Bool {
+        sender.orderOut(nil)
+        return false
+    }
+
+    func configureMainWindows() {
+        for window in NSApp.windows where isMainAppWindow(window) {
+            window.isReleasedWhenClosed = false
+            if window.delegate !== self {
+                window.delegate = self
+            }
+        }
+    }
+
+    private func installWindowObservers() {
+        let center = NotificationCenter.default
+
+        windowObservers = [
+            center.addObserver(
+                forName: NSWindow.didBecomeKeyNotification,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                Task { @MainActor in
+                    self?.configureMainWindows()
+                }
+            },
+            center.addObserver(
+                forName: NSApplication.didUpdateNotification,
+                object: nil,
+                queue: .main
+            ) { [weak self] _ in
+                Task { @MainActor in
+                    self?.configureMainWindows()
+                }
+            },
+        ]
+    }
+
+    private func showMainWindowIfNeeded() {
+        guard !NSApp.windows.contains(where: { isMainAppWindow($0) && $0.isVisible }) else { return }
+        showMainWindow()
+    }
+
+    private func showMainWindow() {
+        guard let window = NSApp.windows.first(where: isMainAppWindow) else { return }
+        window.makeKeyAndOrderFront(nil)
+        NSApp.activate(ignoringOtherApps: true)
+    }
+
+    private func isMainAppWindow(_ window: NSWindow) -> Bool {
+        guard window.canBecomeMain, !(window is NSPanel) else { return false }
+        if window.title == "Sign in to Reddit" { return false }
+        return window.level == .normal
+    }
+}

+ 10 - 4
Reddit App/App/RedditAppApp.swift

@@ -3,22 +3,28 @@ import SwiftUI
 
 @main
 struct RedditAppApp: App {
+    @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
     @StateObject private var subscriptions = SubscriptionManager()
     @StateObject private var appRating = AppRatingManager()
     @State private var appearanceManager = AppearanceManager.shared
+    @State private var splashViewModel = SplashViewModel()
+    @State private var session = AppSessionState()
 
     var body: some Scene {
-        WindowGroup {
-            FrontPageView()
+        Window(AppLinks.appName, id: "main-window") {
+            RootView(session: session, splashViewModel: splashViewModel)
                 .environmentObject(subscriptions)
                 .environmentObject(appRating)
                 .environment(appearanceManager)
                 .preferredColorScheme(appearanceManager.isDarkMode ? .dark : .light)
-                .task {
-                    await NetworkClient.prepare()
+                .background {
+                    MainWindowLifecycleConfigurator()
                 }
         }
         .defaultSize(width: AppWindow.width, height: AppWindow.height)
         .windowStyle(.hiddenTitleBar)
+        .commands {
+            CommandGroup(replacing: .newItem) {}
+        }
     }
 }

+ 13 - 0
Reddit App/Managers/AppSessionState.swift

@@ -0,0 +1,13 @@
+import SwiftUI
+
+@MainActor
+@Observable
+final class AppSessionState {
+    let frontPage = FrontPageViewModel()
+    let paywall = PaywallViewModel()
+    let postGenerator = PostGeneratorViewModel()
+    let titleOptimizer = TitleOptimizerViewModel()
+    let commentWriter = CommentWriterViewModel()
+    let settings = SettingsViewModel()
+    let history = AIHistoryViewModel()
+}

+ 22 - 0
Reddit App/Managers/RedditWebViewStore.swift

@@ -0,0 +1,22 @@
+import AppKit
+import WebKit
+
+@MainActor
+final class RedditWebViewStore {
+    static let shared = RedditWebViewStore()
+
+    private(set) var container: RedditWebViewContainer?
+
+    private init() {}
+
+    func containerIfNeeded() -> RedditWebViewContainer {
+        if let container {
+            return container
+        }
+
+        let webView = RedditWebViewManager.makeWebView()
+        let newContainer = RedditWebViewContainer(webView: webView)
+        container = newContainer
+        return newContainer
+    }
+}

+ 2 - 0
Reddit App/ViewModels/FrontPageViewModel.swift

@@ -17,6 +17,7 @@ final class FrontPageViewModel {
     var isHistoryActive = false
     var isPaywallPresented = false
     var authErrorMessage: String?
+    var hasOpenedReddit = false
     private(set) var redditReloadTrigger = 0
     private(set) var redditGoBackTrigger = 0
     var redditCanGoBack = false
@@ -34,6 +35,7 @@ final class FrontPageViewModel {
     )
 
     func openReddit(at url: URL? = nil) {
+        hasOpenedReddit = true
         selectedNavItem = nil
         isRedditActive = true
         isSettingsActive = false

+ 78 - 0
Reddit App/ViewModels/SplashViewModel.swift

@@ -0,0 +1,78 @@
+import SwiftUI
+
+@MainActor
+@Observable
+final class SplashViewModel {
+    private(set) var isVisible = true
+    private(set) var loadingProgress: Double = 0
+
+    private static var hasCompletedThisSession = false
+    private var hasStarted = false
+
+    private let minimumDisplayDuration: Duration = .seconds(1.35)
+
+    init() {
+        if Self.hasCompletedThisSession {
+            isVisible = false
+            loadingProgress = 1
+        }
+    }
+
+    func runIfNeeded() async {
+        guard !Self.hasCompletedThisSession, !hasStarted else { return }
+        hasStarted = true
+        await run()
+        Self.hasCompletedThisSession = true
+    }
+
+    private func run() async {
+        withAnimation(.easeOut(duration: 0.25)) {
+            loadingProgress = 0.08
+        }
+
+        async let networkPrepare: Void = NetworkClient.prepare()
+        async let minimumDelay: Void = waitMinimumDuration()
+        async let progressAnimation: Void = animateLoadingProgress()
+
+        _ = await (networkPrepare, minimumDelay, progressAnimation)
+
+        withAnimation(.easeOut(duration: 0.22)) {
+            loadingProgress = 1
+        }
+
+        try? await Task.sleep(for: .milliseconds(150))
+
+        withAnimation(.easeInOut(duration: 0.45)) {
+            isVisible = false
+        }
+    }
+
+    private func waitMinimumDuration() async {
+        try? await Task.sleep(for: minimumDisplayDuration)
+    }
+
+    private func animateLoadingProgress() async {
+        let start: Double = 0.08
+        let target: Double = 0.92
+        let duration = 1.35
+        let steps = 30
+        let stepDuration = duration / Double(steps)
+
+        for step in 1...steps {
+            if Task.isCancelled { return }
+
+            let fraction = Double(step) / Double(steps)
+            let eased = start + (target - start) * easeOutCubic(fraction)
+
+            withAnimation(.linear(duration: stepDuration)) {
+                loadingProgress = eased
+            }
+
+            try? await Task.sleep(for: .seconds(stepDuration))
+        }
+    }
+
+    private func easeOutCubic(_ value: Double) -> Double {
+        1 - pow(1 - value, 3)
+    }
+}

+ 18 - 0
Reddit App/Views/Components/MainWindowLifecycleConfigurator.swift

@@ -0,0 +1,18 @@
+import AppKit
+import SwiftUI
+
+struct MainWindowLifecycleConfigurator: NSViewRepresentable {
+    func makeNSView(context: Context) -> WindowObservationView {
+        let view = WindowObservationView()
+        view.onWindowChange = { window in
+            guard let window, let delegate = NSApp.delegate as? AppDelegate else { return }
+            delegate.configureMainWindows()
+        }
+        return view
+    }
+
+    func updateNSView(_ nsView: WindowObservationView, context: Context) {
+        guard let window = nsView.window, let delegate = NSApp.delegate as? AppDelegate else { return }
+        delegate.configureMainWindows()
+    }
+}

+ 23 - 18
Reddit App/Views/FrontPageView.swift

@@ -4,13 +4,15 @@ struct FrontPageView: View {
     @EnvironmentObject private var subscriptions: SubscriptionManager
     @EnvironmentObject private var appRating: AppRatingManager
     @Environment(AppearanceManager.self) private var appearanceManager
-    @State private var viewModel = FrontPageViewModel()
-    @State private var paywallViewModel = PaywallViewModel()
-    @State private var postGeneratorViewModel = PostGeneratorViewModel()
-    @State private var titleOptimizerViewModel = TitleOptimizerViewModel()
-    @State private var commentWriterViewModel = CommentWriterViewModel()
-    @State private var settingsViewModel = SettingsViewModel()
-    @State private var historyViewModel = AIHistoryViewModel()
+    @Bindable var session: AppSessionState
+
+    private var viewModel: FrontPageViewModel { session.frontPage }
+    private var paywallViewModel: PaywallViewModel { session.paywall }
+    private var postGeneratorViewModel: PostGeneratorViewModel { session.postGenerator }
+    private var titleOptimizerViewModel: TitleOptimizerViewModel { session.titleOptimizer }
+    private var commentWriterViewModel: CommentWriterViewModel { session.commentWriter }
+    private var settingsViewModel: SettingsViewModel { session.settings }
+    private var historyViewModel: AIHistoryViewModel { session.history }
 
     var body: some View {
         let _ = appearanceManager.isDarkMode
@@ -128,17 +130,20 @@ struct FrontPageView: View {
             }
 
             ZStack {
-                if viewModel.isRedditActive {
+                if viewModel.isRedditActive || viewModel.hasOpenedReddit {
                     RedditWebView(
-                        url: viewModel.redditLoadURL,
-                        reloadTrigger: viewModel.redditReloadTrigger,
-                        isDarkMode: appearanceManager.isDarkMode,
-                        goBackTrigger: viewModel.redditGoBackTrigger,
-                        onCanGoBackChange: { viewModel.updateRedditCanGoBack($0) },
-                        onAuthError: { viewModel.showAuthError($0) }
-                    )
-                    .id("reddit-webview")
-                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    url: viewModel.redditLoadURL,
+                    reloadTrigger: viewModel.redditReloadTrigger,
+                    isDarkMode: appearanceManager.isDarkMode,
+                    goBackTrigger: viewModel.redditGoBackTrigger,
+                    onCanGoBackChange: { viewModel.updateRedditCanGoBack($0) },
+                    onAuthError: { viewModel.showAuthError($0) }
+                )
+                .id("reddit-webview")
+                .opacity(viewModel.isRedditActive ? 1 : 0)
+                .allowsHitTesting(viewModel.isRedditActive)
+                .accessibilityHidden(!viewModel.isRedditActive)
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
                 }
 
                 if viewModel.isSettingsActive {
@@ -227,7 +232,7 @@ private struct ToolPlaceholderView: View {
 }
 
 #Preview {
-    FrontPageView()
+    FrontPageView(session: AppSessionState())
         .environmentObject(SubscriptionManager())
         .environmentObject(AppRatingManager())
         .environment(AppearanceManager.shared)

+ 27 - 2
Reddit App/Views/RedditWebView.swift

@@ -18,7 +18,8 @@ struct RedditWebView: NSViewRepresentable {
     }
 
     func makeNSView(context: Context) -> RedditWebViewContainer {
-        let container = RedditWebViewContainer(webView: RedditWebViewManager.makeWebView())
+        let container = RedditWebViewStore.shared.containerIfNeeded()
+        container.removeFromSuperview()
         container.webView.navigationDelegate = context.coordinator
         container.webView.uiDelegate = context.coordinator
         context.coordinator.container = container
@@ -27,10 +28,24 @@ struct RedditWebView: NSViewRepresentable {
         context.coordinator.installLoginBridge()
         context.coordinator.startObservingNavigation(in: container.webView)
         container.applyTheme(isDarkMode: isDarkMode)
-        container.load(url: url)
+
+        if let existingURL = container.webView.url {
+            context.coordinator.lastLoadedURL = existingURL
+            context.coordinator.lastReloadTrigger = reloadTrigger
+        } else {
+            context.coordinator.lastLoadedURL = url
+            context.coordinator.lastReloadTrigger = reloadTrigger
+            container.load(url: url)
+        }
+
         return container
     }
 
+    static func dismantleNSView(_ nsView: RedditWebViewContainer, coordinator: Coordinator) {
+        coordinator.detach()
+        nsView.removeFromSuperview()
+    }
+
     func updateNSView(_ container: RedditWebViewContainer, context: Context) {
         context.coordinator.onCanGoBackChange = onCanGoBackChange
         context.coordinator.installLoginBridge()
@@ -92,6 +107,16 @@ struct RedditWebView: NSViewRepresentable {
             }
         }
 
+        func detach() {
+            canGoBackObservation?.invalidate()
+            canGoBackObservation = nil
+            urlObservation?.invalidate()
+            urlObservation = nil
+            prefillWorkItem?.cancel()
+            prefillWorkItem = nil
+            container = nil
+        }
+
         private func reportBackButtonVisibility(for webView: WKWebView) {
             let isOnHome = webView.url.map(RedditWebAuthHelper.isRedditHomeURL) ?? false
             onCanGoBackChange(webView.canGoBack && !isOnHome)

+ 22 - 0
Reddit App/Views/RootView.swift

@@ -0,0 +1,22 @@
+import SwiftUI
+
+struct RootView: View {
+    let session: AppSessionState
+    @Bindable var splashViewModel: SplashViewModel
+
+    var body: some View {
+        ZStack {
+            FrontPageView(session: session)
+
+            if splashViewModel.isVisible {
+                SplashScreenView(viewModel: splashViewModel)
+                    .transition(.opacity)
+                    .zIndex(1)
+            }
+        }
+        .animation(.easeInOut(duration: 0.45), value: splashViewModel.isVisible)
+        .task {
+            await splashViewModel.runIfNeeded()
+        }
+    }
+}

+ 152 - 0
Reddit App/Views/SplashScreenView.swift

@@ -0,0 +1,152 @@
+import SwiftUI
+
+struct SplashScreenView: View {
+    @Bindable var viewModel: SplashViewModel
+    @Environment(AppearanceManager.self) private var appearanceManager
+    @State private var contentScale: CGFloat = 0.9
+    @State private var contentOpacity: Double = 0
+    @State private var detailsOpacity: Double = 0
+    @State private var glowPulse = false
+
+    var body: some View {
+        let isDark = appearanceManager.isDarkMode
+
+        ZStack {
+            background(isDark: isDark)
+            glowOrb(isDark: isDark)
+
+            VStack(spacing: 28) {
+                brandMark
+
+                VStack(spacing: 24) {
+                    VStack(spacing: 10) {
+                        title
+                        Text("AI Assistant for Reddit")
+                            .font(.system(size: 14, weight: .medium))
+                            .foregroundStyle(AppTheme.textSecondary)
+                    }
+
+                    loadingBar
+                }
+                .opacity(detailsOpacity)
+            }
+            .frame(maxWidth: .infinity, maxHeight: .infinity)
+            .scaleEffect(contentScale)
+            .opacity(contentOpacity)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+        .ignoresSafeArea()
+        .onAppear {
+            withAnimation(.spring(response: 0.62, dampingFraction: 0.78)) {
+                contentScale = 1
+                contentOpacity = 1
+            }
+            withAnimation(.easeOut(duration: 0.55).delay(0.12)) {
+                detailsOpacity = 1
+            }
+            withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) {
+                glowPulse = true
+            }
+        }
+    }
+
+    private func background(isDark: Bool) -> some View {
+        Group {
+            if isDark {
+                AppTheme.background
+            } else {
+                LinearGradient(
+                    colors: [
+                        Color(hex: 0xF3F0FF),
+                        AppTheme.background
+                    ],
+                    startPoint: .top,
+                    endPoint: .bottom
+                )
+            }
+        }
+        .ignoresSafeArea()
+    }
+
+    private func glowOrb(isDark: Bool) -> some View {
+        Circle()
+            .fill(
+                RadialGradient(
+                    colors: [
+                        AppTheme.accentPurple.opacity(isDark ? 0.22 : 0.14),
+                        Color.clear
+                    ],
+                    center: .center,
+                    startRadius: 0,
+                    endRadius: 280
+                )
+            )
+            .frame(width: 560, height: 560)
+            .scaleEffect(glowPulse ? 1.06 : 0.94)
+            .offset(x: 180, y: -220)
+            .allowsHitTesting(false)
+    }
+
+    private var brandMark: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 20, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [AppTheme.accentPurpleLight, AppTheme.accentPurple],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+                .frame(width: 80, height: 80)
+                .shadow(color: AppTheme.accentPurple.opacity(0.38), radius: 28, y: 10)
+
+            Image(systemName: "brain.head.profile")
+                .font(.system(size: 36, weight: .semibold))
+                .foregroundStyle(.white)
+        }
+    }
+
+    private var loadingBar: some View {
+        VStack(spacing: 12) {
+            GeometryReader { geometry in
+                ZStack(alignment: .leading) {
+                    Capsule()
+                        .fill(AppTheme.border)
+
+                    Capsule()
+                        .fill(
+                            LinearGradient(
+                                colors: [AppTheme.accentPurple, AppTheme.accentPurpleLight],
+                                startPoint: .leading,
+                                endPoint: .trailing
+                            )
+                        )
+                        .frame(width: max(geometry.size.width * viewModel.loadingProgress, geometry.size.height))
+                }
+            }
+            .frame(width: 300, height: 7)
+
+            Text("Loading…")
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(AppTheme.textTertiary)
+        }
+        .frame(maxWidth: .infinity)
+    }
+
+    private var title: some View {
+        HStack(spacing: 7) {
+            Text("Reddora")
+                .foregroundStyle(AppTheme.textPrimary)
+            Text("AI")
+                .foregroundStyle(
+                    LinearGradient(
+                        colors: [AppTheme.accentPurple, AppTheme.accentPurpleLight],
+                        startPoint: .leading,
+                        endPoint: .trailing
+                    )
+                )
+        }
+        .font(.system(size: 34, weight: .bold))
+        .tracking(-0.6)
+    }
+}