mirror of
https://github.com/jayanezj/traccar-client-macos.git
synced 2026-11-05 00:00:45 +01:00
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
200 lines
7.4 KiB
Swift
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
|
|
}
|
|
}
|