field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

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:
MRadroots.xcodeproj/project.pbxproj | 4++++
DRadroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 15---------------
MRadroots/App/AppState.swift | 19++++++++++++++++++-
MRadroots/Info.plist | 2++
ARadroots/Runtime/FieldLocationCheckIn.swift | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } + } +}