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
This commit is contained in:
jayanezj committed 2026-10-25 15:58:05 +01:00
1 parent db90ad9a0e
commit 3f351296b8
18 files changed
+1030 -38

No files matched your search

@@ -0,0 +1,53 @@
import SwiftUI
struct LocationStatusView: View {
let location: LocationModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Label("Location capture", systemImage: "location")
.font(.title2)
Text("Capture your Mac's location continuously or once while paused. Nothing is sent to a server.")
Text("Choose Start or Capture once to allow macOS to request location permission if needed.")
.foregroundStyle(.secondary)
Form {
LabeledContent("Continuous capture", value: location.continuousRequested ? "Active" : "Paused")
LabeledContent("Condition", value: location.status)
LabeledContent("Location Services", value: location.availability.services.rawValue)
LabeledContent("Permission", value: location.availability.permission.rawValue)
LabeledContent("Single capture", value: location.singleStatus.rawValue)
if let fix = location.lastFix {
LabeledContent("Fix timestamp", value: fix.timestamp.formatted(date: .numeric, time: .standard))
LabeledContent("Reported accuracy", value: "\(fix.horizontalAccuracy.formatted()) m")
LabeledContent("Source", value: location.lastFixOrigin?.rawValue ?? "Location Services")
} else {
Text("No location received.").foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
if let error = location.errorMessage ?? location.continuousError {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
HStack {
Button(location.continuousRequested ? "Pause" : "Start") {
if location.continuousRequested { location.pause() } else {
location.startContinuous()
}
}
Button("Capture once") {
location.captureOnce()
}
.disabled(location.singleStatus == .waiting)
if location.singleStatus == .waiting {
Button("Cancel capture") { location.cancelSingle() }
}
Spacer()
Button("Refresh status") { location.refreshAvailability() }
}
}
.padding(20)
.frame(width: 620, height: 540)
.onAppear { location.refreshAvailability() }
}
}
+7 -3
View File
@@ -3,6 +3,7 @@ import SwiftUI
struct SettingsView: View {
@Bindable var model: ConfigurationModel
let location: LocationModel
@State private var focus = WindowFocusController()
var body: some View {
@@ -122,7 +123,7 @@ struct SettingsView: View {
TextField("Heartbeat (seconds)", value: $model.draft.tracking.heartbeatSeconds, format: .number)
Toggle("Buffer positions offline", isOn: $model.draft.tracking.bufferingEnabled)
Toggle("Detect stationary periods", isOn: $model.draft.tracking.stationaryDetectionEnabled)
Text("Preferences are saved for later tracking implementation. Tracking remains paused in this build.")
Text("Accuracy applies on capture start. Selection, buffering and stationary detection are not active yet.")
}
.disabled(!model.hasLoaded)
}
@@ -132,13 +133,16 @@ struct SettingsView: View {
Toggle("Launch at login", isOn: $model.draft.tracking.launchAtLogin).disabled(true)
Toggle("Restore continuous tracking", isOn: $model.draft.tracking.restoreTracking).disabled(true)
Text("Login startup and tracking restoration will be available in a later build. Both default to off.")
Text("Closing Settings keeps the app in the menu bar. Use Quit to exit.")
Text("Closing Settings keeps requested capture running in the menu bar. Use Pause to stop capture.")
}
}
private var diagnostics: some View {
Form {
LabeledContent("Status", value: "Paused")
LabeledContent("Location capture", value: location.continuousRequested ? "Active" : "Paused")
LabeledContent("Condition", value: location.status)
LabeledContent("Location permission", value: location.availability.permission.rawValue)
LabeledContent("Location Services", value: location.availability.services.rawValue)
LabeledContent("Configuration", value: model.hasLoaded ? "Loaded locally" : "Unavailable")
Text("Connection and identity: Keychain. Tracking preferences: UserDefaults.")
Text("No location permission requests or network activity are started by loading or saving settings.")
+30 -4
View File
@@ -3,15 +3,35 @@ import SwiftUI
struct TrackingMenu: View {
@Environment(\.openSettings) private var openSettings
@Environment(\.openWindow) private var openWindow
let location: LocationModel
var body: some View {
Text("Paused")
Text("Tracking is not available in this build.")
Text(location.continuousRequested ? "Location capture active" : "Paused")
Text(location.status)
Text("Local capture only. No positions are sent.")
if let fix = location.lastFix {
Text("Last fix: \(fix.timestamp.formatted(date: .numeric, time: .standard))")
Text("Reported accuracy: \(fix.horizontalAccuracy.formatted()) m")
}
Divider()
Button("Start tracking") {}
.disabled(true)
Button(location.continuousRequested ? "Pause location capture" : "Start location capture") {
if location.continuousRequested { location.pause() } else if location.availability.canCapture {
location.startContinuous()
} else {
showCaptureWindow()
}
}
Button("Capture location once") {
if location.availability.canCapture {
location.captureOnce()
}
showCaptureWindow()
}
.disabled(location.singleStatus == .waiting)
Button("Location status…") { showCaptureWindow() }
Button("Send location now") {}
.disabled(true)
Button("Send SOS") {}
@@ -38,8 +58,14 @@ struct TrackingMenu: View {
Divider()
Button("Quit Traccar Client macOS") {
location.shutdown()
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q")
}
private func showCaptureWindow() {
NSApplication.shared.activate()
openWindow(id: "location-capture")
}
}