Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Inventory/SunseekerViewModel.swift
Sayan Datta fefe8373ec
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled
feat: Ipad app features and Dream Weaver for Velocity WebOS
2026-04-28 10:59:07 +05:30

141 lines
4.8 KiB
Swift

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 }
}