diff --git a/IOSAccessAssessment.xcodeproj/project.pbxproj b/IOSAccessAssessment.xcodeproj/project.pbxproj index 4d1c4ceb..91c5216f 100644 --- a/IOSAccessAssessment.xcodeproj/project.pbxproj +++ b/IOSAccessAssessment.xcodeproj/project.pbxproj @@ -55,7 +55,7 @@ A355471E2EC1A47400F43AFD /* SharedAppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A355471D2EC1A47200F43AFD /* SharedAppData.swift */; }; A35547C22EC1AE4E00F43AFD /* SafeDeque.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35547C12EC1AE4C00F43AFD /* SafeDeque.swift */; }; A35547C42EC1AF5700F43AFD /* CaptureData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35547C32EC1AF5500F43AFD /* CaptureData.swift */; }; - A35547C82EC1B0DB00F43AFD /* MappingData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35547C72EC1B0D900F43AFD /* MappingData.swift */; }; + A35547C82EC1B0DB00F43AFD /* LiveMappingData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35547C72EC1B0D900F43AFD /* LiveMappingData.swift */; }; A35547CA2EC2045F00F43AFD /* CapturedMeshSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35547C92EC2045F00F43AFD /* CapturedMeshSnapshot.swift */; }; A35547CC2EC3018E00F43AFD /* AnnotationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35547CB2EC3018C00F43AFD /* AnnotationView.swift */; }; A35547CE2EC3048700F43AFD /* AnnotationImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35547CD2EC3048200F43AFD /* AnnotationImageViewController.swift */; }; @@ -65,7 +65,7 @@ A35E050A2EDE299F003C26CF /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35E05092EDE2999003C26CF /* LocationManager.swift */; }; A35E050D2EDE35E1003C26CF /* LocalizationProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35E050C2EDE35DE003C26CF /* LocalizationProcessor.swift */; }; A35E05102EDE60C0003C26CF /* InvalidContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35E050F2EDE60BC003C26CF /* InvalidContentView.swift */; }; - A35E05162EDEA050003C26CF /* APITransmissionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35E05152EDEA04B003C26CF /* APITransmissionController.swift */; }; + A35E05162EDEA050003C26CF /* APIChangesetUploadController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35E05152EDEA04B003C26CF /* APIChangesetUploadController.swift */; }; A35E05182EDEA476003C26CF /* AttributeEstimationPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35E05172EDEA470003C26CF /* AttributeEstimationPipeline.swift */; }; A35E051A2EDFB017003C26CF /* OSMPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35E05192EDFB015003C26CF /* OSMPayload.swift */; }; A35E051C2EDFB094003C26CF /* OSMNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35E051B2EDFB093003C26CF /* OSMNode.swift */; }; @@ -76,10 +76,11 @@ A364B5DD2F259AFE00325E5C /* WorldPoints.metal in Sources */ = {isa = PBXBuildFile; fileRef = A364B5DC2F259AF900325E5C /* WorldPoints.metal */; }; A364B5DF2F26DB5700325E5C /* WorldPointsProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A364B5DE2F26DB5300325E5C /* WorldPointsProcessor.swift */; }; A36C6E022E134CE600A86004 /* bisenetv2_35_640_640.mlpackage in Sources */ = {isa = PBXBuildFile; fileRef = A36C6E012E134CE600A86004 /* bisenetv2_35_640_640.mlpackage */; }; - A374FAB72EE0173600055268 /* OSMResponseElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = A374FAB62EE0173200055268 /* OSMResponseElement.swift */; }; + A374FAB72EE0173600055268 /* OSMChangesetUploadResponseElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = A374FAB62EE0173200055268 /* OSMChangesetUploadResponseElement.swift */; }; A37C3C182F3141FF001F4248 /* Plane.metal in Sources */ = {isa = PBXBuildFile; fileRef = A37C3C172F3141F9001F4248 /* Plane.metal */; }; A37C3C1A2F3144F7001F4248 /* PlaneAttributeProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37C3C192F3144F4001F4248 /* PlaneAttributeProcessor.swift */; }; A37C3C1C2F356254001F4248 /* IntersectionFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37C3C1B2F356254001F4248 /* IntersectionFilter.swift */; }; + A37D4A2C2F79F9AC0025A91F /* CurrentMappingData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37D4A2B2F79F9AC0025A91F /* CurrentMappingData.swift */; }; A37E3E3C2EED60F300B07B77 /* PngEncoder.mm in Sources */ = {isa = PBXBuildFile; fileRef = A37E3E3B2EED60F300B07B77 /* PngEncoder.mm */; }; A37E3E3D2EED60F300B07B77 /* lodepng.cpp in Sources */ = {isa = PBXBuildFile; fileRef = A37E3E392EED60F300B07B77 /* lodepng.cpp */; }; A37E3E952EFB66EB00B07B77 /* CameraIntrinsicsCoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37E3E942EFB66E600B07B77 /* CameraIntrinsicsCoder.swift */; }; @@ -109,6 +110,8 @@ A3B2DDBF2DC99DEF003416FB /* HomographyRequestProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B2DDBE2DC99DE9003416FB /* HomographyRequestProcessor.swift */; }; A3B2DDC12DC99F44003416FB /* SegmentationModelRequestProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B2DDC02DC99F3D003416FB /* SegmentationModelRequestProcessor.swift */; }; A3B61FC52F76480B0052AE2C /* EnvironmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B61FC42F7647FC0052AE2C /* EnvironmentService.swift */; }; + A3B61FC92F78F93B0052AE2C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B61FC82F78F9390052AE2C /* Extensions.swift */; }; + A3B61FCB2F79036A0052AE2C /* OSMMapDataResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B61FCA2F7903660052AE2C /* OSMMapDataResponse.swift */; }; A3BB5AFB2DB210AE008673ED /* BinaryMaskFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BB5AFA2DB210A8008673ED /* BinaryMaskFilter.swift */; }; A3BCBC502EFBB92900D15E15 /* AccessibilityFeatureEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BCBC4F2EFBB92500D15E15 /* AccessibilityFeatureEncoder.swift */; }; A3C22FD32CF194A600533BF7 /* CGImageUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C22FD22CF194A200533BF7 /* CGImageUtils.swift */; }; @@ -258,7 +261,7 @@ A355471D2EC1A47200F43AFD /* SharedAppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedAppData.swift; sourceTree = ""; }; A35547C12EC1AE4C00F43AFD /* SafeDeque.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeDeque.swift; sourceTree = ""; }; A35547C32EC1AF5500F43AFD /* CaptureData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureData.swift; sourceTree = ""; }; - A35547C72EC1B0D900F43AFD /* MappingData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MappingData.swift; sourceTree = ""; }; + A35547C72EC1B0D900F43AFD /* LiveMappingData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMappingData.swift; sourceTree = ""; }; A35547C92EC2045F00F43AFD /* CapturedMeshSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturedMeshSnapshot.swift; sourceTree = ""; }; A35547CB2EC3018C00F43AFD /* AnnotationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationView.swift; sourceTree = ""; }; A35547CD2EC3048200F43AFD /* AnnotationImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationImageViewController.swift; sourceTree = ""; }; @@ -268,7 +271,7 @@ A35E05092EDE2999003C26CF /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = ""; }; A35E050C2EDE35DE003C26CF /* LocalizationProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationProcessor.swift; sourceTree = ""; }; A35E050F2EDE60BC003C26CF /* InvalidContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvalidContentView.swift; sourceTree = ""; }; - A35E05152EDEA04B003C26CF /* APITransmissionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITransmissionController.swift; sourceTree = ""; }; + A35E05152EDEA04B003C26CF /* APIChangesetUploadController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIChangesetUploadController.swift; sourceTree = ""; }; A35E05172EDEA470003C26CF /* AttributeEstimationPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeEstimationPipeline.swift; sourceTree = ""; }; A35E05192EDFB015003C26CF /* OSMPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMPayload.swift; sourceTree = ""; }; A35E051B2EDFB093003C26CF /* OSMNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMNode.swift; sourceTree = ""; }; @@ -279,10 +282,11 @@ A364B5DC2F259AF900325E5C /* WorldPoints.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = WorldPoints.metal; sourceTree = ""; }; A364B5DE2F26DB5300325E5C /* WorldPointsProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldPointsProcessor.swift; sourceTree = ""; }; A36C6E012E134CE600A86004 /* bisenetv2_35_640_640.mlpackage */ = {isa = PBXFileReference; lastKnownFileType = folder.mlpackage; path = bisenetv2_35_640_640.mlpackage; sourceTree = ""; }; - A374FAB62EE0173200055268 /* OSMResponseElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMResponseElement.swift; sourceTree = ""; }; + A374FAB62EE0173200055268 /* OSMChangesetUploadResponseElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMChangesetUploadResponseElement.swift; sourceTree = ""; }; A37C3C172F3141F9001F4248 /* Plane.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Plane.metal; sourceTree = ""; }; A37C3C192F3144F4001F4248 /* PlaneAttributeProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaneAttributeProcessor.swift; sourceTree = ""; }; A37C3C1B2F356254001F4248 /* IntersectionFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntersectionFilter.swift; sourceTree = ""; }; + A37D4A2B2F79F9AC0025A91F /* CurrentMappingData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentMappingData.swift; sourceTree = ""; }; A37E3E382EED60F300B07B77 /* lodepng.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = lodepng.h; sourceTree = ""; }; A37E3E392EED60F300B07B77 /* lodepng.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = lodepng.cpp; sourceTree = ""; }; A37E3E3A2EED60F300B07B77 /* PngEncoder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PngEncoder.h; sourceTree = ""; }; @@ -314,6 +318,8 @@ A3B2DDBE2DC99DE9003416FB /* HomographyRequestProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomographyRequestProcessor.swift; sourceTree = ""; }; A3B2DDC02DC99F3D003416FB /* SegmentationModelRequestProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentationModelRequestProcessor.swift; sourceTree = ""; }; A3B61FC42F7647FC0052AE2C /* EnvironmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentService.swift; sourceTree = ""; }; + A3B61FC82F78F9390052AE2C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + A3B61FCA2F7903660052AE2C /* OSMMapDataResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSMMapDataResponse.swift; sourceTree = ""; }; A3BB5AFA2DB210A8008673ED /* BinaryMaskFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryMaskFilter.swift; sourceTree = ""; }; A3BCBC4F2EFBB92500D15E15 /* AccessibilityFeatureEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityFeatureEncoder.swift; sourceTree = ""; }; A3C22FD22CF194A200533BF7 /* CGImageUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImageUtils.swift; sourceTree = ""; }; @@ -700,6 +706,7 @@ A35547C02EC1AE4600F43AFD /* Utils */ = { isa = PBXGroup; children = ( + A3B61FC82F78F9390052AE2C /* Extensions.swift */, A35547C12EC1AE4C00F43AFD /* SafeDeque.swift */, A3DA4DB02EB99A5A005BB812 /* MetalBufferUtils.swift */, ); @@ -748,7 +755,7 @@ A35E05142EDE7494003C26CF /* Transmission */ = { isa = PBXGroup; children = ( - A35E05152EDEA04B003C26CF /* APITransmissionController.swift */, + A35E05152EDEA04B003C26CF /* APIChangesetUploadController.swift */, A3EE6E512F5F9F1100F515E6 /* APITransmissionHelpers.swift */, ); path = Transmission; @@ -758,12 +765,13 @@ isa = PBXGroup; children = ( A39C9F3A2DD9B03000455E45 /* OSMElement.swift */, - A374FAB62EE0173200055268 /* OSMResponseElement.swift */, A35E05192EDFB015003C26CF /* OSMPayload.swift */, A35E051B2EDFB093003C26CF /* OSMNode.swift */, A35E051D2EDFB099003C26CF /* OSMWay.swift */, A329434F2EE80EC200C4C1BC /* OSMRelation.swift */, A3EE6EFF2F6A29F300F515E6 /* OSMLocation.swift */, + A374FAB62EE0173200055268 /* OSMChangesetUploadResponseElement.swift */, + A3B61FCA2F7903660052AE2C /* OSMMapDataResponse.swift */, ); path = OSM; sourceTree = ""; @@ -849,7 +857,8 @@ children = ( A3EE6E452F57FE6200F515E6 /* AppMode.swift */, A35547C32EC1AF5500F43AFD /* CaptureData.swift */, - A35547C72EC1B0D900F43AFD /* MappingData.swift */, + A35547C72EC1B0D900F43AFD /* LiveMappingData.swift */, + A37D4A2B2F79F9AC0025A91F /* CurrentMappingData.swift */, A3DA4DBD2EBCB9F9005BB812 /* MetalContext.swift */, A3A413A32ECD3C7B0039298C /* RasterizeConfig.swift */, ); @@ -1273,6 +1282,7 @@ A3BB5AFB2DB210AE008673ED /* BinaryMaskFilter.swift in Sources */, A355471E2EC1A47400F43AFD /* SharedAppData.swift in Sources */, A3B2DDC12DC99F44003416FB /* SegmentationModelRequestProcessor.swift in Sources */, + A37D4A2C2F79F9AC0025A91F /* CurrentMappingData.swift in Sources */, A3B2DDBF2DC99DEF003416FB /* HomographyRequestProcessor.swift in Sources */, A3FFAA802DE444C6002B99BD /* AnnotationOption.swift in Sources */, CAF812C42CFA108100D44B84 /* UserStateViewModel.swift in Sources */, @@ -1288,7 +1298,7 @@ A308015C2EC09BB700B1BA3A /* CityscapesClassConfig.swift in Sources */, A308015D2EC09BB700B1BA3A /* CityscapesSubsetClassConfig.swift in Sources */, A364B5352F25589B00325E5C /* DepthFiltering.metal in Sources */, - A35547C82EC1B0DB00F43AFD /* MappingData.swift in Sources */, + A35547C82EC1B0DB00F43AFD /* LiveMappingData.swift in Sources */, A37E3EA02EFBAADD00B07B77 /* AccessibilityFeatureClassSnapshot.swift in Sources */, A35547C42EC1AF5700F43AFD /* CaptureData.swift in Sources */, A3EE6E462F57FE6400F515E6 /* AppMode.swift in Sources */, @@ -1301,13 +1311,14 @@ A35E051A2EDFB017003C26CF /* OSMPayload.swift in Sources */, A30801612EC09BB700B1BA3A /* CocoCustom53ClassConfig.swift in Sources */, A30BED382ED162E7004A5B51 /* MeshDefinitions.swift in Sources */, - A374FAB72EE0173600055268 /* OSMResponseElement.swift in Sources */, + A374FAB72EE0173600055268 /* OSMChangesetUploadResponseElement.swift in Sources */, A35547C22EC1AE4E00F43AFD /* SafeDeque.swift in Sources */, A3C55A492EAFFABF00F6CFDC /* CenterCropTransformUtilsExtension.swift in Sources */, A3EE6E4A2F580D6200F515E6 /* TestCameraView.swift in Sources */, A329433C2EE7BEE100C4C1BC /* OSWPolicy.swift in Sources */, A38338BF2EDA889C00F1A402 /* CustomPicker.swift in Sources */, A32943592EE8204400C4C1BC /* OSWPolygon.swift in Sources */, + A3B61FCB2F79036A0052AE2C /* OSMMapDataResponse.swift in Sources */, CAF812BC2CF78F8100D44B84 /* NetworkError.swift in Sources */, A305B06C2E18A85F00ECCF9B /* DepthCoder.swift in Sources */, A3DA4DBC2EBCB881005BB812 /* SegmentationMeshRecord.swift in Sources */, @@ -1315,7 +1326,7 @@ A3FFAA7E2DE3E41D002B99BD /* SegmentationARPipeline.swift in Sources */, A30BED3C2ED2F48B004A5B51 /* MeshClusteringUtils.swift in Sources */, A308016C2EC15CC400B1BA3A /* AccessibilityFeatureAttribute.swift in Sources */, - A35E05162EDEA050003C26CF /* APITransmissionController.swift in Sources */, + A35E05162EDEA050003C26CF /* APIChangesetUploadController.swift in Sources */, A36C6E022E134CE600A86004 /* bisenetv2_35_640_640.mlpackage in Sources */, A35BB2862DC30386009A3FE0 /* CameraOrientation.swift in Sources */, A35547CC2EC3018E00F43AFD /* AnnotationView.swift in Sources */, @@ -1424,6 +1435,7 @@ A30BED3A2ED162F1004A5B51 /* ConnectedComponents.swift in Sources */, A32943462EE7C07E00C4C1BC /* OSWGeometry.swift in Sources */, A35547CA2EC2045F00F43AFD /* CapturedMeshSnapshot.swift in Sources */, + A3B61FC92F78F93B0052AE2C /* Extensions.swift in Sources */, DAA7F8B52CA38C11003666D8 /* Constants.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/IOSAccessAssessment/ARCamera/ARCameraManager.swift b/IOSAccessAssessment/ARCamera/ARCameraManager.swift index d015d4da..3d9a1c72 100644 --- a/IOSAccessAssessment/ARCamera/ARCameraManager.swift +++ b/IOSAccessAssessment/ARCamera/ARCameraManager.swift @@ -251,10 +251,8 @@ final class ARCameraManager: NSObject, ObservableObject, ARSessionCameraProcessi self.cameraOutputImageCallback = cameraOutputImageCallback self.isConfigured = true - Task { - await MainActor.run { - self.capturedMeshSnapshotGenerator = CapturedMeshSnapshotGenerator() - } + Task { @MainActor in + self.capturedMeshSnapshotGenerator = CapturedMeshSnapshotGenerator() } } @@ -268,10 +266,8 @@ final class ARCameraManager: NSObject, ObservableObject, ARSessionCameraProcessi } func setOrientation(_ orientation: UIInterfaceOrientation) { - Task { - await MainActor.run { - self.interfaceOrientation = orientation - } + Task { @MainActor in + self.interfaceOrientation = orientation } } diff --git a/IOSAccessAssessment/AccessibilityFeature/AttributeEstimation/Localization/LocationManager.swift b/IOSAccessAssessment/AccessibilityFeature/AttributeEstimation/Localization/LocationManager.swift index c181b4ef..2f967ec6 100644 --- a/IOSAccessAssessment/AccessibilityFeature/AttributeEstimation/Localization/LocationManager.swift +++ b/IOSAccessAssessment/AccessibilityFeature/AttributeEstimation/Localization/LocationManager.swift @@ -6,6 +6,34 @@ // import CoreLocation +import UIKit +import MapKit + +struct BBox { + let minLat: Double + let maxLat: Double + let minLon: Double + let maxLon: Double + + func toQueryString() -> String { + return "\(minLon.roundedTo7Digits()),\(minLat.roundedTo7Digits()),\(maxLon.roundedTo7Digits()),\(maxLat.roundedTo7Digits())" + } +} + +class LocationHelpers { + static func boundingBoxAroundLocation(location: CLLocationCoordinate2D, radius: CLLocationDistance) -> BBox { + let region = MKCoordinateRegion(center: location, latitudinalMeters: radius, longitudinalMeters: radius) + let center = region.center + let span = region.span + let minLat = center.latitude - span.latitudeDelta + let maxLat = center.latitude + span.latitudeDelta + let minLon = center.longitude - span.longitudeDelta + let maxLon = center.longitude + span.longitudeDelta + + return BBox(minLat: minLat, maxLat: maxLat, minLon: minLon, maxLon: maxLon) + } +} + enum LocationManagerError: Error, LocalizedError { case locationUnavailable @@ -24,49 +52,80 @@ enum LocationManagerError: Error, LocalizedError { /** A wrapper around CLLocationManager to manage location and heading updates in a more controlled and safe manner. */ -class LocationManager { - let locationManager: CLLocationManager +@MainActor +class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { + private let locationManager: CLLocationManager = CLLocationManager() + + @Published var currentLocation: CLLocation? + @Published var currentHeading: CLHeading? + + override init() { + super.init() + } - init() { - self.locationManager = CLLocationManager() - self.setupLocationManager() + func startLocationUpdates() { + setupLocationManager() } private func setupLocationManager() { + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation locationManager.distanceFilter = kCLDistanceFilterNone + // TODO: Sync heading with the device orientation locationManager.headingOrientation = .portrait locationManager.headingFilter = kCLHeadingFilterNone + locationManager.pausesLocationUpdatesAutomatically = false // Prevent auto-pausing + locationManager.requestWhenInUseAuthorization() locationManager.startUpdatingLocation() locationManager.startUpdatingHeading() } - func getLocation() throws -> CLLocation { - guard let location = locationManager.location, - location.horizontalAccuracy > 0, location.verticalAccuracy > 0 else { - throw LocationManagerError.locationUnavailable + /** + Updates the heading orientation of the location manager based on the current device orientation. This ensures that heading data is accurate and consistent with the user's perspective. + */ + public func updateOrientation(_ orientation: UIInterfaceOrientation) { + switch orientation { + case .portrait: + locationManager.headingOrientation = .portrait + case .portraitUpsideDown: + locationManager.headingOrientation = .portraitUpsideDown + /// Flipped because the heading is relative to the device's top, which is opposite in landscape orientations + case .landscapeLeft: + locationManager.headingOrientation = .landscapeRight + case .landscapeRight: + locationManager.headingOrientation = .landscapeLeft + default: + locationManager.headingOrientation = .portrait } - return location } - private func getHeading() throws -> CLHeading { - guard let heading = locationManager.heading, - heading.headingAccuracy > 0 else { - throw LocationManagerError.headingUnavailable + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let latestLocation = locations.last else { return } + guard let horizontalAccuracy = latestLocation.horizontalAccuracy as CLLocationAccuracy?, + let verticalAccuracy = latestLocation.verticalAccuracy as CLLocationAccuracy?, + horizontalAccuracy > 0, verticalAccuracy > 0 else { + return + } + Task { @MainActor in + self.currentLocation = latestLocation } - return heading } - func getLocationCoordinate() throws -> CLLocationCoordinate2D { - let location = try getLocation() - return location.coordinate + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + guard let headingAccuracy = newHeading.headingAccuracy as CLLocationDirection?, + headingAccuracy > 0 else { + return + } + Task { @MainActor in + self.currentHeading = newHeading + } } - func getHeadingDegrees() throws -> CLLocationDirection { - let heading = try getHeading() - return heading.trueHeading + func stopLocationUpdates() { + locationManager.stopUpdatingLocation() } } diff --git a/IOSAccessAssessment/Shared/Constants.swift b/IOSAccessAssessment/Shared/Constants.swift index c9df153e..3c844e63 100644 --- a/IOSAccessAssessment/Shared/Constants.swift +++ b/IOSAccessAssessment/Shared/Constants.swift @@ -28,6 +28,10 @@ struct Constants { // ["1463"] // ["288", "349", "1411"] // "252", "322", "368", "374", "378", "381", "384", "323", "369", "156", "375", "379"] + + static let fetchRadiusInMeters: Double = 100.0 + static let fetchUpdateRadiusThresholdInMeters: Double = 50.0 + static let updateElementDistanceThresholdInMeters: Double = 20.0 } struct OtherConstants { diff --git a/IOSAccessAssessment/Shared/Definitions/CurrentMappingData.swift b/IOSAccessAssessment/Shared/Definitions/CurrentMappingData.swift new file mode 100644 index 00000000..504e068f --- /dev/null +++ b/IOSAccessAssessment/Shared/Definitions/CurrentMappingData.swift @@ -0,0 +1,116 @@ +// +// CurrentMappingData.swift +// IOSAccessAssessment +// +// Created by Himanshu on 11/9/25. +// + +import Foundation +import CoreLocation + +enum CurrentMappingDataError: Error, LocalizedError { +} + +class CurrentMappingData: CustomStringConvertible { + + var featuresMap: [AccessibilityFeatureClass: [any OSWElement]] = [:] +// var otherFeatures: [OSWElement] = [] + + init() { + + } + + var description: String { + var description = "CurrentMappingData:\n" + for (featureClass, features) in featuresMap { + description += "- \(featureClass.name): \(features.count) features\n" + } + return description + } + + init(osmMapDataResponse: OSMMapDataResponse, accessibilityFeatureClasses: [AccessibilityFeatureClass]) { + self.featuresMap = getFeatures(with: osmMapDataResponse, accessibilityFeatureClasses: accessibilityFeatureClasses) + print("Initialized features map with OSM data. \n\(description)") + } + + /// Note: Replaces the feature map instead of incrementally updating it. + func update(osmMapDataResponse: OSMMapDataResponse, accessibilityFeatureClasses: [AccessibilityFeatureClass]) { + self.featuresMap = getFeatures(with: osmMapDataResponse, accessibilityFeatureClasses: accessibilityFeatureClasses) + print("Updated features map with new OSM data. \n\(description)") + } + + func getFeatures( + with osmMapDataResponse: OSMMapDataResponse, accessibilityFeatureClasses: [AccessibilityFeatureClass] + ) -> [AccessibilityFeatureClass: [any OSWElement]] { + var featuresMap: [AccessibilityFeatureClass: [any OSWElement]] = [:] + let osmMapDataResponseElements: [OSMMapDataResponseElement] = osmMapDataResponse.elements + let osmElements: [any OSMElement] = osmMapDataResponseElements.compactMap { element in + return element.toOSMElement() + } + var featureNodes: [String: OSMNode] = [:] + var featureWays: [String: OSMWay] = [:] + var featureRelations: [String: OSMRelation] = [:] + osmElements.forEach { osmElement in + if let osmNode = osmElement as? OSMNode { + featureNodes[osmElement.id] = osmNode + } else if let osmWay = osmElement as? OSMWay { + featureWays[osmElement.id] = osmWay + } else if let osmRelation = osmElement as? OSMRelation { + featureRelations[osmElement.id] = osmRelation + } + } + + for featureClass in accessibilityFeatureClasses { + if featuresMap[featureClass] == nil { + featuresMap[featureClass] = [] + } + let oswElementClass = featureClass.oswPolicy.oswElementClass + let geometry = oswElementClass.geometry + let identifyingFieldTags: [String: String] = oswElementClass.identifyingFieldTags + + switch geometry.osmElementType { + case .node: + let matchingOSWPoints: [OSWPoint] = featureNodes.values.filter { node in + return identifyingFieldTags.allSatisfy { tagKey, tagValue in + return node.tags[tagKey] == tagValue + } + }.compactMap { node in + return OSWPoint(osmNode: node, oswElementClass: oswElementClass) + } + featuresMap[featureClass]?.append(contentsOf: matchingOSWPoints) + case .way: + let matchingOSWLineStrings: [OSWLineString] = featureWays.values.filter { way in + return identifyingFieldTags.allSatisfy { tagKey, tagValue in + return way.tags[tagKey] == tagValue + } + }.compactMap { way in + return OSWLineString( + osmWay: way, oswElementClass: oswElementClass, + osmNodes: Array(featureNodes.values) + ) + } + featuresMap[featureClass]?.append(contentsOf: matchingOSWLineStrings) + case .relation: + let matchingOSWPolygons: [OSWPolygon] = featureRelations.values.filter { relation in + return identifyingFieldTags.allSatisfy { tagKey, tagValue in + return relation.tags[tagKey] == tagValue + } + }.compactMap { relation in + return OSWPolygon( + osmRelation: relation, oswElementClass: oswElementClass, + osmMemberElements: osmElements + ) + } + featuresMap[featureClass]?.append(contentsOf: matchingOSWPolygons) + } + } + return featuresMap + } + + func getNearestFeature( + to location: CLLocationCoordinate2D, featureClass: AccessibilityFeatureClass, + distanceThreshold: CLLocationDistance = 50.0 + ) -> (any OSWElement)? { + return nil + } +} diff --git a/IOSAccessAssessment/Shared/Definitions/MappingData.swift b/IOSAccessAssessment/Shared/Definitions/LiveMappingData.swift similarity index 93% rename from IOSAccessAssessment/Shared/Definitions/MappingData.swift rename to IOSAccessAssessment/Shared/Definitions/LiveMappingData.swift index f364b889..f80d8bf4 100644 --- a/IOSAccessAssessment/Shared/Definitions/MappingData.swift +++ b/IOSAccessAssessment/Shared/Definitions/LiveMappingData.swift @@ -1,5 +1,5 @@ // -// MapData.swift +// LiveMappingData.swift // IOSAccessAssessment // // Created by Himanshu on 11/9/25. @@ -7,7 +7,7 @@ import Foundation -enum MappingDataError: Error, LocalizedError { +enum LiveMappingDataError: Error, LocalizedError { case accessibilityFeatureClassNotWay(AccessibilityFeatureClass) case noActiveWayForFeatureClass(AccessibilityFeatureClass) case accessibilityFeatureNodeNotPresent(AccessibilityFeatureClass) @@ -24,7 +24,7 @@ enum MappingDataError: Error, LocalizedError { } } -class MappingData: CustomStringConvertible { +class LiveMappingData: CustomStringConvertible { var featuresMap: [AccessibilityFeatureClass: [MappedAccessibilityFeature]] = [:] var featureIdToIndexDictMap: [AccessibilityFeatureClass: [UUID: Int]] = [:] @@ -51,7 +51,7 @@ class MappingData: CustomStringConvertible { } var description: String { - var desc = "MappingData:\n" + var desc = "LiveMappingData:\n" desc += "Feature Nodes:\n" featuresMap.forEach { (featureClass, featureData) in return featureData.forEach { feature in diff --git a/IOSAccessAssessment/Shared/SharedAppData.swift b/IOSAccessAssessment/Shared/SharedAppData.swift index ab059a50..e82195f6 100644 --- a/IOSAccessAssessment/Shared/SharedAppData.swift +++ b/IOSAccessAssessment/Shared/SharedAppData.swift @@ -21,7 +21,8 @@ final class SharedAppData: ObservableObject { var captureDataQueue: SafeDeque var captureDataCapacity: Int - var mappingData: MappingData = MappingData() + var currentMappingData: CurrentMappingData = CurrentMappingData() + var liveMappingData: LiveMappingData = LiveMappingData() init(captureDataCapacity: Int = 5) { self.captureDataCapacity = captureDataCapacity @@ -37,6 +38,8 @@ final class SharedAppData: ObservableObject { self.currentDatasetEncoder = nil self.currentDatasetDecoder = nil self.currentCaptureDataRecord = nil + self.currentMappingData = CurrentMappingData() + self.liveMappingData = LiveMappingData() } func saveCaptureData(_ data: CaptureData) { diff --git a/IOSAccessAssessment/Shared/Utils/Extensions.swift b/IOSAccessAssessment/Shared/Utils/Extensions.swift new file mode 100644 index 00000000..0c8646fb --- /dev/null +++ b/IOSAccessAssessment/Shared/Utils/Extensions.swift @@ -0,0 +1,14 @@ +// +// Extensions.swift +// IOSAccessAssessment +// +// Created by Himanshu on 3/28/26. +// + +import Foundation + +extension Double { + func roundedTo7Digits() -> Double { + (self * 1_000_0000).rounded() / 1_000_0000 + } +} diff --git a/IOSAccessAssessment/TDEI/Config/APIEndpoint.swift b/IOSAccessAssessment/TDEI/Config/APIEndpoint.swift index 1f39f28c..7dba8a50 100644 --- a/IOSAccessAssessment/TDEI/Config/APIEndpoint.swift +++ b/IOSAccessAssessment/TDEI/Config/APIEndpoint.swift @@ -31,6 +31,11 @@ struct APIEndpoint { return baseURL?.appending(path: "workspaces/mine") } + static let getMapData = { (environment: APIEnvironment) in + let baseURL = URL(string: environment.osmBaseURL) + return baseURL?.appending(path: "map.json") + } + static let createChangeset = { (environment: APIEnvironment) in let baseURL = URL(string: environment.osmBaseURL) return baseURL?.appending(path: "changeset/create") diff --git a/IOSAccessAssessment/TDEI/Config/APIEnvironment.swift b/IOSAccessAssessment/TDEI/Config/APIEnvironment.swift index cf92fbf3..e9f8c0ba 100644 --- a/IOSAccessAssessment/TDEI/Config/APIEnvironment.swift +++ b/IOSAccessAssessment/TDEI/Config/APIEnvironment.swift @@ -10,7 +10,7 @@ import Foundation /** TODO: Use this to replace the environment-agnostic APIConstants */ -enum APIEnvironment: String, CaseIterable, Hashable { +enum APIEnvironment: String, CaseIterable, Sendable, Hashable { // case development = "Development" case staging = "Staging" // case production = "Production" diff --git a/IOSAccessAssessment/TDEI/Helpers/CustomXMLParser.swift b/IOSAccessAssessment/TDEI/Helpers/CustomXMLParser.swift index 8d73badc..8818cd6f 100644 --- a/IOSAccessAssessment/TDEI/Helpers/CustomXMLParser.swift +++ b/IOSAccessAssessment/TDEI/Helpers/CustomXMLParser.swift @@ -11,9 +11,9 @@ class ChangesetXMLParser: NSObject, XMLParserDelegate { var currentElement = "" var parsedItems: [String] = [] - var nodesWithAttributes: [String: OSMResponseNode] = [:] - var waysWithAttributes: [String: OSMResponseWay] = [:] - var relationsWithAttributes: [String: OSMResponseRelation] = [:] + var nodesWithAttributes: [String: OSMChangesetUploadResponseNode] = [:] + var waysWithAttributes: [String: OSMChangesetUploadResponseWay] = [:] + var relationsWithAttributes: [String: OSMChangesetUploadResponseRelation] = [:] func parse(data: Data) { let parser = XMLParser(data: data) @@ -33,7 +33,7 @@ class ChangesetXMLParser: NSObject, XMLParserDelegate { let newVersion = attributeDict[APIConstants.AttributeKeys.newVersion] else { return } - nodesWithAttributes[oldId] = OSMResponseNode( + nodesWithAttributes[oldId] = OSMChangesetUploadResponseNode( oldId: oldId, newId: newId, newVersion: newVersion, attributeDict: attributeDict ) } @@ -43,7 +43,7 @@ class ChangesetXMLParser: NSObject, XMLParserDelegate { let newVersion = attributeDict[APIConstants.AttributeKeys.newVersion] else { return } - waysWithAttributes[oldId] = OSMResponseWay( + waysWithAttributes[oldId] = OSMChangesetUploadResponseWay( oldId: oldId, newId: newId, newVersion: newVersion, attributeDict: attributeDict ) } @@ -53,7 +53,7 @@ class ChangesetXMLParser: NSObject, XMLParserDelegate { let newVersion = attributeDict[APIConstants.AttributeKeys.newVersion] else { return } - relationsWithAttributes[oldId] = OSMResponseRelation( + relationsWithAttributes[oldId] = OSMChangesetUploadResponseRelation( oldId: oldId, newId: newId, newVersion: newVersion, attributeDict: attributeDict ) } diff --git a/IOSAccessAssessment/TDEI/OSM/OSMResponseElement.swift b/IOSAccessAssessment/TDEI/OSM/OSMChangesetUploadResponseElement.swift similarity index 52% rename from IOSAccessAssessment/TDEI/OSM/OSMResponseElement.swift rename to IOSAccessAssessment/TDEI/OSM/OSMChangesetUploadResponseElement.swift index d85d0b7b..5907118d 100644 --- a/IOSAccessAssessment/TDEI/OSM/OSMResponseElement.swift +++ b/IOSAccessAssessment/TDEI/OSM/OSMChangesetUploadResponseElement.swift @@ -1,5 +1,5 @@ // -// OSMResponseElement.swift +// OSMChangesetUploadResponseElement.swift // IOSAccessAssessment // // Created by Himanshu on 12/2/25. @@ -7,13 +7,13 @@ import Foundation -protocol OSMResponseElement: Sendable, Equatable, Hashable { +protocol OSMChangesetUploadResponseElement: Sendable, Equatable, Hashable { var oldId: String { get } var newId: String { get } var newVersion: String { get } } -struct OSMResponseNode: OSMResponseElement { +struct OSMChangesetUploadResponseNode: OSMChangesetUploadResponseElement { let oldId: String let newId: String let newVersion: String @@ -28,7 +28,7 @@ struct OSMResponseNode: OSMResponseElement { } } -struct OSMResponseWay: OSMResponseElement { +struct OSMChangesetUploadResponseWay: OSMChangesetUploadResponseElement { let oldId: String let newId: String let newVersion: String @@ -43,7 +43,7 @@ struct OSMResponseWay: OSMResponseElement { } } -struct OSMResponseRelation: OSMResponseElement { +struct OSMChangesetUploadResponseRelation: OSMChangesetUploadResponseElement { let oldId: String let newId: String let newVersion: String @@ -58,4 +58,22 @@ struct OSMResponseRelation: OSMResponseElement { } } - +struct OSMChangesetUploadResponseElements: Sendable { + let nodes: [String: OSMChangesetUploadResponseNode] + let ways: [String: OSMChangesetUploadResponseWay] + let relations: [String: OSMChangesetUploadResponseRelation] + + var oldToNewIdMap: [String: String] { + let nodeMap = nodes.reduce(into: [String: String]()) { dict, pair in + dict[pair.value.oldId] = pair.value.newId + } + let wayMap = ways.reduce(into: [String: String]()) { dict, pair in + dict[pair.value.oldId] = pair.value.newId + } + let relationMap = relations.reduce(into: [String: String]()) { dict, pair in + dict[pair.value.oldId] = pair.value.newId + } + return nodeMap.merging(wayMap) { _, new in new } + .merging(relationMap) { _, new in new } + } +} diff --git a/IOSAccessAssessment/TDEI/OSM/OSMElement.swift b/IOSAccessAssessment/TDEI/OSM/OSMElement.swift index e83980d6..f8dfec22 100644 --- a/IOSAccessAssessment/TDEI/OSM/OSMElement.swift +++ b/IOSAccessAssessment/TDEI/OSM/OSMElement.swift @@ -5,11 +5,18 @@ // Created by Himanshu on 5/17/25. // -protocol OSMElement: Sendable, Equatable { +protocol OSMElement: Sendable, Equatable, Codable { var id: String { get } var version: String { get } + var tags: [String: String] { get } func toOSMCreateXML(changesetId: String) -> String func toOSMModifyXML(changesetId: String) -> String func toOSMDeleteXML(changesetId: String) -> String } + +enum OSMElementType: String, Codable { + case node = "node" + case way = "way" + case relation = "relation" +} diff --git a/IOSAccessAssessment/TDEI/OSM/OSMMapDataResponse.swift b/IOSAccessAssessment/TDEI/OSM/OSMMapDataResponse.swift new file mode 100644 index 00000000..d2379dbd --- /dev/null +++ b/IOSAccessAssessment/TDEI/OSM/OSMMapDataResponse.swift @@ -0,0 +1,100 @@ +// +// OSMMapDataResponse.swift +// IOSAccessAssessment +// +// Created by Himanshu on 3/28/26. +// + +import Foundation + +enum OSMMapDataResponseError: Error, LocalizedError { + case invalidNodeCoordinates + + var errorDescription: String? { + switch self { + case .invalidNodeCoordinates: + return "Node has invalid coordinates." + } + } +} + +struct OSMMapDataResponse: Codable { + let version, generator, copyright: String + let attribution, license: String + let bounds: Bounds + let elements: [OSMMapDataResponseElement] + + func getOSMElements() -> [String: any OSMElement] { + var osmElements: [String: any OSMElement] = [:] + for element in elements { + if let osmElement = try? element.toOSMElement() { + osmElements["\(element.id)"] = osmElement + } + } + return osmElements + } +} + +struct Bounds: Codable { // use this for bounds in the map + public let minlat, minlon, maxlat, maxlon: Double +} + +struct OSMMapDataResponseElement: Codable { + public var isInteresting: Bool? = false + public var isSkippable: Bool? = false + public let type: OSMElementType + public let id: Int + public let lat, lon: Double? + public let timestamp: Date + public var version, changeset: Int + public let user: String + public let uid: Int + public let tags: [String: String] + public let nodes: [Int]? + public let members: [OSMRelationMember]? + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + isInteresting = try values.decodeIfPresent(Bool.self, forKey: .isInteresting) ?? false + isSkippable = try values.decodeIfPresent(Bool.self, forKey: .isSkippable) ?? false + id = try values.decodeIfPresent(Int.self, forKey: .id) ?? 0 + lat = try values.decodeIfPresent(Double.self, forKey: .lat) ?? 0.0 + lon = try values.decodeIfPresent(Double.self, forKey: .lon) ?? 0.0 + timestamp = try values.decodeIfPresent(Date.self, forKey: .timestamp) ?? Date() + version = try values.decodeIfPresent(Int.self, forKey: .version) ?? 0 + changeset = try values.decodeIfPresent(Int.self, forKey: .changeset) ?? 0 + user = try values.decodeIfPresent(String.self, forKey: .user) ?? "" + uid = try values.decodeIfPresent(Int.self, forKey: .uid) ?? 0 + tags = try values.decodeIfPresent([String: String].self, forKey: .tags) ?? [:] + nodes = try values.decodeIfPresent([Int].self, forKey: .nodes) ?? [] + type = try values.decodeIfPresent(OSMElementType.self, forKey: .type) ?? OSMElementType.node + members = try values.decodeIfPresent([OSMRelationMember].self, forKey: .members) ?? [] + } + + func toOSMElement() -> (any OSMElement)? { + switch type { + case .node: + return toOSMNode() + case .way: + return toOSMWay() + case .relation: + return toOSMRelation() + } + } +// + private func toOSMNode() -> OSMNode? { + guard let lat = lat, let lon = lon else { + return nil + } + return OSMNode(id: "\(id)", version: "\(version)", latitude: lat, longitude: lon, tags: tags) + } + + private func toOSMWay() -> OSMWay { + let nodeRefs = nodes?.map { "\($0)" } ?? [] + return OSMWay(id: "\(id)", version: "\(version)", tags: tags, nodeRefs: nodeRefs) + } + + private func toOSMRelation() -> OSMRelation { + return OSMRelation(id: "\(id)", version: "\(version)", tags: tags, members: members ?? []) + } +} diff --git a/IOSAccessAssessment/TDEI/OSM/OSMRelation.swift b/IOSAccessAssessment/TDEI/OSM/OSMRelation.swift index 059b7a9a..7c2d7462 100644 --- a/IOSAccessAssessment/TDEI/OSM/OSMRelation.swift +++ b/IOSAccessAssessment/TDEI/OSM/OSMRelation.swift @@ -7,18 +7,8 @@ import Foundation -enum OSMRelationMemberType: String, Sendable { - case node - case way - case relation - - var description: String { - return self.rawValue - } -} - -struct OSMRelationMember: Sendable, Equatable, Hashable { - let type: OSMRelationMemberType +struct OSMRelationMember: Sendable, Equatable, Hashable, Codable { + let type: OSMElementType let ref: String let role: String diff --git a/IOSAccessAssessment/TDEI/OSW/OSWElement.swift b/IOSAccessAssessment/TDEI/OSW/OSWElement.swift index d11b4c92..7e9dabe9 100644 --- a/IOSAccessAssessment/TDEI/OSW/OSWElement.swift +++ b/IOSAccessAssessment/TDEI/OSW/OSWElement.swift @@ -8,7 +8,7 @@ import Foundation import CoreLocation protocol OSWElement: Sendable, CustomStringConvertible { - var elementOSMString: String { get } + var osmElementType: OSMElementType { get } var id: String { get } var version: String { get } diff --git a/IOSAccessAssessment/TDEI/OSW/OSWGeometry.swift b/IOSAccessAssessment/TDEI/OSW/OSWGeometry.swift index 68c25ecf..e2e8b33d 100644 --- a/IOSAccessAssessment/TDEI/OSW/OSWGeometry.swift +++ b/IOSAccessAssessment/TDEI/OSW/OSWGeometry.swift @@ -20,4 +20,15 @@ enum OSWGeometry: String, CaseIterable, Hashable, Codable { return "Polygon" } } + + var osmElementType: OSMElementType { + switch self { + case .point: + return .node + case .linestring: + return .way + case .polygon: + return .way + } + } } diff --git a/IOSAccessAssessment/TDEI/OSW/OSWLineString.swift b/IOSAccessAssessment/TDEI/OSW/OSWLineString.swift index 5d59d7cc..9e520acf 100644 --- a/IOSAccessAssessment/TDEI/OSW/OSWLineString.swift +++ b/IOSAccessAssessment/TDEI/OSW/OSWLineString.swift @@ -9,7 +9,7 @@ import Foundation import CoreLocation struct OSWLineString: OSWElement { - let elementOSMString: String = "way" + let osmElementType: OSMElementType = .way let id: String let version: String @@ -41,6 +41,36 @@ struct OSWLineString: OSWElement { self.additionalTags = additionalTags } + /** + Initializes an OSWLineString from an OSMWay and its associated OSMNodes. + + - Parameters: + - osmWay: The OSMWay object representing the way element from OpenStreetMap. + - osmElementClass: The OSWElementClass that defines the classification of the way element. + - osmNodes: An array of OSMNode objects that are associated with the OSMWay. These nodes generally represent the points that make up the way. But they may contain additional nodes that are not part of the way, so we filter them based on the node references in the OSMWay. + */ + init( + osmWay: OSMWay, + oswElementClass: OSWElementClass, + osmNodes: [OSMNode] + ) { + self.id = osmWay.id + self.version = osmWay.version + self.oswElementClass = oswElementClass + self.attributeValues = [:] + self.calculatedAttributeValues = [:] + self.experimentalAttributeValues = [:] + let nodeRefs = osmWay.nodeRefs + let nodeRefSet = Set(nodeRefs) + self.points = osmNodes.compactMap { osmNode in + if !nodeRefSet.contains(osmNode.id) { + return nil + } + return OSWPoint(osmNode: osmNode, oswElementClass: oswElementClass) + } + self.additionalTags = osmWay.tags + } + var tags: [String: String] { var identifyingFieldTags: [String: String] = [:] if oswElementClass.geometry == .linestring { @@ -52,14 +82,22 @@ struct OSWLineString: OSWElement { if let calculatedAttributeValues { calculatedAttributeTags = getTagsFromAttributeValues(attributeValues: calculatedAttributeValues, isCalculated: true) } - let tags = identifyingFieldTags.merging(attributeTags) { _, new in - return new - }.merging(experimentalAttributeTags) { _, new in - return new - }.merging(calculatedAttributeTags) { _, new in - return new - }.merging(additionalTags) { _, new in - return new + /** + The merging strategy for tags is to prioritize as follows (high to low): + 1. Identifying Field Tags: These are derived from the OSWElementClass and are essential for defining the type of element. + 2. Attribute Tags: These are derived from the attribute values of the element. + 3. Experimental Attribute Tags: These are derived from the experimental attribute values and may represent new or in-testing features. + 4. Calculated Attribute Tags: These are derived from calculated attribute values and may represent attributes that are not directly set but inferred from other data. + 5. Additional Tags: These are any extra tags that may be added for specific use cases or to provide additional context. + */ + let tags = identifyingFieldTags.merging(attributeTags) { old, new in + return old + }.merging(experimentalAttributeTags) { old, new in + return old + }.merging(calculatedAttributeTags) { old, new in + return old + }.merging(additionalTags) { old, new in + return old } return tags } diff --git a/IOSAccessAssessment/TDEI/OSW/OSWPoint.swift b/IOSAccessAssessment/TDEI/OSW/OSWPoint.swift index cd80e458..87ad093d 100644 --- a/IOSAccessAssessment/TDEI/OSW/OSWPoint.swift +++ b/IOSAccessAssessment/TDEI/OSW/OSWPoint.swift @@ -9,7 +9,7 @@ import Foundation import CoreLocation struct OSWPoint: OSWElement { - let elementOSMString: String = "node" + let osmElementType: OSMElementType = .node let id: String let version: String @@ -43,6 +43,23 @@ struct OSWPoint: OSWElement { self.additionalTags = additionalTags } + init( + osmNode: OSMNode, + oswElementClass: OSWElementClass + ) { + self.id = osmNode.id + self.version = osmNode.version + self.oswElementClass = oswElementClass + self.latitude = osmNode.latitude + self.longitude = osmNode.longitude + self.attributeValues = [:] + self.calculatedAttributeValues = [:] + self.experimentalAttributeValues = [:] + /// NOTE: Some tags might actually correspond to attribute values, but these will be overwritten when the attribute values are set. + /// The OSM xml functions are designed such that attribute value tags take precedence over additional tags, so this should not cause any issues. + self.additionalTags = osmNode.tags + } + var tags: [String: String] { var identifyingFieldTags: [String: String] = [:] if oswElementClass.geometry == .point { @@ -54,14 +71,22 @@ struct OSWPoint: OSWElement { if let calculatedAttributeValues { calculatedAttributeTags = getTagsFromAttributeValues(attributeValues: calculatedAttributeValues, isCalculated: true) } - let tags = identifyingFieldTags.merging(attributeTags) { _, new in - return new - }.merging(experimentalAttributeTags) { _, new in - return new - }.merging(calculatedAttributeTags) { _, new in - return new - }.merging(additionalTags) { _, new in - return new + /** + The merging strategy for tags is to prioritize as follows (high to low): + 1. Identifying Field Tags: These are derived from the OSWElementClass and are essential for defining the type of element. + 2. Attribute Tags: These are derived from the attribute values of the element. + 3. Experimental Attribute Tags: These are derived from the experimental attribute values and may represent new or in-testing features. + 4. Calculated Attribute Tags: These are derived from calculated attribute values and may represent attributes that are not directly set but inferred from other data. + 5. Additional Tags: These are any extra tags that may be added for specific use cases or to provide additional context. + */ + let tags = identifyingFieldTags.merging(attributeTags) { old, new in + return old + }.merging(experimentalAttributeTags) { old, new in + return old + }.merging(calculatedAttributeTags) { old, new in + return old + }.merging(additionalTags) { old, new in + return old } return tags } diff --git a/IOSAccessAssessment/TDEI/OSW/OSWPolygon.swift b/IOSAccessAssessment/TDEI/OSW/OSWPolygon.swift index 0ea8a947..0aae8fac 100644 --- a/IOSAccessAssessment/TDEI/OSW/OSWPolygon.swift +++ b/IOSAccessAssessment/TDEI/OSW/OSWPolygon.swift @@ -23,12 +23,12 @@ struct OSWRelationMember: Sendable { } var toXML: String { - return "" + return "" } } struct OSWPolygon: OSWElement { - let elementOSMString: String = "relation" + let osmElementType: OSMElementType = .relation let id: String let version: String @@ -61,6 +61,93 @@ struct OSWPolygon: OSWElement { self.additionalTags = additionalTags } + /** + Initializes an OSWPolygon from an OSMRelation and its member elements. + + - Parameters: + - osmRelation: The OSMRelation object representing the relation element in OSM. + - oswElementClass: The OSWElementClass corresponding to the relation's tags. + - osmMemberElements: An array of OSWElement objects representing the members of the relation. This array can actually represent nested relations as discreet elements. This initializer is supposed to identify the members of the relation and assign them the correct roles. + */ + init( + osmRelation: OSMRelation, + oswElementClass: OSWElementClass, + osmMemberElements: [any OSMElement] + ) { + self.id = osmRelation.id + self.version = osmRelation.version + self.oswElementClass = oswElementClass + self.attributeValues = [:] + self.calculatedAttributeValues = [:] + self.experimentalAttributeValues = [:] + + let osmMemberRefs: [OSMRelationMember] = osmRelation.members + let osmNodeMemberRefs = osmMemberRefs.filter { $0.type == .node } + let osmWayMemberRefs = osmMemberRefs.filter { $0.type == .way } + let osmRelationMemberRefs = osmMemberRefs.filter { $0.type == .relation } + + let osmMemberElementsDict: [String: any OSMElement] = Dictionary( + uniqueKeysWithValues: osmMemberElements.map { ($0.id, $0) } + ) + let osmNodeElements: [OSMNode] = osmMemberElements.filter { element in + return osmNodeMemberRefs.contains { $0.ref == element.id } + }.compactMap { element in + return element as? OSMNode + } + let osmNodeElementsDict: [String: OSMNode] = Dictionary(uniqueKeysWithValues: osmNodeElements.map { ($0.id, $0) }) + let osmWayElements: [OSMWay] = osmMemberElements.filter { element in + return osmWayMemberRefs.contains { $0.ref == element.id } + }.compactMap { element in + return element as? OSMWay + } + let osmWayElementsDict: [String: OSMWay] = Dictionary(uniqueKeysWithValues: osmWayElements.map { ($0.id, $0) }) + let osmRelationElements: [OSMRelation] = osmMemberElements.filter { element in + return osmRelationMemberRefs.contains { $0.ref == element.id } + }.compactMap { element in + return element as? OSMRelation + } + let osmRelationElementsDict: [String: OSMRelation] = Dictionary( + uniqueKeysWithValues: osmRelationElements.map { ($0.id, $0) } + ) + + var oswRelationMembers: [OSWRelationMember] = [] + osmNodeMemberRefs.forEach { osmNodeMemberRef in + if let matchingOSMNodeElement = osmNodeElementsDict[osmNodeMemberRef.ref] { + let oswPoint: OSWPoint = OSWPoint(osmNode: matchingOSMNodeElement, oswElementClass: oswElementClass) + let oswRelationMember = OSWRelationMember(element: oswPoint, role: osmNodeMemberRef.role) + oswRelationMembers.append(oswRelationMember) + } + } + osmWayMemberRefs.forEach { osmWayMemberRef in + if let matchingOSMWayElement = osmWayElementsDict[osmWayMemberRef.ref] { + let matchingOSWWayNodes = matchingOSMWayElement.nodeRefs.compactMap { nodeRef in + return osmNodeElementsDict[nodeRef] + } + let oswLineString: OSWLineString = OSWLineString( + osmWay: matchingOSMWayElement, oswElementClass: oswElementClass, + osmNodes: matchingOSWWayNodes + ) + let oswRelationMember = OSWRelationMember(element: oswLineString, role: osmWayMemberRef.role) + oswRelationMembers.append(oswRelationMember) + } + } + osmRelationMemberRefs.forEach { osmRelationMemberRef in + if let matchingOSMRelationElement = osmRelationElementsDict[osmRelationMemberRef.ref] { + let matchingOSMRelationMemberElements = matchingOSMRelationElement.members.compactMap { memberRef in + return osmMemberElementsDict[memberRef.ref] + } + let oswPolygon: OSWPolygon = OSWPolygon( + osmRelation: matchingOSMRelationElement, oswElementClass: oswElementClass, + osmMemberElements: matchingOSMRelationMemberElements + ) + let oswRelationMember = OSWRelationMember(element: oswPolygon, role: osmRelationMemberRef.role) + oswRelationMembers.append(oswRelationMember) + } + } + self.members = oswRelationMembers + self.additionalTags = osmRelation.tags + } + var tags: [String: String] { var identifyingFieldTags: [String: String] = [:] if oswElementClass.geometry == .polygon { @@ -72,14 +159,14 @@ struct OSWPolygon: OSWElement { if let calculatedAttributeValues { calculatedAttributeTags = getTagsFromAttributeValues(attributeValues: calculatedAttributeValues, isCalculated: true) } - let tags = identifyingFieldTags.merging(attributeTags) { _, new in - return new - }.merging(experimentalAttributeTags) { _, new in - return new - }.merging(calculatedAttributeTags) { _, new in - return new - }.merging(additionalTags) { _, new in - return new + let tags = identifyingFieldTags.merging(attributeTags) { old, new in + return old + }.merging(experimentalAttributeTags) { old, new in + return old + }.merging(calculatedAttributeTags) { old, new in + return old + }.merging(additionalTags) { old, new in + return old } return tags } diff --git a/IOSAccessAssessment/TDEI/Services/ChangesetService.swift b/IOSAccessAssessment/TDEI/Services/ChangesetService.swift index 15104727..10f58031 100644 --- a/IOSAccessAssessment/TDEI/Services/ChangesetService.swift +++ b/IOSAccessAssessment/TDEI/Services/ChangesetService.swift @@ -7,26 +7,6 @@ import Foundation -struct UploadedOSMResponseElements: Sendable { - let nodes: [String: OSMResponseNode] - let ways: [String: OSMResponseWay] - let relations: [String: OSMResponseRelation] - - var oldToNewIdMap: [String: String] { - let nodeMap = nodes.reduce(into: [String: String]()) { dict, pair in - dict[pair.value.oldId] = pair.value.newId - } - let wayMap = ways.reduce(into: [String: String]()) { dict, pair in - dict[pair.value.oldId] = pair.value.newId - } - let relationMap = relations.reduce(into: [String: String]()) { dict, pair in - dict[pair.value.oldId] = pair.value.newId - } - return nodeMap.merging(wayMap) { _, new in new } - .merging(relationMap) { _, new in new } - } -} - enum ChangesetDiffOperation { case create(any OSWElement) case modify(any OSWElement) @@ -108,7 +88,7 @@ class ChangesetService { operations: [ChangesetDiffOperation], accessToken: String, environment: APIEnvironment? = nil, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { let selectedEnvironment = environment ?? EnvironmentService.shared.environment guard let url = APIEndpoint.uploadChanges(selectedEnvironment, changesetId) else { @@ -165,7 +145,7 @@ class ChangesetService { // print("Parsed Ways: ", parser.waysWithAttributes) // print("Parsed Relations: ", parser.relationsWithAttributes) - completion(.success(UploadedOSMResponseElements( + completion(.success(OSMChangesetUploadResponseElements( nodes: parser.nodesWithAttributes, ways: parser.waysWithAttributes, relations: parser.relationsWithAttributes @@ -244,7 +224,7 @@ extension ChangesetService { operations: [ChangesetDiffOperation], accessToken: String, environment: APIEnvironment? = nil - ) async throws -> UploadedOSMResponseElements { + ) async throws -> OSMChangesetUploadResponseElements { return try await withCheckedThrowingContinuation { continuation in performUpload( workspaceId: workspaceId, changesetId: changesetId, operations: operations, diff --git a/IOSAccessAssessment/TDEI/Services/WorkspaceService.swift b/IOSAccessAssessment/TDEI/Services/WorkspaceService.swift index 2fd6f3e0..900075c4 100644 --- a/IOSAccessAssessment/TDEI/Services/WorkspaceService.swift +++ b/IOSAccessAssessment/TDEI/Services/WorkspaceService.swift @@ -60,16 +60,16 @@ class WorkspaceService { private init() {} func fetchWorkspaces( - location: CLLocationCoordinate2D?, radius: Int = 2000, + location: CLLocationCoordinate2D?, radius: Double = 2000, accessToken: String, environment: APIEnvironment? = nil ) async throws -> [Workspace] { let selectedEnvironment = environment ?? EnvironmentService.shared.environment - guard let url = APIEndpoint.getWorkspaces(selectedEnvironment) + guard let urlEndpoint = APIEndpoint.getWorkspaces(selectedEnvironment) else { throw APIError.invalidURL } - var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) + var comps = URLComponents(url: urlEndpoint, resolvingAgainstBaseURL: false) comps?.queryItems = [ URLQueryItem(name: "radius", value: "\(radius)") ] @@ -105,4 +105,47 @@ class WorkspaceService { throw APIError.decoding(error) } } + + func fetchMapData( + workspaceId: String, + location: CLLocationCoordinate2D, radius: Double = 1000, + accessToken: String, + environment: APIEnvironment? = nil + ) async throws -> OSMMapDataResponse { + let selectedEnvironment = environment ?? EnvironmentService.shared.environment + guard let urlEndpoint = APIEndpoint.getMapData(selectedEnvironment) else { + throw APIError.invalidURL + } + let bbox: BBox = LocationHelpers.boundingBoxAroundLocation(location: location, radius: radius) + + var comps = URLComponents(url: urlEndpoint, resolvingAgainstBaseURL: false) + comps?.queryItems = [ + URLQueryItem(name: "bbox", value: bbox.toQueryString()) + ] + guard let url = comps?.url else { throw APIError.invalidURL } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(workspaceId, forHTTPHeaderField: "X-Workspace") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw APIError.badStatus(httpResponse.statusCode) + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let mapData: OSMMapDataResponse = try decoder.decode(OSMMapDataResponse.self, from: data) + + return mapData + } catch { + throw APIError.decoding(error) + } + } } diff --git a/IOSAccessAssessment/TDEI/Transmission/APITransmissionController.swift b/IOSAccessAssessment/TDEI/Transmission/APIChangesetUploadController.swift similarity index 84% rename from IOSAccessAssessment/TDEI/Transmission/APITransmissionController.swift rename to IOSAccessAssessment/TDEI/Transmission/APIChangesetUploadController.swift index 9ab09ef4..384de626 100644 --- a/IOSAccessAssessment/TDEI/Transmission/APITransmissionController.swift +++ b/IOSAccessAssessment/TDEI/Transmission/APIChangesetUploadController.swift @@ -1,5 +1,5 @@ // -// APITransmissionController.swift +// APIChangesetUploadController.swift // IOSAccessAssessment // // Created by Himanshu on 12/1/25. @@ -8,7 +8,7 @@ import SwiftUI import CoreLocation -enum APITransmissionError: Error, LocalizedError { +enum APIChangesetUploadError: Error, LocalizedError { case featureClassNotLineString(AccessibilityFeatureClass) case featureClassNotPolygon(AccessibilityFeatureClass) @@ -22,14 +22,27 @@ enum APITransmissionError: Error, LocalizedError { } } -class APITransmissionController: ObservableObject { +/** + This controller is responsible for handling the upload of accessibility features as OSW elements to the OSW API, and mapping the response back to the accessibility features with their new ids and other details from OSM. + + The general workflow for the upload is as follows: + 1. Map the accessibility features to OSW elements based on their geometry type (point, linestring, polygon). This involves creating the corresponding OSW elements (OSWPoint, OSWLineString, OSWPolygon) and preparing any additional tags or properties that need to be uploaded along with the elements. + 2. Prepare the upload operations (create, modify) for the OSW elements and perform the upload using the ChangesetService. + 3. Handle the response from the upload to get the new ids and details for the uploaded OSW elements. This involves mapping the response back to the original accessibility features using a cache that keeps track of the mapping between the original features and the OSW elements. + 4. Return the results of the upload, including the mapped accessibility features with their new ids and any failed uploads. + + - TODO: Eventually, the methods here such as featureToPoint, featureToLineString, featureToPolygon can be moved to the AccessibilityFeatureProtocol as extension methods (or a dedicated helper class) since they are responsible for mapping an accessibility feature to an OSW element, which is a core responsibility of the accessibility feature model. + + */ +class APIChangesetUploadController: ObservableObject { private var idGenerator: IntIdGenerator = IntIdGenerator() public var capturedFrameIds: Set = [] func uploadFeatures( accessibilityFeatures: [any AccessibilityFeatureProtocol], - inputs: APITransmissionInputs - ) async throws -> APITransmissionResults { + liveMappingData: LiveMappingData, + inputs: APIChangesetUploadInputs + ) async throws -> APIChangesetUploadResults { idGenerator = IntIdGenerator() var isFailedCaptureUpload = false if !capturedFrameIds.contains(inputs.captureData.id) { @@ -40,31 +53,34 @@ class APITransmissionController: ObservableObject { isFailedCaptureUpload = true } } - var apiTransmissionResults: APITransmissionResults + var apiChangesetUploadResults: APIChangesetUploadResults switch inputs.accessibilityFeatureClass.oswPolicy.oswElementClass.geometry { case .point: - apiTransmissionResults = try await uploadPoints( + apiChangesetUploadResults = try await uploadPoints( accessibilityFeatures: accessibilityFeatures, + liveMappingData: liveMappingData, inputs: inputs ) case .linestring: - apiTransmissionResults = try await uploadLineStrings( + apiChangesetUploadResults = try await uploadLineStrings( accessibilityFeatures: accessibilityFeatures, + liveMappingData: liveMappingData, inputs: inputs ) case .polygon: - apiTransmissionResults = try await uploadPolygons( + apiChangesetUploadResults = try await uploadPolygons( accessibilityFeatures: accessibilityFeatures, + liveMappingData: liveMappingData, inputs: inputs ) } - return APITransmissionResults( - from: apiTransmissionResults, + return APIChangesetUploadResults( + from: apiChangesetUploadResults, isFailedCaptureUpload: isFailedCaptureUpload ) } - func uploadCapturePoint(inputs: APITransmissionInputs) async throws { + func uploadCapturePoint(inputs: APIChangesetUploadInputs) async throws { let additionalTags: [String: String] = [ APIConstants.TagKeys.captureIdKey: inputs.captureData.id.uuidString, APIConstants.TagKeys.captureLatitudeKey: String(inputs.captureLocation.latitude), @@ -90,7 +106,7 @@ class APITransmissionController: ObservableObject { private func getAdditionalTags( accessibilityFeatureClass: AccessibilityFeatureClass, captureData: CaptureData, - mappingData: MappingData, + liveMappingData: LiveMappingData, ) -> [String: String] { var enhancedAnalysisMode: Bool = false switch captureData { @@ -109,18 +125,19 @@ class APITransmissionController: ObservableObject { /** Extension for methods to handle points transmission */ -extension APITransmissionController { +extension APIChangesetUploadController { func uploadPoints( accessibilityFeatures: [any AccessibilityFeatureProtocol], - inputs: APITransmissionInputs - ) async throws -> APITransmissionResults { + liveMappingData: LiveMappingData, + inputs: APIChangesetUploadInputs + ) async throws -> APIChangesetUploadResults { let accessibilityFeatures = accessibilityFeatures let totalFeatures = accessibilityFeatures.count /// Map Accessibility Features to OSW Elements - let featureCache: APIFeatureCache = APIFeatureCache() + let featureCache: APIChangesetUploadCache = APIChangesetUploadCache() let additionalTags: [String: String] = getAdditionalTags( accessibilityFeatureClass: inputs.accessibilityFeatureClass, - captureData: inputs.captureData, mappingData: inputs.mappingData + captureData: inputs.captureData, liveMappingData: liveMappingData ) for feature in accessibilityFeatures { let oswElement = featureToPoint(feature, additionalTags: additionalTags) @@ -136,12 +153,12 @@ extension APITransmissionController { accessToken: inputs.accessToken ) guard featureCache.getOSWPoints().count > 0 else { - return APITransmissionResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) + return APIChangesetUploadResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) } /// Get the new ids and other details for the OSW Elements, from the uploaded elements response let uploadedOSWElements = getUploadedOSWPoints(from: uploadedElements, featureCache: featureCache) guard !uploadedOSWElements.isEmpty else { - return APITransmissionResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) + return APIChangesetUploadResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) } /// Created Mapped Accessibility Features from the uploaded OSW Elements /// Make sure you are using the old ids of the uploaded elements to map back to the features @@ -157,7 +174,7 @@ extension APITransmissionController { ) } let failedUploads = totalFeatures - mappedAccessibilityFeatures.count - return APITransmissionResults( + return APIChangesetUploadResults( accessibilityFeatures: mappedAccessibilityFeatures, failedFeatureUploads: failedUploads, totalFeatureUploads: totalFeatures ) @@ -194,8 +211,8 @@ extension APITransmissionController { } private func getUploadedOSWPoints( - from uploadedElements: UploadedOSMResponseElements, - featureCache: APIFeatureCache + from uploadedElements: OSMChangesetUploadResponseElements, + featureCache: APIChangesetUploadCache ) -> [OSWPoint] { let cachedOSWPoints = featureCache.getOSWPoints() var uploadedOSWPoints: [OSWPoint?] = Array(repeating: nil, count: cachedOSWPoints.count) @@ -227,15 +244,16 @@ extension APITransmissionController { /** Extension to handle line string transmission */ -extension APITransmissionController { +extension APIChangesetUploadController { func uploadLineStrings( accessibilityFeatures: [any AccessibilityFeatureProtocol], - inputs: APITransmissionInputs - ) async throws -> APITransmissionResults { + liveMappingData: LiveMappingData, + inputs: APIChangesetUploadInputs + ) async throws -> APIChangesetUploadResults { var accessibilityFeatures = accessibilityFeatures var totalFeatures = accessibilityFeatures.count guard totalFeatures > 0, let firstFeature = accessibilityFeatures.first else { - return APITransmissionResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) + return APIChangesetUploadResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) } /// For the sidewalk feature class, only upload one linestring representing the entire sidewalk if inputs.accessibilityFeatureClass.oswPolicy.oswElementClass == .Sidewalk { @@ -243,10 +261,10 @@ extension APITransmissionController { totalFeatures = 1 } /// Map Accessibility Features to OSW Elements - let featureCache: APIFeatureCache = APIFeatureCache() + let featureCache: APIChangesetUploadCache = APIChangesetUploadCache() let additionalTags: [String: String] = getAdditionalTags( accessibilityFeatureClass: inputs.accessibilityFeatureClass, - captureData: inputs.captureData, mappingData: inputs.mappingData + captureData: inputs.captureData, liveMappingData: liveMappingData ) for feature in accessibilityFeatures { let oswElement = featureToLineString(feature, additionalTags: additionalTags) @@ -258,7 +276,7 @@ extension APITransmissionController { var uploadOperations: [ChangesetDiffOperation] = featureCache.getOSWElements().map { .create($0) } /// For the sidewalk class, get the previously uploaded linestring, connect it to the new linestring, and add a modify operation if inputs.accessibilityFeatureClass.oswPolicy.oswElementClass == .Sidewalk, - let existingMappedFeature = inputs.mappingData.featuresMap[inputs.accessibilityFeatureClass]?.last { + let existingMappedFeature = liveMappingData.featuresMap[inputs.accessibilityFeatureClass]?.last { let existingOSWElement = existingMappedFeature.oswElement if var existingOSWLineString = existingOSWElement as? OSWLineString, let newOSWLineString = featureCache.getOSWLineStrings().first, @@ -275,7 +293,7 @@ extension APITransmissionController { accessToken: inputs.accessToken ) guard featureCache.getOSWLineStrings().count > 0 else { - return APITransmissionResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) + return APIChangesetUploadResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) } /// Get the new ids and other details for the OSW Elements, from the uploaded elements response let uploadedOSWElements = getUploadedOSWLineStrings( @@ -283,7 +301,7 @@ extension APITransmissionController { featureCache: featureCache ) guard !uploadedOSWElements.isEmpty else { - return APITransmissionResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) + return APIChangesetUploadResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) } /// Created Mapped Accessibility Features from the uploaded OSW Elements /// Make sure you are using the old ids of the uploaded elements to map back to the features @@ -299,7 +317,7 @@ extension APITransmissionController { ) } let failedUploads = totalFeatures - mappedAccessibilityFeatures.count - return APITransmissionResults( + return APIChangesetUploadResults( accessibilityFeatures: mappedAccessibilityFeatures, failedFeatureUploads: failedUploads, totalFeatureUploads: totalFeatures ) @@ -359,8 +377,8 @@ extension APITransmissionController { } private func getUploadedOSWLineStrings( - from uploadedElements: UploadedOSMResponseElements, - featureCache: APIFeatureCache + from uploadedElements: OSMChangesetUploadResponseElements, + featureCache: APIChangesetUploadCache ) -> [OSWLineString] { let cachedOSWLineStrings = featureCache.getOSWLineStrings() var uploadedOSWLineStrings: [OSWLineString?] = Array(repeating: nil, count: cachedOSWLineStrings.count) @@ -375,7 +393,7 @@ extension APITransmissionController { return } /// First, get a new feature cache for the nodes that belong to this linestring - let pointsCache: APIFeatureCache = APIFeatureCache() + let pointsCache: APIChangesetUploadCache = APIChangesetUploadCache() matchedOriginalOSWLineString.points.forEach { point in pointsCache.addEntry(osmOldId: point.id, feature: nil, oswElement: point) } @@ -403,18 +421,19 @@ extension APITransmissionController { /** Extension to handle polygon transmission */ -extension APITransmissionController { +extension APIChangesetUploadController { func uploadPolygons( accessibilityFeatures: [any AccessibilityFeatureProtocol], - inputs: APITransmissionInputs - ) async throws -> APITransmissionResults { + liveMappingData: LiveMappingData, + inputs: APIChangesetUploadInputs + ) async throws -> APIChangesetUploadResults { let accessibilityFeatures = accessibilityFeatures let totalFeatures = accessibilityFeatures.count /// Map Accessibility Features to OSW Elements - let featureCache: APIFeatureCache = APIFeatureCache() + let featureCache: APIChangesetUploadCache = APIChangesetUploadCache() let additionalTags: [String: String] = getAdditionalTags( accessibilityFeatureClass: inputs.accessibilityFeatureClass, - captureData: inputs.captureData, mappingData: inputs.mappingData + captureData: inputs.captureData, liveMappingData: liveMappingData ) for feature in accessibilityFeatures { let oswElement = featureToPolygon(feature, additonalTags: additionalTags) @@ -430,7 +449,7 @@ extension APITransmissionController { accessToken: inputs.accessToken ) guard featureCache.getOSWPolygons().count > 0 else { - return APITransmissionResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) + return APIChangesetUploadResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) } /// Get the new ids and other details for the OSW Elements, from the uploaded elements response let uploadedOSWElements = getUploadedPolygons( @@ -438,7 +457,7 @@ extension APITransmissionController { featureCache: featureCache ) guard !uploadedOSWElements.isEmpty else { - return APITransmissionResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) + return APIChangesetUploadResults(failedFeatureUploads: totalFeatures, totalFeatureUploads: totalFeatures) } /// Created Mapped Accessibility Features from the uploaded OSW Elements /// Make sure you are using the old ids of the uploaded elements to map back to the features @@ -454,7 +473,7 @@ extension APITransmissionController { ) } let failedUploads = totalFeatures - mappedAccessibilityFeatures.count - return APITransmissionResults( + return APIChangesetUploadResults( accessibilityFeatures: mappedAccessibilityFeatures, failedFeatureUploads: failedUploads, totalFeatureUploads: totalFeatures ) @@ -470,16 +489,11 @@ extension APITransmissionController { } var oswElements: [any OSWElement] = [] -// guard let featureLocationArrays: [[CLLocationCoordinate2D]] = feature.locationDetails?.coordinates, -// let firstLocationArray = featureLocationArrays.first, !firstLocationArray.isEmpty else { -// return nil -// } guard let featureLocationDetails: OSMLocationDetails = feature.locationDetails, !featureLocationDetails.locations.isEmpty else { return nil } featureLocationDetails.locations.forEach { locationElement in -// featureLocationArrays.forEach { locationArray in guard !locationElement.coordinates.isEmpty else { return } var oswPoints: [OSWPoint] = [] locationElement.coordinates.forEach { location in @@ -536,8 +550,8 @@ extension APITransmissionController { } private func getUploadedPolygons( - from uploadedElements: UploadedOSMResponseElements, - featureCache: APIFeatureCache + from uploadedElements: OSMChangesetUploadResponseElements, + featureCache: APIChangesetUploadCache ) -> [OSWPolygon] { let cachedOSWPolygons = featureCache.getOSWPolygons() var uploadedOSWPolygons: [OSWPolygon?] = Array(repeating: nil, count: cachedOSWPolygons.count) @@ -552,7 +566,7 @@ extension APITransmissionController { return } /// First, create a new feature cache for the point members of the polygon - let pointsCache: APIFeatureCache = APIFeatureCache() + let pointsCache: APIChangesetUploadCache = APIChangesetUploadCache() matchedOriginalOSWPolygon.members.forEach { member in let element = member.element guard let point = element as? OSWPoint else { return } @@ -564,7 +578,7 @@ extension APITransmissionController { featureCache: pointsCache ) /// Second, create a new feature cache for the linestring members of the polygon - let lineStringsCache: APIFeatureCache = APIFeatureCache() + let lineStringsCache: APIChangesetUploadCache = APIChangesetUploadCache() matchedOriginalOSWPolygon.members.forEach { member in let element = member.element guard let lineString = element as? OSWLineString else { return } diff --git a/IOSAccessAssessment/TDEI/Transmission/APITransmissionHelpers.swift b/IOSAccessAssessment/TDEI/Transmission/APITransmissionHelpers.swift index d1ba381e..be7c34b4 100644 --- a/IOSAccessAssessment/TDEI/Transmission/APITransmissionHelpers.swift +++ b/IOSAccessAssessment/TDEI/Transmission/APITransmissionHelpers.swift @@ -8,15 +8,15 @@ import SwiftUI import CoreLocation -struct APIFeatureCacheEntry: @unchecked Sendable { +struct APIChangesetUploadCacheEntry: @unchecked Sendable { let osmOldId: String let feature: (any AccessibilityFeatureProtocol)? let oswElement: any OSWElement } -class APIFeatureCache { - /// OSM old ID to APIFeatureCacheEntry - var cacheEntry: [APIFeatureCacheEntry] +class APIChangesetUploadCache { + /// OSM old ID to APIChangesetUploadCacheEntry + var cacheEntry: [APIChangesetUploadCacheEntry] init() { self.cacheEntry = [] @@ -26,12 +26,12 @@ class APIFeatureCache { return cacheEntry.firstIndex { $0.osmOldId == osmOldId } } - func getEntry(osmOldId: String) -> APIFeatureCacheEntry? { + func getEntry(osmOldId: String) -> APIChangesetUploadCacheEntry? { return cacheEntry.first { $0.osmOldId == osmOldId } } func addEntry(osmOldId: String, feature: (any AccessibilityFeatureProtocol)?, oswElement: any OSWElement) { - let entry = APIFeatureCacheEntry(osmOldId: osmOldId, feature: feature, oswElement: oswElement) + let entry = APIChangesetUploadCacheEntry(osmOldId: osmOldId, feature: feature, oswElement: oswElement) cacheEntry.append(entry) } @@ -67,18 +67,17 @@ class APIFeatureCache { } } -struct APITransmissionInputs { +struct APIChangesetUploadInputs { let workspaceId: String let changesetId: String let accessibilityFeatureClass: AccessibilityFeatureClass let captureData: CaptureData let captureLocation: CLLocationCoordinate2D - let mappingData: MappingData let accessToken: String let environment: APIEnvironment? } -struct APITransmissionResults: @unchecked Sendable { +struct APIChangesetUploadResults: @unchecked Sendable { let accessibilityFeatures: [MappedAccessibilityFeature]? let failedFeatureUploads: Int @@ -106,7 +105,7 @@ struct APITransmissionResults: @unchecked Sendable { } /// Clone method for overwriting isFailedCaptureUpload - init(from other: APITransmissionResults, isFailedCaptureUpload: Bool) { + init(from other: APIChangesetUploadResults, isFailedCaptureUpload: Bool) { self.accessibilityFeatures = other.accessibilityFeatures self.failedFeatureUploads = other.failedFeatureUploads self.totalFeatureUploads = other.totalFeatureUploads diff --git a/IOSAccessAssessment/View/ARCameraView.swift b/IOSAccessAssessment/View/ARCameraView.swift index 152c55e2..6c055883 100644 --- a/IOSAccessAssessment/View/ARCameraView.swift +++ b/IOSAccessAssessment/View/ARCameraView.swift @@ -31,6 +31,11 @@ enum ARCameraViewConstants { static let managerStatusAlertTitleKey = "Error" static let managerStatusAlertDismissButtonKey = "OK" + /// Mapping Data Status Alert + static let mappingDataStatusAlertTitleKey = "Error" + static let mappingDataStatusAlertRetryButtonKey = "Retry" + static let mappingDataStatusAlertDismissButtonKey = "OK" + /// Invalid Content View static let invalidContentViewTitle = "Invalid Capture" static let invalidContentViewMessage = "The captured data is invalid. Please try again." @@ -65,11 +70,17 @@ enum ARCameraViewConstants { enum ARCameraViewError: Error, LocalizedError { case captureNoSegmentationAccessibilityFeatures + case workspaceConfigurationFailed + case authenticationError var errorDescription: String? { switch self { case .captureNoSegmentationAccessibilityFeatures: return "No accessibility features were captured. Please try again." + case .workspaceConfigurationFailed: + return "Workspace configuration failed. Please check your workspace settings." + case .authenticationError: + return "Authentication error. Please log in again." } } } @@ -84,6 +95,16 @@ class ARCameraManagerStatusViewModel: ObservableObject { } } +class MappingDataStatusViewModel: ObservableObject { + @Published var isFailed: Bool = false + @Published var errorMessage: String = "" + + func update(isFailed: Bool, errorMessage: String) { + self.isFailed = isFailed + self.errorMessage = errorMessage + } +} + struct ARCameraView: View { let selectedClasses: [AccessibilityFeatureClass] @@ -91,20 +112,22 @@ struct ARCameraView: View { @EnvironmentObject var sharedAppContext: SharedAppContext @EnvironmentObject var segmentationPipeline: SegmentationARPipeline @EnvironmentObject var userStateViewModel: UserStateViewModel + @EnvironmentObject var workspaceViewModel: WorkspaceViewModel @Environment(\.dismiss) var dismiss @StateObject private var manager: ARCameraManager = ARCameraManager() @StateObject private var managerConfigureStatusViewModel = ARCameraManagerStatusViewModel() @State private var cameraHintText: String = ARCameraViewConstants.Texts.cameraHintPlaceholderText - var locationManager: LocationManager = LocationManager() - @State private var captureLocation: CLLocationCoordinate2D? - @State private var captureHeading: CLLocationDirection? + @StateObject private var locationManager: LocationManager = LocationManager() +// @State private var captureLocation: CLLocationCoordinate2D? +// @State private var captureHeading: CLLocationDirection? + @StateObject private var mappingDataStatusViewModel = MappingDataStatusViewModel() @State private var showARCameraLearnMoreSheet = false @State private var showAnnotationView = false - @StateObject private var apiTransmissionController: APITransmissionController = APITransmissionController() + @StateObject private var apiChangesetUploadController: APIChangesetUploadController = APIChangesetUploadController() var body: some View { Group { @@ -155,6 +178,7 @@ struct ARCameraView: View { } .navigationBarTitle(ARCameraViewConstants.Texts.contentViewTitle, displayMode: .inline) .onAppear { + locationManager.startLocationUpdates() showAnnotationView = false segmentationPipeline.setSelectedClasses(selectedClasses) do { @@ -169,6 +193,14 @@ struct ARCameraView: View { } } .onDisappear { + Task { + do { + try manager.pause() + locationManager.stopLocationUpdates() + } catch { + print("Error pausing ARCameraManager: \(error)") + } + } } .alert(ARCameraViewConstants.Texts.managerStatusAlertTitleKey, isPresented: $managerConfigureStatusViewModel.isFailed, actions: { Button(ARCameraViewConstants.Texts.managerStatusAlertDismissButtonKey) { @@ -178,11 +210,23 @@ struct ARCameraView: View { }, message: { Text(managerConfigureStatusViewModel.errorMessage) }) + .alert(ARCameraViewConstants.Texts.mappingDataStatusAlertTitleKey, isPresented: $mappingDataStatusViewModel.isFailed, actions: { + Button(ARCameraViewConstants.Texts.mappingDataStatusAlertRetryButtonKey) { + mappingDataStatusViewModel.update(isFailed: false, errorMessage: "") + handleLocationUpdate(oldLocation: nil, newLocation: locationManager.currentLocation) + } + Button(ARCameraViewConstants.Texts.mappingDataStatusAlertDismissButtonKey) { + mappingDataStatusViewModel.update(isFailed: false, errorMessage: "") + dismiss() + } + }, message: { + Text(mappingDataStatusViewModel.errorMessage) + }) .fullScreenCover(isPresented: $showAnnotationView) { - if let captureLocation { + if let captureLocation = locationManager.currentLocation?.coordinate { AnnotationView( selectedClasses: selectedClasses, captureLocation: captureLocation, - apiTransmissionController: apiTransmissionController + apiChangesetUploadController: apiChangesetUploadController ) } else { InvalidContentView( @@ -196,9 +240,8 @@ struct ARCameraView: View { Task { if (oldValue == true && newValue == false) { do { + locationManager.startLocationUpdates() await sharedAppData.refreshQueue() - captureLocation = nil - captureHeading = nil try manager.resume() } catch { managerConfigureStatusViewModel.update(isFailed: true, errorMessage: error.localizedDescription) @@ -206,6 +249,12 @@ struct ARCameraView: View { } } } + .onChange(of: manager.interfaceOrientation) { oldOrientation, newOrientation in + locationManager.updateOrientation(newOrientation) + } + .onChange(of: locationManager.currentLocation) { oldLocation, newLocation in + handleLocationUpdate(oldLocation: oldLocation, newLocation: newLocation) + } .sheet(isPresented: $showARCameraLearnMoreSheet) { ARCameraLearnMoreSheetView() .presentationDetents([.medium, .large]) @@ -249,14 +298,13 @@ struct ARCameraView: View { throw ARCameraViewError.captureNoSegmentationAccessibilityFeatures } } - captureLocation = try locationManager.getLocationCoordinate() - captureHeading = try locationManager.getHeadingDegrees() try manager.pause() + locationManager.stopLocationUpdates() /// Get location. Done after pausing the manager to avoid delays, despite being less accurate. sharedAppData.saveCaptureData(captureData) addCaptureDataToCurrentDataset( captureImageData: captureData.imageData, captureMeshData: captureData.meshData, - location: captureLocation, heading: captureHeading + location: locationManager.currentLocation?.coordinate, heading: locationManager.currentHeading?.trueHeading ) showAnnotationView = true } catch ARCameraManagerError.finalSessionMeshUnavailable { @@ -285,8 +333,8 @@ struct ARCameraView: View { try sharedAppData.currentDatasetEncoder?.addCaptureData( captureImageData: captureImageData, captureMeshData: captureMeshData, - location: captureLocation, - heading: captureHeading + location: locationManager.currentLocation?.coordinate, + heading: locationManager.currentHeading?.trueHeading ) } catch { print("Error adding capture data to dataset encoder: \(error)") @@ -294,6 +342,41 @@ struct ARCameraView: View { } } + private func handleLocationUpdate(oldLocation: CLLocation?, newLocation: CLLocation?) { + var shouldUpdateMap = oldLocation == nil && newLocation != nil + if let oldLocation, let newLocation { + let distance = oldLocation.distance(from: newLocation) + shouldUpdateMap = distance > Constants.WorkspaceConstants.fetchUpdateRadiusThresholdInMeters + } + if !shouldUpdateMap { + return + } + Task { + do { + guard let workspaceId = workspaceViewModel.workspaceId, + let location = newLocation?.coordinate else { + throw ARCameraViewError.workspaceConfigurationFailed + } + guard let accessToken = userStateViewModel.getAccessToken() else { + throw ARCameraViewError.authenticationError + } + let mapData = try await WorkspaceService.shared.fetchMapData( + workspaceId: workspaceId, + location: location, + radius: Constants.WorkspaceConstants.fetchRadiusInMeters, + accessToken: accessToken, + environment: userStateViewModel.selectedEnvironment + ) + sharedAppData.currentMappingData.update( + osmMapDataResponse: mapData, + accessibilityFeatureClasses: selectedClasses + ) + } catch { + mappingDataStatusViewModel.update(isFailed: true, errorMessage: error.localizedDescription) + } + } + } + /// Set text for 2 seconds, and then fall back to placeholder private func setHintText(_ text: String) { cameraHintText = text diff --git a/IOSAccessAssessment/View/AnnotationView.swift b/IOSAccessAssessment/View/AnnotationView.swift index c3885be5..ce96a90d 100644 --- a/IOSAccessAssessment/View/AnnotationView.swift +++ b/IOSAccessAssessment/View/AnnotationView.swift @@ -33,10 +33,10 @@ enum AnnotationViewConstants { static let managerStatusAlertDismissButtonKey = "OK" static let managerStatusAlertMessageDismissScreenSuffixKey = "Press OK to close this screen." static let managerStatusAlertMessageDismissAlertSuffixKey = "Press OK to dismiss this alert." - static let apiTransmissionStatusAlertTitleKey = "Upload Error" - static let apiTransmissionStatusAlertDismissButtonKey = "OK" - static let apiTransmissionStatusAlertGenericMessageKey = "Failed to upload features. Press OK to dismiss this alert." - static let apiTransmissionStatusAlertMessageSuffixKey = " feature(s) failed to upload. Press OK to dismiss this alert." + static let apiChangesetUploadStatusAlertTitleKey = "Upload Error" + static let apiChangesetUploadStatusAlertDismissButtonKey = "OK" + static let apiChangesetUploadStatusAlertGenericMessageKey = "Failed to upload features. Press OK to dismiss this alert." + static let apiChangesetUploadStatusAlertMessageSuffixKey = " feature(s) failed to upload. Press OK to dismiss this alert." /// SelectObjectInfoTip static let selectFeatureInfoTipTitle = "Select a Feature" @@ -74,7 +74,7 @@ enum AnnotationViewError: Error, LocalizedError { case workspaceConfigurationFailed case attributeEstimationFailed(Error) case uploadFailed - case apiTransmissionFailed(APITransmissionResults) + case apiChangesetUploadFailed(APIChangesetUploadResults) var errorDescription: String? { switch self { @@ -94,7 +94,7 @@ enum AnnotationViewError: Error, LocalizedError { return "Some Attribute Estimation calculations failed. They may be ignored. \nError: \(error.localizedDescription)" case .uploadFailed: return "Failed to upload annotations." - case .apiTransmissionFailed(let results): + case .apiChangesetUploadFailed(let results): return "API Transmission failed with \(results.failedFeatureUploads) failed uploads." } } @@ -201,7 +201,7 @@ class AnnotationViewStatusViewModel: ObservableObject { } } -class APITransmissionStatusViewModel: ObservableObject { +class APIChangesetUploadStatusViewModel: ObservableObject { @Published var isFailed: Bool = false @Published var errorMessage: String = "" @@ -210,14 +210,14 @@ class APITransmissionStatusViewModel: ObservableObject { self.errorMessage = errorMessage } - func update(apiTransmissionResults: APITransmissionResults) { - let failedFeatureUploads = apiTransmissionResults.failedFeatureUploads + func update(apiChangesetUploadResults: APIChangesetUploadResults) { + let failedFeatureUploads = apiChangesetUploadResults.failedFeatureUploads if failedFeatureUploads == 0 { self.isFailed = true self.errorMessage = "Unknown Error Occurred." } else { self.isFailed = true - self.errorMessage = "\(failedFeatureUploads) \(AnnotationViewConstants.Texts.apiTransmissionStatusAlertMessageSuffixKey)" + self.errorMessage = "\(failedFeatureUploads) \(AnnotationViewConstants.Texts.apiChangesetUploadStatusAlertMessageSuffixKey)" } } } @@ -225,7 +225,7 @@ class APITransmissionStatusViewModel: ObservableObject { struct AnnotationView: View { let selectedClasses: [AccessibilityFeatureClass] let captureLocation: CLLocationCoordinate2D - let apiTransmissionController: APITransmissionController + let apiChangesetUploadController: APIChangesetUploadController @EnvironmentObject var userStateViewModel: UserStateViewModel @EnvironmentObject var workspaceViewModel: WorkspaceViewModel @@ -238,7 +238,7 @@ struct AnnotationView: View { @StateObject var attributeEstimationPipeline: AttributeEstimationPipeline = AttributeEstimationPipeline() @StateObject private var managerStatusViewModel = AnnotationViewStatusViewModel() - @StateObject private var apiTransmissionStatusViewModel = APITransmissionStatusViewModel() + @StateObject private var apiChangesetUploadStatusViewModel = APIChangesetUploadStatusViewModel() @State private var interfaceOrientation: UIInterfaceOrientation = .portrait // To bind one-way with manager's orientation @StateObject var featureClassSelectionViewModel = AnnotationFeatureClassSelectionViewModel() @@ -318,10 +318,10 @@ struct AnnotationView: View { }, message: { Text(managerStatusViewModel.errorMessage) }) - .alert(AnnotationViewConstants.Texts.apiTransmissionStatusAlertTitleKey, - isPresented: $apiTransmissionStatusViewModel.isFailed, actions: { + .alert(AnnotationViewConstants.Texts.apiChangesetUploadStatusAlertTitleKey, + isPresented: $apiChangesetUploadStatusViewModel.isFailed, actions: { Button(AnnotationViewConstants.Texts.managerStatusAlertDismissButtonKey) { - apiTransmissionStatusViewModel.update(isFailed: false, errorMessage: "") + apiChangesetUploadStatusViewModel.update(isFailed: false, errorMessage: "") do { try moveToNextClass() } catch { @@ -329,7 +329,7 @@ struct AnnotationView: View { } } }, message: { - Text(apiTransmissionStatusViewModel.errorMessage) + Text(apiChangesetUploadStatusViewModel.errorMessage) }) } @@ -602,19 +602,19 @@ struct AnnotationView: View { private func confirmAnnotation() { Task { do { - let apiTransmissionResults = try await uploadFeatures() - if let apiTransmissionResults, apiTransmissionResults.failedFeatureUploads > 0 { - throw AnnotationViewError.apiTransmissionFailed(apiTransmissionResults) + let apiChangesetUploadResults = try await uploadFeatures() + if let apiChangesetUploadResults, apiChangesetUploadResults.failedFeatureUploads > 0 { + throw AnnotationViewError.apiChangesetUploadFailed(apiChangesetUploadResults) } try moveToNextClass() } catch AnnotationViewError.classIndexOutofBounds { managerStatusViewModel.update(isFailed: true, error: AnnotationViewError.classIndexOutofBounds) - } catch AnnotationViewError.apiTransmissionFailed(let results) { - apiTransmissionStatusViewModel.update(apiTransmissionResults: results) + } catch AnnotationViewError.apiChangesetUploadFailed(let results) { + apiChangesetUploadStatusViewModel.update(apiChangesetUploadResults: results) } catch { - apiTransmissionStatusViewModel.update( + apiChangesetUploadStatusViewModel.update( isFailed: true, - errorMessage: AnnotationViewConstants.Texts.apiTransmissionStatusAlertGenericMessageKey + errorMessage: AnnotationViewConstants.Texts.apiChangesetUploadStatusAlertGenericMessageKey ) } } @@ -634,7 +634,7 @@ struct AnnotationView: View { try featureClassSelectionViewModel.setCurrent(index: currentClassIndex + 1, classes: segmentedClasses) } - private func uploadFeatures() async throws -> APITransmissionResults? { + private func uploadFeatures() async throws -> APIChangesetUploadResults? { guard let currentCaptureDataRecord = sharedAppData.currentCaptureDataRecord else { throw AnnotationViewError.invalidCaptureDataRecord } @@ -658,25 +658,25 @@ struct AnnotationView: View { guard !featuresToUpload.isEmpty else { return nil } - let apiTransmissionInputs = APITransmissionInputs( + let apiChangesetUploadInputs = APIChangesetUploadInputs( workspaceId: workspaceId, changesetId: changesetId, accessibilityFeatureClass: accessibilityFeatureClass, captureData: currentCaptureDataRecord, captureLocation: captureLocation, - mappingData: sharedAppData.mappingData, accessToken: accessToken, environment: userStateViewModel.selectedEnvironment ) - let apiTransmissionResults = try await apiTransmissionController.uploadFeatures( + let apiChangesetUploadResults = try await apiChangesetUploadController.uploadFeatures( accessibilityFeatures: featuresToUpload, - inputs: apiTransmissionInputs + liveMappingData: sharedAppData.liveMappingData, + inputs: apiChangesetUploadInputs ) - guard let mappedAccessibilityFeatures = apiTransmissionResults.accessibilityFeatures else { - throw AnnotationViewError.apiTransmissionFailed(apiTransmissionResults) + guard let mappedAccessibilityFeatures = apiChangesetUploadResults.accessibilityFeatures else { + throw AnnotationViewError.apiChangesetUploadFailed(apiChangesetUploadResults) } - sharedAppData.mappingData.updateFeatures(mappedAccessibilityFeatures, for: accessibilityFeatureClass) - print("Mapping Data: \(sharedAppData.mappingData)") + sharedAppData.liveMappingData.updateFeatures(mappedAccessibilityFeatures, for: accessibilityFeatureClass) + print("Live Mapping Data: \(sharedAppData.liveMappingData)") addFeaturesToCurrentDataset( captureImageData: currentCaptureDataRecord.imageData, @@ -684,7 +684,7 @@ struct AnnotationView: View { ) sharedAppData.isUploadReady = true - return apiTransmissionResults + return apiChangesetUploadResults } private func addFeaturesToCurrentDataset( diff --git a/IOSAccessAssessment/View/TestMode/TestCameraView.swift b/IOSAccessAssessment/View/TestMode/TestCameraView.swift index aa334d71..4c2485f0 100644 --- a/IOSAccessAssessment/View/TestMode/TestCameraView.swift +++ b/IOSAccessAssessment/View/TestMode/TestCameraView.swift @@ -53,6 +53,50 @@ enum TestCameraViewError: Error, LocalizedError { } } +@MainActor +class LocationManagerPlaceholder: NSObject, ObservableObject { + @Published var currentLocation: CLLocation? + @Published var currentHeading: CLHeading? + + override init() { + super.init() + } + + func startLocationUpdates() {} + + private func setupLocationManager() {} + + /** + Updates the heading orientation of the location manager based on the current device orientation. This ensures that heading data is accurate and consistent with the user's perspective. + */ + public func updateOrientation(_ orientation: UIInterfaceOrientation) {} + + func locationManager(didUpdateLocations locations: [CLLocation]) { + guard let latestLocation = locations.last else { return } + guard let horizontalAccuracy = latestLocation.horizontalAccuracy as CLLocationAccuracy?, + let verticalAccuracy = latestLocation.verticalAccuracy as CLLocationAccuracy?, + horizontalAccuracy > 0, verticalAccuracy > 0 else { + return + } + Task { @MainActor in + self.currentLocation = latestLocation + } + } + + func locationManager(didUpdateHeading newHeading: CLHeading) { + guard let headingAccuracy = newHeading.headingAccuracy as CLLocationDirection?, + headingAccuracy > 0 else { + return + } + Task { @MainActor in + self.currentHeading = newHeading + } + } + + func stopLocationUpdates() {} + +} + /** TestCameraView uses the data saved in the changeset directory, to simulate mapping */ @@ -65,6 +109,7 @@ struct TestCameraView: View { @EnvironmentObject var sharedAppContext: SharedAppContext @EnvironmentObject var segmentationPipeline: SegmentationARPipeline @EnvironmentObject var userStateViewModel: UserStateViewModel + @EnvironmentObject var workspaceViewModel: WorkspaceViewModel @Environment(\.dismiss) var dismiss @StateObject private var manager: TestCameraManager = TestCameraManager() @@ -72,14 +117,15 @@ struct TestCameraView: View { @State private var cameraHintDefaultText: String = ARCameraViewConstants.Texts.cameraHintPlaceholderText @State private var cameraHintText: String = ARCameraViewConstants.Texts.cameraHintPlaceholderText -// var locationManager: LocationManager = LocationManager() - @State private var captureLocation: CLLocationCoordinate2D? - @State private var captureHeading: CLLocationDirection? + var locationManager: LocationManagerPlaceholder = LocationManagerPlaceholder() +// @State private var captureLocation: CLLocationCoordinate2D? +// @State private var captureHeading: CLLocationDirection? + @StateObject private var mappingDataStatusViewModel = MappingDataStatusViewModel() @State private var showARCameraLearnMoreSheet = false @State private var showAnnotationView = false - @StateObject private var apiTransmissionController: APITransmissionController = APITransmissionController() + @StateObject private var apiChangesetUploadController: APIChangesetUploadController = APIChangesetUploadController() // Latest dataset capture data @State private var datasetCaptureData: DatasetCaptureData? @@ -170,8 +216,16 @@ struct TestCameraView: View { ) sharedAppData.currentDatasetDecoder = datasetDecoder self.datasetCaptureData = datasetCaptureData - self.captureLocation = datasetCaptureData.location - self.captureHeading = datasetCaptureData.heading + if let captureLocation = datasetCaptureData.location { + self.locationManager.locationManager(didUpdateLocations: [ + CLLocation(latitude: captureLocation.latitude, longitude: captureLocation.longitude) + ]) + } + if let captureHeading = datasetCaptureData.heading { + let heading = CLHeading() + heading.setValue(captureHeading, forKey: "trueHeading") + self.locationManager.locationManager(didUpdateHeading: heading) + } try manager.configure( selectedClasses: selectedClasses, segmentationPipeline: segmentationPipeline, metalContext: sharedAppContext.metalContext, @@ -197,11 +251,23 @@ struct TestCameraView: View { }, message: { Text(managerConfigureStatusViewModel.errorMessage) }) + .alert(ARCameraViewConstants.Texts.mappingDataStatusAlertTitleKey, isPresented: $mappingDataStatusViewModel.isFailed, actions: { + Button(ARCameraViewConstants.Texts.mappingDataStatusAlertRetryButtonKey) { + mappingDataStatusViewModel.update(isFailed: false, errorMessage: "") + handleLocationUpdate(oldLocation: nil, newLocation: locationManager.currentLocation) + } + Button(ARCameraViewConstants.Texts.mappingDataStatusAlertDismissButtonKey) { + mappingDataStatusViewModel.update(isFailed: false, errorMessage: "") + dismiss() + } + }, message: { + Text(mappingDataStatusViewModel.errorMessage) + }) .fullScreenCover(isPresented: $showAnnotationView) { - if let captureLocation { + if let captureLocation = locationManager.currentLocation?.coordinate { AnnotationView( selectedClasses: selectedClasses, captureLocation: captureLocation, - apiTransmissionController: apiTransmissionController + apiChangesetUploadController: apiChangesetUploadController ) } else { InvalidContentView( @@ -219,6 +285,12 @@ struct TestCameraView: View { } } } +// .onChange(of: manager.interfaceOrientation) { oldOrientation, newOrientation in +// locationManager.updateOrientation(newOrientation) +// } + .onChange(of: locationManager.currentLocation) { oldLocation, newLocation in + handleLocationUpdate(oldLocation: oldLocation, newLocation: newLocation) + } .onChange(of: currentIndex) { oldValue, newValue in do { guard let datasetDecoder = sharedAppData.currentDatasetDecoder else { @@ -228,8 +300,17 @@ struct TestCameraView: View { datasetDecoder: datasetDecoder, enhancedAnalysisMode: userStateViewModel.isEnhancedAnalysisEnabled ) self.datasetCaptureData = datasetCaptureData - self.captureLocation = datasetCaptureData.location - self.captureHeading = datasetCaptureData.heading + if let captureLocation = datasetCaptureData.location { + self.locationManager.locationManager(didUpdateLocations: [ + CLLocation(latitude: captureLocation.latitude, longitude: captureLocation.longitude) + ]) + } + if let captureHeading = datasetCaptureData.heading { + let heading = CLHeading() + heading.setValue(captureHeading, forKey: "trueHeading") + self.locationManager.locationManager(didUpdateHeading: heading) + } + manager.handleSessionUpdate(datasetCaptureData: datasetCaptureData) /// For easier testing @@ -340,6 +421,41 @@ struct TestCameraView: View { } } + private func handleLocationUpdate(oldLocation: CLLocation?, newLocation: CLLocation?) { + var shouldUpdateMap = oldLocation == nil && newLocation != nil + if let oldLocation, let newLocation { + let distance = oldLocation.distance(from: newLocation) + shouldUpdateMap = distance > Constants.WorkspaceConstants.fetchUpdateRadiusThresholdInMeters + } + if !shouldUpdateMap { + return + } + Task { + do { + guard let workspaceId = workspaceViewModel.workspaceId, + let location = newLocation?.coordinate else { + throw ARCameraViewError.workspaceConfigurationFailed + } + guard let accessToken = userStateViewModel.getAccessToken() else { + throw ARCameraViewError.authenticationError + } + let mapData = try await WorkspaceService.shared.fetchMapData( + workspaceId: workspaceId, + location: location, + radius: Constants.WorkspaceConstants.fetchRadiusInMeters, + accessToken: accessToken, + environment: userStateViewModel.selectedEnvironment + ) + sharedAppData.currentMappingData.update( + osmMapDataResponse: mapData, + accessibilityFeatureClasses: selectedClasses + ) + } catch { + mappingDataStatusViewModel.update(isFailed: true, errorMessage: error.localizedDescription) + } + } + } + /// Set text for 2 seconds, and then fall back to placeholder private func setHintText(_ text: String) { cameraHintText = text