commit 5bfff42bbaaf0d653c296daef7aed641fdf3a3dc
parent f7cd11a2876bf91ff6019af674c8b76a614d8c96
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 03:36:14 -0700
app: add location check-in runtime
- add the field location check-in runtime boundary
- expose check-in status and actions through AppState
- add the When In Use location privacy string
- regenerate the Xcode project for the new runtime source
Diffstat:
5 files changed, 122 insertions(+), 16 deletions(-)
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj
@@ -10,6 +10,7 @@
022DA21729F49893319717AA /* RelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D9496F9F05A4E79E73A247 /* RelaysView.swift */; };
049D620DD8C02816893BF765 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE1472FFD63A33F3AEA6C6C /* AppState.swift */; };
04AA409CFECBA11BFC175C5C /* RadrootsFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */; };
+ 1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */; };
1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0274A0260D1C04F40C71AF /* HomeView.swift */; };
275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A93F98C7B93188748B99B /* ProfileView.swift */; };
2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; };
@@ -64,6 +65,7 @@
227028B4EBDC6703999FB9DA /* ToastModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastModifier.swift; sourceTree = "<group>"; };
26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListingDetailView.swift; sourceTree = "<group>"; };
2818363B157125491FB84A1E /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
+ 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldLocationCheckIn.swift; sourceTree = "<group>"; };
2FE790CA1CD31208947913B9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
41A4289F43625DD65E6C4B25 /* CopyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyRow.swift; sourceTree = "<group>"; };
466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeOrderRequestView.swift; sourceTree = "<group>"; };
@@ -186,6 +188,7 @@
491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */,
CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */,
9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */,
+ 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */,
E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */,
8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */,
D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */,
@@ -417,6 +420,7 @@
7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */,
D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */,
6E15D30653861F26AC45B501 /* FieldLocalState.swift in Sources */,
+ 1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */,
D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */,
D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */,
1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */,
diff --git a/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,15 +0,0 @@
-{
- "originHash" : "298655de90aea0ed8ab5c0ee9375e40ecc5a3fc54fbde2fe3490bb729ed9c9f4",
- "pins" : [
- {
- "identity" : "apple_kit",
- "kind" : "remoteSourceControl",
- "location" : "git@github.com:radrootslabs/apple_kit.git",
- "state" : {
- "branch" : "master",
- "revision" : "7ef15f40dde6ce9b4da2409eac5cb2da2c97fb40"
- }
- }
- ],
- "version" : 3
-}
diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift
@@ -43,6 +43,9 @@ public final class AppState: ObservableObject {
@Published public private(set) var relayLastError: String?
@Published public private(set) var fileAccessProbeValue: String?
@Published public private(set) var documentInterchangeProbeValue: String?
+ @Published public private(set) var locationCheckInState: FieldLocationCheckInState = .idle(
+ RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable)
+ )
public var canShowAppContent: Bool {
bootstrapPhase == .ready && runtimeIdentityReady && !isLocked
@@ -73,6 +76,7 @@ public final class AppState: ObservableObject {
private var statusTask: Task<Void, Never>?
private var secureIdentityStore: FieldSecureIdentityStore?
private var identityMetadataStore: FieldIdentityPublicMetadataStore?
+ private let locationCheckIn = FieldLocationCheckIn()
public init(radroots: Radroots = Radroots()) {
self.radroots = radroots
@@ -112,7 +116,7 @@ public final class AppState: ObservableObject {
} else {
loadStoredIdentityMetadata(metadataStore)
}
- try await refreshRuntimeState(using: service)
+ await refreshRuntimeState(using: service)
if runtimeIdentityReady && !isLocked {
try await connect(using: service)
startPollingStatus()
@@ -123,6 +127,7 @@ public final class AppState: ObservableObject {
identityResetObserved: false
)
try refreshDocumentInterchangeProbe(bundleIdentifier: appBundleIdentifier)
+ await refreshLocationCheckInStatus()
bootstrapPhase = .ready
} catch {
statusTask?.cancel()
@@ -220,6 +225,18 @@ public final class AppState: ObservableObject {
return service
}
+ public func refreshLocationCheckInStatus() async {
+ locationCheckInState = await locationCheckIn.status()
+ }
+
+ public func performLocationCheckIn() async {
+ let currentState = await locationCheckIn.status()
+ if let availability = currentState.availability {
+ locationCheckInState = .checking(availability)
+ }
+ locationCheckInState = await locationCheckIn.checkIn()
+ }
+
func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument {
try documentInterchange().prepareDiagnosticsExport(
infoJSONString: infoJSONString,
diff --git a/Radroots/Info.plist b/Radroots/Info.plist
@@ -20,6 +20,8 @@
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
+ <key>NSLocationWhenInUseUsageDescription</key>
+ <string>Radroots uses your location only when you tap Location Check-in.</string>
<key>GIT_SHA</key>
<string>$(GIT_SHA)</string>
diff --git a/Radroots/Runtime/FieldLocationCheckIn.swift b/Radroots/Runtime/FieldLocationCheckIn.swift
@@ -0,0 +1,98 @@
+import Foundation
+import RadrootsKit
+
+public struct FieldLocationCheckInReading: Equatable, Sendable {
+ public let latitude: Double
+ public let longitude: Double
+ public let horizontalAccuracyMeters: Double
+ public let capturedAt: Date
+
+ init(reading: RadrootsLocationReading) {
+ self.latitude = reading.coordinate.latitude
+ self.longitude = reading.coordinate.longitude
+ self.horizontalAccuracyMeters = reading.horizontalAccuracyMeters
+ self.capturedAt = reading.capturedAt
+ }
+
+ public var coordinateSummary: String {
+ String(format: "%.4f, %.4f", latitude, longitude)
+ }
+
+ public var accuracySummary: String {
+ String(format: "within %.0f m", horizontalAccuracyMeters)
+ }
+}
+
+public enum FieldLocationCheckInState: Equatable, Sendable {
+ case idle(RadrootsLocationServicesAvailability)
+ case checking(RadrootsLocationServicesAvailability)
+ case checkedIn(FieldLocationCheckInReading)
+ case failed(RadrootsLocationServicesAvailability?, String)
+
+ public var availability: RadrootsLocationServicesAvailability? {
+ switch self {
+ case .idle(let availability):
+ availability
+ case .checking(let availability):
+ availability
+ case .checkedIn:
+ nil
+ case .failed(let availability, _):
+ availability
+ }
+ }
+}
+
+public struct FieldLocationCheckIn: Sendable {
+ private let locationServices: any RadrootsLocationServices
+ private let request: RadrootsCurrentLocationRequest
+
+ public init(
+ locationServices: any RadrootsLocationServices = RadrootsAppleLocationServices(),
+ request: RadrootsCurrentLocationRequest = try! RadrootsCurrentLocationRequest(
+ timeoutSeconds: 10,
+ desiredAccuracyMeters: 100,
+ maximumCachedReadingAgeSeconds: 30
+ )
+ ) {
+ self.locationServices = locationServices
+ self.request = request
+ }
+
+ public func status() async -> FieldLocationCheckInState {
+ .idle(await locationServices.currentAvailability())
+ }
+
+ public func checkIn() async -> FieldLocationCheckInState {
+ let availability = await locationServices.currentAvailability()
+ guard availability.locationServicesEnabled else {
+ return .failed(availability, "Location Services are disabled.")
+ }
+ do {
+ if availability.authorization == .notDetermined {
+ let authorization = try await locationServices.requestWhenInUseAuthorization()
+ guard authorization == .authorizedWhenInUse || authorization == .authorizedAlways else {
+ return .failed(
+ RadrootsLocationServicesAvailability(
+ locationServicesEnabled: true,
+ authorization: authorization
+ ),
+ "Location permission was not granted."
+ )
+ }
+ }
+ let result = try await locationServices.currentLocation(request)
+ return .checkedIn(FieldLocationCheckInReading(reading: result.reading))
+ } catch RadrootsLocationServicesError.permissionDenied(let message) {
+ return .failed(availability, message)
+ } catch RadrootsLocationServicesError.unavailable(let message) {
+ return .failed(availability, message)
+ } catch RadrootsLocationServicesError.timeout(let message) {
+ return .failed(availability, message)
+ } catch RadrootsLocationServicesError.cancelled(let message) {
+ return .failed(availability, message)
+ } catch {
+ return .failed(availability, error.localizedDescription)
+ }
+ }
+}