import SwiftUI // MARK: - Dark Mode Animation System // Ported from DarkModeAnimation reference project (Balaji Venkatesh). // Circular reveal mask animation for theme switching. // Step 1: Wrap app's main content with DarkModeWrapper in RosettaApp. // Step 2: Place DarkModeButton wherever the toggle should appear. /// Creates an overlay UIWindow for the dark mode transition animation. /// Supports three theme modes: dark, light, system (via `rosetta_theme_mode`). struct DarkModeWrapper: View { @ViewBuilder var content: Content @State private var overlayWindow: UIWindow? @AppStorage("rosetta_theme_mode") private var themeModeRaw: String = "dark" var body: some View { content .onAppear { // Migrate legacy `rosetta_dark_mode` Bool → `rosetta_theme_mode` String let defaults = UserDefaults.standard if defaults.object(forKey: "rosetta_theme_mode") == nil, let legacy = defaults.object(forKey: "rosetta_dark_mode") as? Bool { themeModeRaw = legacy ? "dark" : "light" defaults.removeObject(forKey: "rosetta_dark_mode") } if overlayWindow == nil { if let windowScene = activeWindowScene { let overlayWindow = UIWindow(windowScene: windowScene) overlayWindow.tag = 0320 overlayWindow.backgroundColor = .clear overlayWindow.isHidden = false overlayWindow.isUserInteractionEnabled = false self.overlayWindow = overlayWindow } } } .onChange(of: themeModeRaw, initial: true) { _, newValue in if let windowScene = activeWindowScene { let style: UIUserInterfaceStyle switch newValue { case "light": style = .light case "system": style = .unspecified default: style = .dark } let bgColor: UIColor = (style == .light) ? .white : .black for window in windowScene.windows { window.overrideUserInterfaceStyle = style // Match window background to app background — prevents // systemBackground (dark gray) from showing as a line // in the bottom safe area. if window.tag != 0320 { window.backgroundColor = bgColor } } } } } private var activeWindowScene: UIWindowScene? { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first } } /// Theme toggle button with sun/moon icon and circular reveal animation. /// Cycles: dark → light → dark (quick toggle, full control in Appearance settings). struct DarkModeButton: View { @State private var buttonRect: CGRect = .zero /// Local icon state — changes INSTANTLY on tap (no round-trip through UserDefaults). @State private var showMoonIcon: Bool = true @AppStorage("rosetta_theme_mode") private var themeModeRaw: String = "dark" var body: some View { Button(action: { showMoonIcon.toggle() animateScheme() }, label: { Image(systemName: showMoonIcon ? "moon.fill" : "sun.max.fill") .font(.system(size: 16, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.text) .symbolEffect(.bounce, value: showMoonIcon) .frame(width: 44, height: 44) }) .buttonStyle(.plain) .onAppear { showMoonIcon = themeModeRaw == "dark" || themeModeRaw == "system" } .darkModeButtonRect { rect in buttonRect = rect } } @MainActor func animateScheme() { guard let windowScene = activeWindowScene, let window = windowScene.windows.first(where: { $0.isKeyWindow }), let overlayWindow = windowScene.windows.first(where: { $0.tag == 0320 }) else { return } let targetDark = showMoonIcon let frameSize = window.frame.size // 1. Capture old state SYNCHRONOUSLY — afterScreenUpdates:false is fast (~5ms). let previousImage = window.darkModeSnapshotFast(frameSize) // 2. Show freeze frame IMMEDIATELY — user sees instant response. // Overlay stays non-interactive so button taps always pass through. let imageView = UIImageView(image: previousImage) imageView.frame = window.frame imageView.contentMode = .scaleAspectFit overlayWindow.addSubview(imageView) // 3. Switch theme underneath the freeze frame (invisible to user). themeModeRaw = targetDark ? "dark" : "light" // 4. Capture new state asynchronously after layout settles. Task { try? await Task.sleep(for: .seconds(0.06)) window.layoutIfNeeded() let currentImage = window.darkModeSnapshot(frameSize) let swiftUIView = DarkModeOverlayView( buttonRect: buttonRect, previousImage: previousImage, currentImage: currentImage ) let hostingController = UIHostingController(rootView: swiftUIView) hostingController.view.backgroundColor = .clear hostingController.view.frame = window.frame hostingController.view.tag = 1009 overlayWindow.addSubview(hostingController.view) imageView.removeFromSuperview() } } private var activeWindowScene: UIWindowScene? { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first } } // MARK: - Button Rect Tracking private struct DarkModeRectKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() } } private extension View { @ViewBuilder func darkModeButtonRect(value: @escaping (CGRect) -> Void) -> some View { self .overlay { GeometryReader { geometry in let rect = geometry.frame(in: .global) Color.clear .preference(key: DarkModeRectKey.self, value: rect) .onPreferenceChange(DarkModeRectKey.self) { rect in value(rect) } } } } } // MARK: - Circular Mask Animation Overlay private struct DarkModeOverlayView: View { var buttonRect: CGRect @State var previousImage: UIImage? @State var currentImage: UIImage? @State private var maskAnimation: Bool = false var body: some View { GeometryReader { geometry in let size = geometry.size let maskRadius = size.height / 10 if let previousImage, let currentImage { ZStack { Image(uiImage: previousImage) .resizable() .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) Image(uiImage: currentImage) .resizable() .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) .mask(alignment: .topLeading) { Circle() .frame( width: buttonRect.width * (maskAnimation ? maskRadius : 1), height: buttonRect.height * (maskAnimation ? maskRadius : 1), alignment: .bottomLeading ) .frame(width: buttonRect.width, height: buttonRect.height) .offset(x: buttonRect.minX, y: buttonRect.minY) .ignoresSafeArea() } } .task { guard !maskAnimation else { return } withAnimation(.easeInOut(duration: 0.9), completionCriteria: .logicallyComplete) { maskAnimation = true } completion: { self.previousImage = nil self.currentImage = nil maskAnimation = false if let window = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first(where: { $0.tag == 0320 }) { for view in window.subviews { view.removeFromSuperview() } } } } } } // Reverse masking — cut out the button area so original button stays visible .mask { Rectangle() .overlay(alignment: .topLeading) { Circle() .frame(width: buttonRect.width, height: buttonRect.height) .offset(x: buttonRect.minX, y: buttonRect.minY) .blendMode(.destinationOut) } } .ignoresSafeArea() } } // MARK: - UIView Snapshot private extension UIView { /// Full-fidelity snapshot — waits for pending layout. Use for the NEW state. func darkModeSnapshot(_ size: CGSize) -> UIImage { let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { _ in drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: true) } } /// Fast snapshot of CURRENT appearance — no layout wait (~5ms vs ~80ms). /// Use for the OLD state (already on screen, nothing pending). func darkModeSnapshotFast(_ size: CGSize) -> UIImage { let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { _ in drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: false) } } }