// JarvisApp.swift import SwiftUI import SwiftData import UIKit // needed for AppDelegate import BackgroundTasks // MARK: - Splash struct SplashView: View { var onDismiss: () -> Void @State private var iconScale: CGFloat = 0.82 @State private var iconOpacity: Double = 0 @State private var markOpacity: Double = 0 @State private var rootOpacity: Double = 1 var body: some View { ZStack { Color(hex: "0A0A0A").ignoresSafeArea() VStack(spacing: 22) { Image("JarvisIcon") .resizable() .frame(width: 108, height: 108) .clipShape(Circle()) .shadow(color: Palette.orange.opacity(0.35), radius: 22, y: 4) .scaleEffect(iconScale) .opacity(iconOpacity) JarvisWordmark(size: 28) .opacity(markOpacity) } } .opacity(rootOpacity) .onAppear { withAnimation(.spring(response: 0.48, dampingFraction: 0.74).delay(0.08)) { iconScale = 1 iconOpacity = 1 } withAnimation(.easeOut(duration: 0.28).delay(0.32)) { markOpacity = 1 } withAnimation(.easeIn(duration: 0.26).delay(1.6)) { rootOpacity = 0 } DispatchQueue.main.asyncAfter(deadline: .now() + 1.9) { onDismiss() } } } } final class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { // Briefing notifications refresh BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in Task { @MainActor in await NotificationManager.shared.applySettings() NotificationManager.shared.scheduleBackgroundRefresh() task.setTaskCompleted(success: true) } } // Story feed cache refresh BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisFeedRefreshID, using: nil) { task in guard let refresh = task as? BGAppRefreshTask else { task.setTaskCompleted(success: false); return } BackgroundRefreshManager.handleFeedRefresh(task: refresh) } return true } } @main struct JarvisApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark @StateObject private var settings = ServerSettings.shared @StateObject private var store = StoryStore.shared @StateObject private var ws = WebSocketManager.shared @StateObject private var connectivity = ConnectivitySettings.shared @StateObject private var connManager = ConnectivityManager.shared @StateObject private var notifications = NotificationManager.shared @Environment(\.scenePhase) private var scenePhase @State private var splashDone = false // Single ModelContainer instance shared with BackgroundRefreshManager. private let container: ModelContainer = { let schema = Schema([CachedStory.self, CachedArticle.self, ReadStory.self, SavedStory.self, SeenStory.self]) return try! ModelContainer(for: schema) }() var body: some Scene { WindowGroup { ZStack { // Main app always rendered underneath so it's ready when splash fades. Group { if settings.isConfigured { RootTabView() } else { OnboardingView() } } if !splashDone { SplashView { splashDone = true } } } .preferredColorScheme(appearanceMode.colorScheme) .environmentObject(settings) .environmentObject(store) .environmentObject(ws) .environmentObject(connectivity) .environmentObject(connManager) .environmentObject(notifications) .task { BackgroundRefreshManager.container = container BackgroundRefreshManager.scheduleFeedRefresh() connManager.start() await connManager.resolveAndActivate() await notifications.bootstrap() store.startForegroundSync() } .onChange(of: scenePhase) { _, phase in switch phase { case .active: store.startForegroundSync() // Refresh immediately if we've been away more than 2 minutes. if let last = store.lastSyncedAt, Date().timeIntervalSince(last) > 120 { Task { await store.loadStories(refresh: true) } } case .background: store.stopForegroundSync() default: break } } } .modelContainer(container) } }