import CoreLocation import CoreMotion import Foundation import Observation import SceneKit // MARK: - SunseekerViewModel /// Owns all sensor state for the Sunseeker AR overlay. /// Separates CoreLocation / CoreMotion concerns from the ARKit view layer. @Observable final class SunseekerViewModel: NSObject, CLLocationManagerDelegate { // MARK: - Published State /// True once we have both a GPS fix and a valid heading. private(set) var isReady = false /// Latest GPS coordinate. nil until first fix. private(set) var coordinate: CLLocationCoordinate2D? /// Latest true heading (0 = North, clockwise). private(set) var heading: Double = 0 /// Dense hourly arc for today. private(set) var arc: [(date: Date, position: SunPosition)] = [] /// Current real-time sun position (updated every second). private(set) var currentPosition: SunPosition? /// Sunrise and sunset for today. private(set) var riseSet: (rise: Date?, set: Date?) = (nil, nil) /// Diagnostic string for the UI when location access is denied. private(set) var locationError: String? // MARK: - Private private let locationManager = CLLocationManager() private let motionManager = CMMotionManager() private var updateTimer: Timer? // MARK: - Init / Deinit override init() { super.init() locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyBest locationManager.headingFilter = 1.0 locationManager.requestWhenInUseAuthorization() locationManager.startUpdatingLocation() locationManager.startUpdatingHeading() startMotionUpdates() startRealTimeTick() } deinit { stop() } // MARK: - Control func stop() { motionManager.stopDeviceMotionUpdates() locationManager.stopUpdatingLocation() locationManager.stopUpdatingHeading() updateTimer?.invalidate() updateTimer = nil } // MARK: - World-Space Transform /// Converts a solar `SunPosition` into a SceneKit world-space position on a sphere of given `radius`. /// Orientation is relative to ARWorldTrackingConfiguration(.gravityAndHeading), so north = -Z axis. func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 { let elev = Float(sun.elevation.radians) let az = Float(sun.azimuth.radians) // clockwise from north let x = radius * cos(elev) * sin(az) let y = radius * sin(elev) let z = -radius * cos(elev) * cos(az) // -Z = north in ARKit gravity+heading return SCNVector3(x, y, z) } // MARK: - Private helpers private func startMotionUpdates() { guard motionManager.isDeviceMotionAvailable else { return } motionManager.deviceMotionUpdateInterval = 0.05 motionManager.startDeviceMotionUpdates() } private func startRealTimeTick() { updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self, let coord = self.coordinate else { return } self.currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord) } } private func refreshArc() { guard let coord = coordinate else { return } arc = SunMath.sunPathArc(for: Date(), coordinate: coord) riseSet = SunMath.sunRiseSet(for: Date(), coordinate: coord) currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord) isReady = true } // MARK: - CLLocationManagerDelegate func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard coordinate == nil, let loc = locations.last else { return } coordinate = loc.coordinate refreshArc() } func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading if coordinate != nil { isReady = true } } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { print("[Sunseeker] Location error: \(error.localizedDescription)") } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { switch manager.authorizationStatus { case .denied, .restricted: locationError = "Location access needed to calculate the sun path. Please enable it in Settings." case .notDetermined: manager.requestWhenInUseAuthorization() default: locationError = nil } } } // MARK: - Degree helpers (internal to this file) private extension Double { var radians: Double { self * .pi / 180.0 } }