diff --git a/AutoCat.xcodeproj/project.pbxproj b/AutoCat.xcodeproj/project.pbxproj index 48a463c..80830ea 100644 --- a/AutoCat.xcodeproj/project.pbxproj +++ b/AutoCat.xcodeproj/project.pbxproj @@ -39,6 +39,8 @@ 7A1CF80529A41C66007962DA /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 7A1CF80429A41C66007962DA /* RealmSwift */; }; 7A1CF81629A42117007962DA /* Realm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1CF81529A42117007962DA /* Realm.swift */; }; 7A1DC38E2517ED98002E9C99 /* BlockBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1DC38D2517ED98002E9C99 /* BlockBarButtonItem.swift */; }; + 7A22B6ED2C67FDEA00E60173 /* SwiftLocationMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A22B6EB2C67FDEA00E60173 /* SwiftLocationMock.swift */; }; + 7A22B6EE2C67FDEA00E60173 /* GeocoderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A22B6EA2C67FDEA00E60173 /* GeocoderMock.swift */; }; 7A27ADC7249D43210035F39E /* RegionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADC6249D43210035F39E /* RegionsController.swift */; }; 7A27ADF3249F8B650035F39E /* RecordsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF2249F8B650035F39E /* RecordsController.swift */; }; 7A27ADF5249FD2F90035F39E /* FileManagerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */; }; @@ -130,6 +132,8 @@ 7AAAFADE2C4D23620050410D /* ACImageSliderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AAAFADD2C4D23620050410D /* ACImageSliderModel.swift */; }; 7AABB1F2267E9CC800D7AB32 /* SwiftDate in Frameworks */ = {isa = PBXBuildFile; productRef = 7AABB1F1267E9CC800D7AB32 /* SwiftDate */; }; 7AABDE26253350C30041AFC6 /* RxSectionedDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AABDE25253350C30041AFC6 /* RxSectionedDataSource.swift */; }; + 7AB0EF812C5CC0FE00291EE6 /* SwiftLocationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB0EF802C5CC0FE00291EE6 /* SwiftLocationProtocol.swift */; }; + 7AB0EF892C5D307600291EE6 /* LocationServiceStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB0EF882C5D307600291EE6 /* LocationServiceStub.swift */; }; 7AB5871D2C42C1CF00FA7B66 /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 7AB5871C2C42C1CF00FA7B66 /* RealmSwift */; }; 7AB587322C42D38E00FA7B66 /* StorageServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB587312C42D38E00FA7B66 /* StorageServiceProtocol.swift */; }; 7AB587342C42D3FA00FA7B66 /* StorageService+Notes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB587332C42D3FA00FA7B66 /* StorageService+Notes.swift */; }; @@ -274,6 +278,8 @@ 7A17CE4B2A2E850200626A6E /* UISegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UISegmentedControl.swift; sourceTree = ""; }; 7A1CF81529A42117007962DA /* Realm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Realm.swift; sourceTree = ""; }; 7A1DC38D2517ED98002E9C99 /* BlockBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockBarButtonItem.swift; sourceTree = ""; }; + 7A22B6EA2C67FDEA00E60173 /* GeocoderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeocoderMock.swift; sourceTree = ""; }; + 7A22B6EB2C67FDEA00E60173 /* SwiftLocationMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftLocationMock.swift; sourceTree = ""; }; 7A27ADC6249D43210035F39E /* RegionsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsController.swift; sourceTree = ""; }; 7A27ADF2249F8B650035F39E /* RecordsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsController.swift; sourceTree = ""; }; 7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerExt.swift; sourceTree = ""; }; @@ -374,6 +380,8 @@ 7AAAFADD2C4D23620050410D /* ACImageSliderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACImageSliderModel.swift; sourceTree = ""; }; 7AABDE25253350C30041AFC6 /* RxSectionedDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RxSectionedDataSource.swift; sourceTree = ""; }; 7AAE6AD224CDDF950023860B /* VehicleEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleEvent.swift; sourceTree = ""; }; + 7AB0EF802C5CC0FE00291EE6 /* SwiftLocationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftLocationProtocol.swift; sourceTree = ""; }; + 7AB0EF882C5D307600291EE6 /* LocationServiceStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationServiceStub.swift; sourceTree = ""; }; 7AB562B9249C9E9B00473D53 /* VehicleRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleRegion.swift; sourceTree = ""; }; 7AB587222C42D27F00FA7B66 /* AutoCatTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AutoCatTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7AB587312C42D38E00FA7B66 /* StorageServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageServiceProtocol.swift; sourceTree = ""; }; @@ -641,6 +649,15 @@ path = NotesScreen; sourceTree = ""; }; + 7A22B6EC2C67FDEA00E60173 /* Mocks */ = { + isa = PBXGroup; + children = ( + 7A22B6EA2C67FDEA00E60173 /* GeocoderMock.swift */, + 7A22B6EB2C67FDEA00E60173 /* SwiftLocationMock.swift */, + ); + path = Mocks; + sourceTree = ""; + }; 7A3F07A924360D9100E59687 /* Extensions */ = { isa = PBXGroup; children = ( @@ -718,6 +735,7 @@ 7A60D24C2C5A9D4900D13F7B /* LocationService.swift */, 7A60D24E2C5A9DA800D13F7B /* LocationServiceProtocol.swift */, 7A60D2502C5A9E4200D13F7B /* GeocoderProtocol.swift */, + 7AB0EF802C5CC0FE00291EE6 /* SwiftLocationProtocol.swift */, ); path = LocationService; sourceTree = ""; @@ -857,6 +875,7 @@ children = ( 7AB587362C42E3EC00FA7B66 /* StorageServiceStub.swift */, 7A176DB12C43071A00999D6B /* ApiServiceStub.swift */, + 7AB0EF882C5D307600291EE6 /* LocationServiceStub.swift */, ); path = Preview; sourceTree = ""; @@ -910,6 +929,7 @@ 7AF6D2292677C3950086EA64 /* Extensions */, 7A11474523FF2A9000B424AF /* Models */, 7AF6D20D2677C0C30086EA64 /* Utils */, + 7A22B6EC2C67FDEA00E60173 /* Mocks */, 7AF6D1F12677C03B0086EA64 /* AutoCatCore.h */, 7AF6D1F22677C03B0086EA64 /* Info.plist */, ); @@ -1269,6 +1289,7 @@ 7A64AE732469DFB600ABE48E /* DismissAnimationController.swift in Sources */, 7ADF6C97250F41B000F237B2 /* PNKeyboard.swift in Sources */, 7A1022702C551EFD00B84627 /* LocationEditCoordinator.swift in Sources */, + 7AB0EF892C5D307600291EE6 /* LocationServiceStub.swift in Sources */, 7A7158042C43EAA200852088 /* OwnersCoordinator.swift in Sources */, 7A7547E024032CB6004E8406 /* VehiclePhotoCell.swift in Sources */, 7A17CE4C2A2E850200626A6E /* UISegmentedControl.swift in Sources */, @@ -1294,11 +1315,14 @@ buildActionMask = 2147483647; files = ( 7A5D84C22C1AE5C900C2209B /* VehicleModel.swift in Sources */, + 7A22B6ED2C67FDEA00E60173 /* SwiftLocationMock.swift in Sources */, + 7A22B6EE2C67FDEA00E60173 /* GeocoderMock.swift in Sources */, 7A5D84B92C1AD3C200C2209B /* DtoConvertible.swift in Sources */, 7AF6D2182677C1680086EA64 /* VehicleAd.swift in Sources */, 7A761C08267E8EA20005F28F /* JWT.swift in Sources */, 7A64A2242C1A07EA00284124 /* Formatters.swift in Sources */, 7A5D84C02C1AE4DC00C2209B /* VehicleEngine.swift in Sources */, + 7AB0EF812C5CC0FE00291EE6 /* SwiftLocationProtocol.swift in Sources */, 7AF6D2282677C2DC0086EA64 /* Constants.swift in Sources */, 7A64A2182C19E64800284124 /* VehicleOwnershipPeriodDto.swift in Sources */, 7A599C3B2C18B36A00D47C18 /* FbVerifyTokenModel.swift in Sources */, @@ -1543,16 +1567,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = AutoCat/AutoCat.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 132; + CURRENT_PROJECT_VERSION = 133; DEVELOPMENT_TEAM = 46DTTB8X4S; INFOPLIST_FILE = AutoCat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AutoCat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCat; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1569,16 +1594,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = AutoCat/AutoCat.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 132; + CURRENT_PROJECT_VERSION = 133; DEVELOPMENT_TEAM = 46DTTB8X4S; INFOPLIST_FILE = AutoCat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AutoCat; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCat; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1601,8 +1627,9 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCatCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1625,8 +1652,9 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCatCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1649,8 +1677,9 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCatTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1675,8 +1704,9 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCatTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1700,12 +1730,13 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AutoCatCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 14.6; PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCatCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -1730,12 +1761,13 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = AutoCatCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 14.6; PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCatCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; diff --git a/AutoCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AutoCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d2d699c..f1cb90f 100644 --- a/AutoCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AutoCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Kolos65/Mockable", "state" : { - "revision" : "81ccaead99a3c038c09345caa2888ae74b644ee9", - "version" : "0.0.9" + "revision" : "da977ecb20974c4b1cf185f5fd38771b2d4674fb", + "version" : "0.0.10" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/realm-core.git", "state" : { - "revision" : "f3d7ae5f9f31d90b327a64536bb7801cc69fd85b", - "version" : "14.9.0" + "revision" : "c2552e1d36867cb42b28130e894a81fc17081062", + "version" : "14.12.0" } }, { @@ -60,17 +60,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/realm-swift.git", "state" : { - "revision" : "4c4413abd0cd2221f59318f800960fe5bddc1494", - "version" : "10.51.0" + "revision" : "5221a83dc720823e3017493394d00d49ccf13446", + "version" : "10.52.3" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", - "version" : "510.0.2" + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" } }, { diff --git a/AutoCat/Cells/AudioRecordCell.swift b/AutoCat/Cells/AudioRecordCell.swift index 178447f..5baf6f9 100644 --- a/AutoCat/Cells/AudioRecordCell.swift +++ b/AutoCat/Cells/AudioRecordCell.swift @@ -25,7 +25,9 @@ class AudioRecordCell: UITableViewCell, ConfigurableCell { self.componentsFormatter.allowedUnits = [.minute, .second] self.componentsFormatter.zeroFormattingBehavior = .pad - self.progressView.progress = 0 + DispatchQueue.main.async { + self.progressView.progress = 0 + } } override func prepareForReuse() { diff --git a/AutoCat/Cells/VehicleNoteCell.swift b/AutoCat/Cells/VehicleNoteCell.swift index bc8d9e4..50e318c 100644 --- a/AutoCat/Cells/VehicleNoteCell.swift +++ b/AutoCat/Cells/VehicleNoteCell.swift @@ -10,8 +10,11 @@ class VehicleNoteCell: UITableViewCell { override func awakeFromNib() { super.awakeFromNib() - self.dateFormatter.dateStyle = .medium - self.dateFormatter.timeStyle = .medium + + DispatchQueue.main.async { + self.dateFormatter.dateStyle = .medium + self.dateFormatter.timeStyle = .medium + } } func configure(with note: VehicleNoteDto) { diff --git a/AutoCat/Cells/VehiclePhotoCell.swift b/AutoCat/Cells/VehiclePhotoCell.swift index 71170a2..7ce845d 100644 --- a/AutoCat/Cells/VehiclePhotoCell.swift +++ b/AutoCat/Cells/VehiclePhotoCell.swift @@ -2,6 +2,7 @@ import UIKit import Kingfisher import AutoCatCore +@MainActor class VehiclePhotoCell: UICollectionViewCell { @IBOutlet weak var photo: UIImageView! @IBOutlet weak var model: UILabel! @@ -11,9 +12,12 @@ class VehiclePhotoCell: UICollectionViewCell { override func awakeFromNib() { super.awakeFromNib() - self.layer.cornerRadius = 8 - formatter.timeStyle = .none - formatter.dateStyle = .medium + + DispatchQueue.main.async { + self.layer.cornerRadius = 8 + self.formatter.timeStyle = .none + self.formatter.dateStyle = .medium + } } override func prepareForReuse() { diff --git a/AutoCat/Preview/LocationServiceStub.swift b/AutoCat/Preview/LocationServiceStub.swift new file mode 100644 index 0000000..32ef131 --- /dev/null +++ b/AutoCat/Preview/LocationServiceStub.swift @@ -0,0 +1,27 @@ +// +// LocationServiceStub.swift +// AutoCat +// +// Created by Selim Mustafaev on 02.08.2024. +// Copyright © 2024 Selim Mustafaev. All rights reserved. +// + +import Foundation +import AutoCatCore + +final class LocationServiceStub: LocationServiceProtocol { + + let event: VehicleEventDto + + init(event: VehicleEventDto) { + self.event = event + } + + func getAddressForLocation(latitude: Double, longitude: Double) async throws -> String { + event.address ?? "" + } + + func requestCurrentLocation() async throws -> AutoCatCore.VehicleEventDto { + event + } +} diff --git a/AutoCat/Screens/LocationPickerScreen/LocationPickerCoordinator.swift b/AutoCat/Screens/LocationPickerScreen/LocationPickerCoordinator.swift index 5f5c4fd..4342692 100644 --- a/AutoCat/Screens/LocationPickerScreen/LocationPickerCoordinator.swift +++ b/AutoCat/Screens/LocationPickerScreen/LocationPickerCoordinator.swift @@ -23,7 +23,7 @@ final class LocationPickerCoordinator: Coordinator { func start() async throws -> VehicleEventDto? { - let viewModel = LocationPickerViewModel(event: event) + let viewModel = LocationPickerViewModel(event: event, locationService: LocationService.shared) let screen = LocationPickerScreen(viewModel: viewModel) let controller = CustomHostingController(rootView: screen) viewController?.pushViewController(controller, animated: true) diff --git a/AutoCat/Screens/LocationPickerScreen/LocationPickerScreen.swift b/AutoCat/Screens/LocationPickerScreen/LocationPickerScreen.swift index abb4e6f..afe82d8 100644 --- a/AutoCat/Screens/LocationPickerScreen/LocationPickerScreen.swift +++ b/AutoCat/Screens/LocationPickerScreen/LocationPickerScreen.swift @@ -18,15 +18,22 @@ struct LocationPickerScreen: View { var body: some View { ZStack { - Map(coordinateRegion: $viewModel.region) + Map(initialPosition: viewModel.position) + .mapControls { + MapUserLocationButton() + } + .onMapCameraChange(frequency: .onEnd) { context in + Task { await viewModel.updateEvent(center: context.region.center) } + } + Image(systemName: "mappin.and.ellipse") .resizable() .aspectRatio(contentMode: .fit) .frame(height: 48) - .offset(.init(width: 0, height: -10)) + .offset(.init(width: 0, height: -16)) .foregroundColor(.blue) } - .ignoresSafeArea() + //.ignoresSafeArea() .navigationTitle(viewModel.event.location) .toolbar { ToolbarItem(placement: .primaryAction) { @@ -44,5 +51,9 @@ struct LocationPickerScreen: View { var event = VehicleEventDto(lat: 47.250049, lon: 39.711821) event.address = "Ул. Ленина, 123" - return LocationPickerScreen(viewModel: .init(event: event)) + let locationService = LocationServiceStub(event: event) + let viewModel = LocationPickerViewModel(event: event, + locationService: locationService) + + return LocationPickerScreen(viewModel: viewModel) } diff --git a/AutoCat/Screens/LocationPickerScreen/LocationPickerViewModel.swift b/AutoCat/Screens/LocationPickerScreen/LocationPickerViewModel.swift index 348c90f..88c2d9a 100644 --- a/AutoCat/Screens/LocationPickerScreen/LocationPickerViewModel.swift +++ b/AutoCat/Screens/LocationPickerScreen/LocationPickerViewModel.swift @@ -14,58 +14,30 @@ import SwiftUI @MainActor final class LocationPickerViewModel: ObservableObject { + let locationService: LocationServiceProtocol + @Published var event: VehicleEventDto - @Published var region: MKCoordinateRegion { - didSet { - Task { await updateEvent(region: region) } - } - } + @Published var position: MapCameraPosition var result: VehicleEventDto? - var geocodingTask: Task? - - init(event: VehicleEventDto) { + init(event: VehicleEventDto, locationService: LocationServiceProtocol) { self.event = event - - let center = CLLocationCoordinate2D(latitude: event.latitude, longitude: event.longitude) - self.region = MKCoordinateRegion(center: center, - latitudinalMeters: 1000, - longitudinalMeters: 1000) + self.locationService = locationService if event.latitude == 0 && event.longitude == 0 { - Task { await moveToCurrentLocation() } + self.position = .userLocation(fallback: .automatic) + } else { + let center = CLLocationCoordinate2D(latitude: event.latitude, longitude: event.longitude) + self.position = .region(.init(center: center, latitudinalMeters: 1000, longitudinalMeters: 1000)) } } - func moveToCurrentLocation() async { - do { - let currentEvent = try await RxLocationManager.requestCurrentLocation() - let center = CLLocationCoordinate2D(latitude: currentEvent.latitude, - longitude: currentEvent.longitude) - self.region = MKCoordinateRegion(center: center, - latitudinalMeters: 1000, - longitudinalMeters: 1000) - - event.latitude = currentEvent.latitude - event.longitude = currentEvent.longitude - event.address = try? await RxLocationManager.getAddressForLocation(latitude: region.center.latitude, - longitude: region.center.longitude) - } catch { - print(error) - } - } - - func updateEvent(region: MKCoordinateRegion) async { - geocodingTask?.cancel() - geocodingTask = Task { - event.latitude = region.center.latitude - event.longitude = region.center.longitude - try await Task.sleep(nanoseconds: 500_000_000) - event.address = try? await RxLocationManager.getAddressForLocation(latitude: region.center.latitude, - longitude: region.center.longitude) - geocodingTask = nil - } + func updateEvent(center: CLLocationCoordinate2D) async { + event.latitude = center.latitude + event.longitude = center.longitude + event.address = try? await locationService.getAddressForLocation(latitude: center.latitude, + longitude: center.longitude) } func done() { diff --git a/AutoCatCoreTests/Mocks/GeocoderMock.swift b/AutoCatCore/Mocks/GeocoderMock.swift similarity index 100% rename from AutoCatCoreTests/Mocks/GeocoderMock.swift rename to AutoCatCore/Mocks/GeocoderMock.swift diff --git a/AutoCatCore/Mocks/SwiftLocationMock.swift b/AutoCatCore/Mocks/SwiftLocationMock.swift new file mode 100644 index 0000000..00ba19d --- /dev/null +++ b/AutoCatCore/Mocks/SwiftLocationMock.swift @@ -0,0 +1,45 @@ +// +// SwiftLocationMock.swift +// AutoCatCoreTests +// +// Created by Selim Mustafaev on 02.08.2024. +// Copyright © 2024 Selim Mustafaev. All rights reserved. +// + +import AutoCatCore +import CoreLocation +import SwiftLocation + +final class SwiftLocationMock { + + var authorizationStatus: CLAuthorizationStatus = .notDetermined + var requestedStatus: CLAuthorizationStatus = .notDetermined + var location: CLLocation? + + var requestLocationTime: TimeInterval = 0 + var requestLocationCount = 0 +} + +extension SwiftLocationMock: SwiftLocationProtocol { + + func requestPermission(_ permission: LocationPermission) async throws -> CLAuthorizationStatus { + authorizationStatus = requestedStatus + return requestedStatus + } + + func requestLocation(accuracy filters: AccuracyFilters?, + timeout: TimeInterval?) async throws -> Tasks.ContinuousUpdateLocation.StreamEvent { + + requestLocationCount += 1 + + if requestLocationTime > 0 { + try await Task.sleep(nanoseconds: UInt64(requestLocationTime*1_000_000_000)) + } + + if let location { + return .didUpdateLocations([location]) + } else { + return .didUpdateLocations([]) + } + } +} diff --git a/AutoCatCore/Services/LocationService/LocationService.swift b/AutoCatCore/Services/LocationService/LocationService.swift index b38fd12..d55770d 100644 --- a/AutoCatCore/Services/LocationService/LocationService.swift +++ b/AutoCatCore/Services/LocationService/LocationService.swift @@ -7,16 +7,53 @@ // import CoreLocation +import SwiftLocation @MainActor -public final class LocationService: LocationServiceProtocol { +public final class LocationService { - private var geocoder: GeocoderProtocol + public static let shared = LocationService(geocoder: CLGeocoder(), + locationManager: Location()) - public init(geocoder: GeocoderProtocol) { + private let geocoder: GeocoderProtocol + private var locationManager: SwiftLocationProtocol + + private var eventTask: Task? + + public init(geocoder: GeocoderProtocol, locationManager: SwiftLocationProtocol) { self.geocoder = geocoder + self.locationManager = locationManager } + private func checkPermissions() async throws { + + switch locationManager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + break + case .notDetermined: + _ = try await locationManager.requestPermission(.always) + try await checkPermissions() + case .denied: + throw CLError(.denied) + default: + throw LocationError.permission + } + } + + private func requestLocation() async throws -> VehicleEventDto { + try await checkPermissions() + let locationEvent = try await locationManager.requestLocation(accuracy: nil, timeout: 20) + + guard let coordinate = locationEvent.location?.coordinate else { + throw LocationError.generic + } + + return VehicleEventDto(lat: coordinate.latitude, lon: coordinate.longitude) + } +} + +extension LocationService: LocationServiceProtocol { + public func getAddressForLocation(latitude: Double, longitude: Double) async throws -> String { let location = CLLocation(latitude: latitude, longitude: longitude) @@ -28,4 +65,20 @@ public final class LocationService: LocationServiceProtocol { throw LocationError.reverseGeocode } } + + @discardableResult + public func requestCurrentLocation() async throws -> VehicleEventDto { + + if let eventTask { + return try await eventTask.value + } else { + let task = Task { + let location = try await requestLocation() + eventTask = nil + return location + } + eventTask = task + return try await task.value + } + } } diff --git a/AutoCatCore/Services/LocationService/LocationServiceProtocol.swift b/AutoCatCore/Services/LocationService/LocationServiceProtocol.swift index 51be8d5..fac01c6 100644 --- a/AutoCatCore/Services/LocationService/LocationServiceProtocol.swift +++ b/AutoCatCore/Services/LocationService/LocationServiceProtocol.swift @@ -6,9 +6,9 @@ // Copyright © 2024 Selim Mustafaev. All rights reserved. // -import Foundation - +@MainActor public protocol LocationServiceProtocol { func getAddressForLocation(latitude: Double, longitude: Double) async throws -> String + func requestCurrentLocation() async throws -> VehicleEventDto } diff --git a/AutoCatCore/Services/LocationService/SwiftLocationProtocol.swift b/AutoCatCore/Services/LocationService/SwiftLocationProtocol.swift new file mode 100644 index 0000000..3e588a2 --- /dev/null +++ b/AutoCatCore/Services/LocationService/SwiftLocationProtocol.swift @@ -0,0 +1,21 @@ +// +// SwiftLocationProtocol.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 02.08.2024. +// Copyright © 2024 Selim Mustafaev. All rights reserved. +// + +import SwiftLocation +import CoreLocation + +public protocol SwiftLocationProtocol { + + var authorizationStatus: CLAuthorizationStatus { get } + + func requestPermission(_ permission: LocationPermission) async throws -> CLAuthorizationStatus + func requestLocation(accuracy filters: AccuracyFilters?, + timeout: TimeInterval?) async throws -> Tasks.ContinuousUpdateLocation.StreamEvent +} + +extension Location: SwiftLocationProtocol { } diff --git a/AutoCatCoreTests/LocationServiceTests.swift b/AutoCatCoreTests/LocationServiceTests.swift index e9ba3e5..d0e9418 100644 --- a/AutoCatCoreTests/LocationServiceTests.swift +++ b/AutoCatCoreTests/LocationServiceTests.swift @@ -8,7 +8,7 @@ import Testing import CoreLocation -import AutoCatCore +@testable import AutoCatCore @MainActor struct LocationServiceTests { @@ -18,10 +18,12 @@ struct LocationServiceTests { let address = "Test Address" let geocoder = GeocoderMock() + let locationManager = SwiftLocationMock() let locationService: LocationService init() { - self.locationService = LocationService(geocoder: geocoder) + self.locationService = LocationService(geocoder: geocoder, + locationManager: locationManager) } @Test @@ -58,4 +60,85 @@ struct LocationServiceTests { longitude: longitude) } } + + @Test("Get location: denied") + func getLocationDenied() async throws { + + locationManager.authorizationStatus = .denied + + await #expect(throws: CLError(.denied)) { + _ = try await locationService.requestCurrentLocation() + } + } + + @Test("Get location: not determined -> denied") + func getLocationNotDeterminedDenied() async throws { + + locationManager.authorizationStatus = .notDetermined + locationManager.requestedStatus = .denied + + await #expect(throws: CLError(.denied)) { + _ = try await locationService.requestCurrentLocation() + } + } + + @Test("Get location: not determined -> allow") + func getLocationNotDeterminedAllow() async throws { + + locationManager.authorizationStatus = .notDetermined + locationManager.requestedStatus = .authorizedWhenInUse + locationManager.location = CLLocation(latitude: latitude, longitude: longitude) + + let event = try await locationService.requestCurrentLocation() + + #expect(event.latitude == latitude) + #expect(event.longitude == longitude) + } + + @Test("Get location: normal") + func getLocationNormal() async throws { + + locationManager.authorizationStatus = .authorizedWhenInUse + locationManager.location = CLLocation(latitude: latitude, longitude: longitude) + + let event = try await locationService.requestCurrentLocation() + + #expect(event.latitude == latitude) + #expect(event.longitude == longitude) + } + + @Test("Get location: no location") + func getLocationNone() async throws { + + locationManager.authorizationStatus = .authorizedWhenInUse + + await #expect(throws: LocationError.generic) { + _ = try await locationService.requestCurrentLocation() + } + } + + @Test("Get location: parallel requests") + func getLocationParallel() async throws { + + locationManager.authorizationStatus = .authorizedWhenInUse + locationManager.location = CLLocation(latitude: latitude, longitude: longitude) + locationManager.requestLocationTime = 1 + + async let task1 = locationService.requestCurrentLocation() + async let task2 = locationService.requestCurrentLocation() + + try await Task.sleep(nanoseconds: 1_500_000_000) + async let task3 = locationService.requestCurrentLocation() + + let (event1, event2, event3) = try await (task1, task2, task3) + + #expect(locationManager.requestLocationCount == 2) + + #expect(event1.latitude == latitude) + #expect(event1.longitude == longitude) + #expect(event2.latitude == latitude) + #expect(event2.longitude == longitude) + #expect(event3.latitude == latitude) + #expect(event3.longitude == longitude) + } } diff --git a/AutoCatTests/LocationPickerTests.swift b/AutoCatTests/LocationPickerTests.swift new file mode 100644 index 0000000..daa5147 --- /dev/null +++ b/AutoCatTests/LocationPickerTests.swift @@ -0,0 +1,66 @@ +// +// LocationPickerTests.swift +// AutoCatTests +// +// Created by Selim Mustafaev on 10.08.2024. +// Copyright © 2024 Selim Mustafaev. All rights reserved. +// + +import Testing +import CoreLocation + +@testable import AutoCat +@testable import AutoCatCore + +@MainActor +struct LocationPickerTests { + + let latitude: CLLocationDegrees = 10 + let longitude: CLLocationDegrees = 10 + let address = "Test Address" + + let geocoder = GeocoderMock() + let locationManager = SwiftLocationMock() + let locationService: LocationService + + init() { + self.locationService = LocationService(geocoder: geocoder, + locationManager: locationManager) + } + + @Test("Set initial location (user)") + func setInitialLocationUser() async throws { + + let viewModel = LocationPickerViewModel(event: .init(lat: 0, lon: 0), + locationService: locationService) + + #expect(viewModel.position == .userLocation(fallback: .automatic)) + } + + @Test("Set initial location (custom)") + func setInitialLocationCustom() async throws { + + let viewModel = LocationPickerViewModel(event: .init(lat: latitude, lon: longitude), + locationService: locationService) + + #expect(viewModel.position.region?.center.latitude == latitude) + #expect(viewModel.position.region?.center.longitude == longitude) + } + + @Test("Update event") + func updateEvent() async throws { + + let viewModel = LocationPickerViewModel(event: .init(lat: 0, lon: 0), + locationService: locationService) + + geocoder.addLocation(latitude: latitude, + longitude: longitude, + address: address) + + await viewModel.updateEvent(center: .init(latitude: latitude, longitude: longitude)) + + #expect(viewModel.event.latitude == latitude) + #expect(viewModel.event.longitude == longitude) + #expect(viewModel.event.address == address) + } +}