Files
traccar-client-macos/TraccarClient/Location/LocationModel.swift
T
jayanezj 3f351296b8 feat: add native local location capture (#4)
Add explicit permission handling, independent continuous and single capture,
location status and deterministic CI coverage.

Final decisions and validation:
https://github.com/jayanezj/traccar-client-macos/issues/4#issuecomment-6399189792
2026-10-25 15:58:05 +01:00

200 lines
7.4 KiB
Swift

import Foundation
import Observation
@MainActor
@Observable
final class LocationModel {
private(set) var availability: LocationAvailability
private(set) var continuousRequested = false
private(set) var singleStatus: SingleCaptureStatus = .idle
private(set) var lastFix: LocationFix?
private(set) var lastFixOrigin: LocationFixOrigin?
private(set) var errorMessage: String?
private(set) var continuousError: String?
@ObservationIgnored private let provider: any LocationProviding
@ObservationIgnored private let readAccuracy: () throws -> LocationAccuracy
@ObservationIgnored private var permissionRequested = false
@ObservationIgnored private var continuousRequest: Int?
@ObservationIgnored private var singleRequest: Int?
@ObservationIgnored private var nextRequest = 0
@ObservationIgnored private var continuousBlocked = false
@ObservationIgnored private var continuousAccuracy: LocationAccuracy = .medium
@ObservationIgnored private var singleAccuracy: LocationAccuracy = .medium
init(provider: any LocationProviding, readAccuracy: @escaping () throws -> LocationAccuracy = { .medium }) {
self.provider = provider
self.readAccuracy = readAccuracy
availability = provider.availability
provider.onEvent = { [weak self] in self?.receive($0) }
}
var status: String {
if availability.services == .disabled { return "Location Services disabled" }
if availability.permission == .denied { return "Location permission denied" }
if availability.permission == .restricted { return "Location permission restricted" }
if continuousError != nil { return "Location unavailable" }
if continuousRequested || singleStatus == .waiting {
if !availability.canCapture { return "Waiting for location authorization" }
return lastFix == nil ? "Waiting for location" : "Location capture active"
}
return "Paused"
}
func startContinuous(accuracy: LocationAccuracy? = nil) {
guard !continuousRequested else { return }
guard let selected = selectedAccuracy(accuracy) else { return }
preparePermissionRequest()
continuousRequested = true
continuousAccuracy = selected
continuousBlocked = false
continuousError = nil
errorMessage = nil
provider.refreshAvailability()
reconcile()
}
func pause() {
continuousRequested = false
stopContinuous()
continuousError = nil
continuousBlocked = false
}
func captureOnce(accuracy: LocationAccuracy? = nil) {
guard singleStatus != .waiting else { return }
guard let selected = selectedAccuracy(accuracy) else {
singleStatus = .failed
return
}
preparePermissionRequest()
singleStatus = .waiting
singleAccuracy = selected
errorMessage = nil
provider.refreshAvailability()
reconcile()
}
func cancelSingle() {
guard singleStatus == .waiting else { return }
singleRequest = nil
singleStatus = .cancelled
provider.cancelSingle()
}
func shutdown() {
pause()
cancelSingle()
}
func refreshAvailability() { provider.refreshAvailability() }
private func preparePermissionRequest() {
// A new explicit attempt may retry an undetermined request; status refreshes never do so on their own.
if !continuousRequested && singleStatus != .waiting { permissionRequested = false }
}
private func selectedAccuracy(_ requested: LocationAccuracy?) -> LocationAccuracy? {
do {
return try requested ?? readAccuracy()
} catch {
errorMessage = "Saved tracking preferences could not be read. Review Settings before capture."
return nil
}
}
private func reconcile() {
guard continuousRequested || singleStatus == .waiting else { return }
if availability.services == .disabled || [.denied, .restricted].contains(availability.permission) {
stopContinuous()
errorMessage = availability.services == .disabled
? "Enable Location Services in System Settings to capture locations."
: "Location access is denied or restricted. Review Location Services in System Settings."
if singleStatus == .waiting {
singleRequest = nil
singleStatus = .failed
provider.cancelSingle()
}
return
}
guard availability.services == .enabled else { return }
if availability.permission == .notDetermined {
guard !permissionRequested else { return }
permissionRequested = true
provider.requestAuthorization()
return
}
guard availability.canCapture else { return }
if continuousRequested && continuousRequest == nil && !continuousBlocked {
nextRequest += 1
continuousRequest = nextRequest
provider.startContinuous(accuracy: continuousAccuracy, request: nextRequest)
}
if singleStatus == .waiting && singleRequest == nil {
nextRequest += 1
singleRequest = nextRequest
provider.requestSingle(accuracy: singleAccuracy, request: nextRequest)
}
}
private func stopContinuous() {
continuousRequest = nil
provider.stopContinuous()
}
private func receive(_ event: LocationEvent) {
switch event {
case .availability(let value):
if availability != value {
continuousBlocked = false
continuousError = nil
errorMessage = nil
}
availability = value
if !value.canCapture { stopContinuous() }
reconcile()
case .locations(let kind, let request, let fixes):
guard accepts(kind, request: request), let fix = fixes.max(by: { $0.timestamp < $1.timestamp }) else {
return
}
lastFix = fix
lastFixOrigin = kind == .single ? .single : .continuous
if kind == .single {
singleStatus = .received
singleRequest = nil
} else {
continuousError = nil
}
errorMessage = nil
case .failure(let kind, let request, let reason, let fallback):
guard accepts(kind, request: request) else { return }
handleFailure(kind, reason: reason, fallback: fallback)
}
}
private func accepts(_ kind: LocationRequestKind, request: Int) -> Bool {
kind == .single ? singleStatus == .waiting && singleRequest == request
: continuousRequested && continuousRequest == request
}
private func handleFailure(_ kind: LocationRequestKind, reason: LocationFailure, fallback: LocationFix?) {
if kind == .continuous {
continuousError = reason.message
if reason == .permissionDenied {
continuousBlocked = true
stopContinuous()
}
return
}
singleRequest = nil
if reason != .permissionDenied, let known = fallback ?? lastFix {
lastFix = known
lastFixOrigin = .lastKnown
singleStatus = .lastKnown
} else {
singleStatus = .failed
}
errorMessage = reason.message
}
}