import SwiftUI #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif // MARK: - App-Icon-Loading (cross-platform, kein Extra-Asset) extension Image { /// Lädt das echte AppIcon zur Laufzeit: /// iOS via `CFBundleIcons` aus der Info.plist, macOS via /// `NSApp.applicationIconImage`. Fällt auf ein SF-Symbol zurück wenn die /// Plattform-spezifische Auflösung leer ist (z.B. Preview ohne Bundle). static var appIcon: Image { #if os(iOS) if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any], let primary = icons["CFBundlePrimaryIcon"] as? [String: Any], let files = primary["CFBundleIconFiles"] as? [String], let name = files.last, let ui = UIImage(named: name) { return Image(uiImage: ui) } return Image(systemName: "books.vertical.fill") #else if let ns = NSApp?.applicationIconImage { return Image(nsImage: ns) } return Image(systemName: "books.vertical.fill") #endif } } // MARK: - Color-Helper für hellere/dunklere Akzent-Töne private extension Color { func lighter(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: amount) } func darker(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: -amount) } private func adjustBrightness(by amount: CGFloat) -> Color { var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 #if os(iOS) UIColor(self).getHue(&h, saturation: &s, brightness: &b, alpha: &a) #else (NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self)) .getHue(&h, saturation: &s, brightness: &b, alpha: &a) #endif return Color(hue: Double(h), saturation: Double(s), brightness: Double(min(1.0, max(0.0, b + amount))), opacity: Double(a)) } } // MARK: - Partikel-Konfiguration private struct ParticleConfig: Identifiable { let id: Int let startAngle: Double // 0…2π — Position auf der Umlaufbahn bei t=0 let orbitRadius: CGFloat // 80…150pt — Radius der Umlaufbahn let size: CGFloat // 4 / 7 / 10pt let appearDelay: Double // 0…0.9s — Stagger-Fade-In let angularVelocity: Double // rad/s — Umlaufgeschwindigkeit, Vorzeichen = Richtung let radialWobble: CGFloat // 0…5pt — sanftes Atmen des Radius let wobbleFrequency: Double // 0.4…0.8 Hz let wobblePhase: Double // 0…2π let shade: ParticleShade } private enum ParticleShade { case light, accent, dark func color(base: Color) -> Color { switch self { case .light: return base.lighter(by: 0.25) case .accent: return base case .dark: return base.darker(by: 0.20) } } } /// Acht Partikel auf unterschiedlichen Umlaufbahnen. Reproduzierbar bei /// jedem App-Start (kein Random). Drei Bahnen-Schichten (innen/mitte/außen) /// plus gemischte Drehrichtungen (Vorzeichen von `angularVelocity`) damit /// das Bild nicht wie ein gleichförmiges Karussell wirkt. private let particleConfigs: [ParticleConfig] = [ .init(id: 0, startAngle: 0.45, orbitRadius: 88, size: 7, appearDelay: 0.05, angularVelocity: 0.32, radialWobble: 3, wobbleFrequency: 0.7, wobblePhase: 0.3, shade: .light), .init(id: 1, startAngle: 1.20, orbitRadius: 130, size: 4, appearDelay: 0.30, angularVelocity: -0.22, radialWobble: 4, wobbleFrequency: 0.5, wobblePhase: 1.1, shade: .accent), .init(id: 2, startAngle: 2.00, orbitRadius: 85, size: 10, appearDelay: 0.55, angularVelocity: 0.18, radialWobble: 2, wobbleFrequency: 0.4, wobblePhase: 2.4, shade: .dark), .init(id: 3, startAngle: 2.80, orbitRadius: 115, size: 7, appearDelay: 0.20, angularVelocity: 0.28, radialWobble: 5, wobbleFrequency: 0.6, wobblePhase: 0.8, shade: .accent), .init(id: 4, startAngle: 3.65, orbitRadius: 95, size: 4, appearDelay: 0.70, angularVelocity: -0.35, radialWobble: 3, wobbleFrequency: 0.55, wobblePhase: 3.2, shade: .light), .init(id: 5, startAngle: 4.40, orbitRadius: 140, size: 10, appearDelay: 0.10, angularVelocity: -0.16, radialWobble: 4, wobbleFrequency: 0.35, wobblePhase: 4.0, shade: .dark), .init(id: 6, startAngle: 5.20, orbitRadius: 100, size: 7, appearDelay: 0.45, angularVelocity: 0.24, radialWobble: 3, wobbleFrequency: 0.65, wobblePhase: 5.1, shade: .light), .init(id: 7, startAngle: 5.80, orbitRadius: 120, size: 4, appearDelay: 0.85, angularVelocity: -0.30, radialWobble: 5, wobbleFrequency: 0.45, wobblePhase: 1.6, shade: .accent), ] // MARK: - SplashScreenView /// Vierphasiger Splash: /// 1. Partikel erscheinen gestaffelt um das Icon (~1.2s) /// 2. Partikel driften organisch in Loop bis `isReady` true wird (min 0.5s) /// 3. Alle Partikel werden ins Icon gesogen (0.6s) mit kleinem Icon-Wobble /// 4. Flash-Ring + Icon-Scale-Pulse (0.4s, iOS auch Haptic) /// 5. Fade-Out (0.4s) → `onComplete()` struct SplashScreenView: View { let isReady: Bool let onComplete: () -> Void private enum Phase { case appearing, idle, attracting, flash, fading } @State private var phase: Phase = .appearing @State private var particlesShown: Bool = false @State private var particlesAttracted: Bool = false @State private var iconScale: CGFloat = 1.0 @State private var iconWobble: CGFloat = 1.0 @State private var iconBrightness: Double = 0 @State private var ringScale: CGFloat = 1.0 @State private var ringOpacity: Double = 0 @State private var splashOpacity: Double = 1.0 /// Spiegel von `isReady` mit `@State`-Storage. Nötig weil die `.task`- /// Closure die View-Struct beim ersten Erscheinen capturet und das /// `let isReady` aus dem alten Snapshot liest — das bleibt forever false /// auch wenn der Parent neu rendert. `@State`-Storage dagegen ist /// shared über Reconstructions hinweg, also sieht der laufende Task die /// Aktualisierung sobald `onChange` sie hier reinpiped. @State private var readySignaled: Bool = false /// Einmalig festgelegt — Basis für die TimelineView-Zeit. Als `@State`, /// damit der Wert bei View-Re-Renders aus ContentView nicht versehentlich /// auf „jetzt" zurückgesetzt wird und die Drift-Bewegung dadurch springt. @State private var referenceDate: Date = .init() var body: some View { ZStack { background ZStack { particleLayer iconLayer flashRing } .frame(width: 320, height: 320) } .opacity(splashOpacity) .task { await runSplashAnimation() } .onAppear { // Falls der Parent isReady bereits beim ersten Render gesetzt hat // (z.B. weil bootstrap super-schnell durchlief), spiegeln wir den // Anfangswert direkt — ohne dass eine onChange-Flanke nötig wäre. if isReady { readySignaled = true } } .onChange(of: isReady) { _, newValue in if newValue { readySignaled = true } } } // MARK: Background @ViewBuilder private var background: some View { #if os(iOS) Color(.systemBackground).ignoresSafeArea() #else Color(NSColor.windowBackgroundColor).ignoresSafeArea() #endif } // MARK: Icon private var iconLayer: some View { Image.appIcon .resizable() .frame(width: 96, height: 96) .clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous)) .shadow(color: .black.opacity(0.15), radius: 8, y: 3) .scaleEffect(iconScale * iconWobble) .brightness(iconBrightness) } // MARK: Flash-Ring private var flashRing: some View { Circle() .stroke(Color.white, lineWidth: 4) .frame(width: 96, height: 96) .scaleEffect(ringScale) .opacity(ringOpacity) .allowsHitTesting(false) } // MARK: Partikel @ViewBuilder private var particleLayer: some View { // TimelineView läuft während `.appearing` und `.idle`, damit die // Partikel schon beim Stagger-Fade-In leicht driften (sonst gäb's // einen sichtbaren Positions-Sprung beim Übergang zu `.idle`). // Pausiert ab `.attracting` — die gefrorene Position dient als // Startpunkt für die Attract-Animation Richtung .zero. let paused = (phase == .attracting || phase == .flash || phase == .fading) TimelineView(.animation(paused: paused)) { context in let t = context.date.timeIntervalSince(referenceDate) ZStack { ForEach(particleConfigs) { cfg in particleView(cfg: cfg, t: t) } } } } @ViewBuilder private func particleView(cfg: ParticleConfig, t: TimeInterval) -> some View { let drifted = idlePosition(for: cfg, t: t) let position = particlesAttracted ? CGPoint.zero : drifted let scale: CGFloat = particlesAttracted ? 0.0 : 1.0 // Wichtig: NICHT `Color.accentColor` (semantisch — konvertiert via // UIColor unzuverlässig zum echten Asset-Wert, gibt manchmal den // System-Tint = blau zurück). Direkt aus dem Asset-Catalog lesen. Circle() .fill(cfg.shade.color(base: Color("AccentColor"))) .frame(width: cfg.size, height: cfg.size) .opacity(particlesShown ? 1.0 : 0.0) .scaleEffect(scale) .offset(x: position.x, y: position.y) // Stagger-Fade-In: jeder Partikel mit eigener Verzögerung .animation(.easeOut(duration: 0.5).delay(cfg.appearDelay), value: particlesShown) // Attract: 0.6s easeIn ins Zentrum, gleichzeitig auf scale=0 .animation(.easeIn(duration: 0.6), value: particlesAttracted) } private func idlePosition(for cfg: ParticleConfig, t: TimeInterval) -> CGPoint { // Echte Umlaufbahn: Winkel wächst mit t (Vorzeichen = Drehrichtung). // Radius "atmet" leicht über sinusförmiges radialWobble — damit die // Bahn nicht maschinell perfekt-kreisförmig wirkt. let angle = cfg.startAngle + t * cfg.angularVelocity let wobble = sin(t * cfg.wobbleFrequency + cfg.wobblePhase) * Double(cfg.radialWobble) let radius = Double(cfg.orbitRadius) + wobble return CGPoint(x: cos(angle) * radius, y: sin(angle) * radius) } // MARK: Animation-Orchestrierung private func runSplashAnimation() async { // Phase 1a — Partikel erscheinen gestaffelt particlesShown = true // Warten bis alle Partikel sichtbar sind (max appearDelay 0.85 + fade 0.5) try? await Task.sleep(for: .milliseconds(1400)) if Task.isCancelled { return } phase = .idle // Phase 1b — Drift-Loop bis readySignaled UND min 500ms in idle. // Wichtig: NICHT `isReady` lesen — siehe Doc-Kommentar an readySignaled. let idleStart = Date() let minIdleDuration: TimeInterval = 0.5 while !readySignaled || Date().timeIntervalSince(idleStart) < minIdleDuration { try? await Task.sleep(for: .milliseconds(80)) if Task.isCancelled { return } } // Phase 2 — Sog ins Icon-Zentrum phase = .attracting withAnimation(.easeIn(duration: 0.6)) { particlesAttracted = true } // Bei ~400ms in die Attract-Phase ein kleiner Icon-Wobble als die // Partikel ankommen — fühlt sich an wie der Impact. try? await Task.sleep(for: .milliseconds(400)) if Task.isCancelled { return } withAnimation(.easeOut(duration: 0.1)) { iconWobble = 1.06 } try? await Task.sleep(for: .milliseconds(100)) withAnimation(.easeIn(duration: 0.1)) { iconWobble = 1.0 } try? await Task.sleep(for: .milliseconds(100)) if Task.isCancelled { return } // Phase 3 — Flash + Scale-Pulse + Haptic phase = .flash #if os(iOS) UIImpactFeedbackGenerator(style: .heavy).impactOccurred() #endif // Ring: 1.0 → 2.5×, opacity 1 → 0 (0.4s easeOut) ringScale = 1.0 ringOpacity = 1.0 withAnimation(.easeOut(duration: 0.4)) { ringScale = 2.5 ringOpacity = 0 } // Icon-Brightness: schnell hoch, langsamer runter withAnimation(.easeOut(duration: 0.15)) { iconBrightness = 0.6 } // Parallel: Icon-Scale-Pulse 1.0 → 1.15 → 1.0 withAnimation(.easeOut(duration: 0.2)) { iconScale = 1.15 } try? await Task.sleep(for: .milliseconds(200)) if Task.isCancelled { return } withAnimation(.easeIn(duration: 0.25)) { iconBrightness = 0 } withAnimation(.easeIn(duration: 0.2)) { iconScale = 1.0 } try? await Task.sleep(for: .milliseconds(200)) if Task.isCancelled { return } // Phase 4 — Fade phase = .fading withAnimation(.easeOut(duration: 0.4)) { splashOpacity = 0 } try? await Task.sleep(for: .milliseconds(420)) onComplete() } }