diff --git a/Modules/Sources/Experiments/DefaultFeatureFlagService.swift b/Modules/Sources/Experiments/DefaultFeatureFlagService.swift index 6c1d4311d71..3ceb9ce15b1 100644 --- a/Modules/Sources/Experiments/DefaultFeatureFlagService.swift +++ b/Modules/Sources/Experiments/DefaultFeatureFlagService.swift @@ -104,7 +104,7 @@ public struct DefaultFeatureFlagService: FeatureFlagService { case .pointOfSaleBarcodeScanningi2: return buildConfig == .localDeveloper || buildConfig == .alpha case .orderAddressMapSearch: - return buildConfig == .localDeveloper || buildConfig == .alpha + return true default: return true } diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 26a4040d6f6..e823639d18c 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -6,6 +6,7 @@ - [*] Increased decimal sensitivity in order creation to mitigate tax rounding issues [https://github.com/woocommerce/woocommerce-ios/pull/15957] - [*] Fix initialization of authenticator to avoid crashes during login [https://github.com/woocommerce/woocommerce-ios/pull/15953] - [*] Shipping Labels: Made HS tariff number field required in customs form for EU destinations [https://github.com/woocommerce/woocommerce-ios/pull/15946] +- [*] Order Details > Edit Shipping/Billing Address: Added map-based address lookup support for iOS 17+. [https://github.com/woocommerce/woocommerce-ios/pull/15964] - [*] Order Creation: Prevent subscription products to be added to an order [https://github.com/woocommerce/woocommerce-ios/pull/15960] - [internal] Replace COTS_DEVICE reader model name with TAP_TO_PAY_DEVICE. [https://github.com/woocommerce/woocommerce-ios/pull/15961] diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift index 46e2892c54f..392dc89f3aa 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift @@ -450,6 +450,8 @@ enum WooAnalyticsStat: String { case orderDetailEditFlowFailed = "order_detail_edit_flow_failed" case orderDetailPaymentLinkShared = "order_detail_payment_link_shared" case orderDetailTrashButtonTapped = "order_detail_trash_tapped" + case orderDetailEditAddressMapPickerTapped = "order_detail_edit_address_map_picker_tapped" + case orderDetailEditAddressMapPickerUseAddressTapped = "order_detail_edit_address_map_picker_use_address_tapped" // MARK: Test order // diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerView.swift index 1cfe5140de0..641136a9fe8 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerView.swift @@ -93,6 +93,7 @@ struct AddressMapPickerView: View { isSearchFocused = false viewModel.updateFields(&fields) dismiss() + ServiceLocator.analytics.track(.orderDetailEditAddressMapPickerUseAddressTapped) } .disabled(!viewModel.hasValidSelection) } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerViewModel.swift index f567eb56287..8b60b0cc243 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerViewModel.swift @@ -5,6 +5,26 @@ import AsyncAlgorithms import CoreLocation import struct Yosemite.Country +/// Protocol for providing local search functionality for address map picker. +/// Abstracts MKLocalSearch to enable testing with mock implementations. +protocol AddressMapLocalSearchProviding { + /// Performs a local search based on the provided search completion. + /// - Parameter completion: The search completion containing location query information. + /// - Returns: A search response containing map items with placemarks. + /// - Throws: MapKit errors if the search fails. + func search(for completion: MKLocalSearchCompletion) async throws -> MKLocalSearch.Response +} + +/// Default implementation of AddressMapLocalSearchProviding that uses MKLocalSearch. +/// This is the production implementation that makes real network requests to MapKit services. +final class DefaultAddressMapLocalSearchProvider: AddressMapLocalSearchProviding { + func search(for completion: MKLocalSearchCompletion) async throws -> MKLocalSearch.Response { + let searchRequest = MKLocalSearch.Request(completion: completion) + let search = MKLocalSearch(request: searchRequest) + return try await search.start() + } +} + @available(iOS 17, *) @Observable final class AddressMapPickerViewModel: NSObject { @@ -13,7 +33,7 @@ final class AddressMapPickerViewModel: NSObject { searchQueryContinuation.yield(newValue) } } - var searchResults: [MKLocalSearchCompletion] = [] + private(set) var searchResults: [MKLocalSearchCompletion] = [] var region = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: 37.3361, longitude: -122.0380), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) @@ -37,9 +57,13 @@ final class AddressMapPickerViewModel: NSObject { private var selectedPlace: MKPlacemark? private let countryByCode: (_ countryCode: String) -> Country? + private let searchProvider: AddressMapLocalSearchProviding - init(fields: AddressFormFields, countryByCode: @escaping (_ countryCode: String) -> Country?) { + init(fields: AddressFormFields, + countryByCode: @escaping (_ countryCode: String) -> Country?, + searchProvider: AddressMapLocalSearchProviding = DefaultAddressMapLocalSearchProvider()) { self.countryByCode = countryByCode + self.searchProvider = searchProvider super.init() configureSearchCompleter() configureMap(with: fields) @@ -65,11 +89,8 @@ final class AddressMapPickerViewModel: NSObject { /// - Parameter result: The selected search completion result. @MainActor func selectLocation(_ result: MKLocalSearchCompletion) async { - let searchRequest = MKLocalSearch.Request(completion: result) - let search = MKLocalSearch(request: searchRequest) - do { - let response = try await search.start() + let response = try await searchProvider.search(for: result) guard let firstPlacemark = response.mapItems.first?.placemark else { return } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/EditOrderAddressForm.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/EditOrderAddressForm.swift index f19444c0aa9..c81fdf947e7 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/EditOrderAddressForm.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/EditOrderAddressForm.swift @@ -329,6 +329,8 @@ struct SingleAddressForm: View { if #available(iOS 17, *), ServiceLocator.featureFlagService.isFeatureFlagEnabled(.orderAddressMapSearch) { Button(action: { showMapPicker = true + ServiceLocator.analytics.track(.orderDetailEditAddressMapPickerTapped, + withProperties: ["locale": Locale.current.identifier]) }) { HStack { Image(systemName: "map") diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index ae59bd01e1a..f1cae1c283e 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -269,6 +269,7 @@ 024A543422BA6F8F00F4F38E /* DeveloperEmailChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024A543322BA6F8F00F4F38E /* DeveloperEmailChecker.swift */; }; 024A543622BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024A543522BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift */; }; 024A8F1F2A588FA500ABF3EB /* EditableImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024A8F1E2A588FA500ABF3EB /* EditableImageView.swift */; }; + 024B9F0E2E39E0F7007757E3 /* AddressMapPickerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024B9F0D2E39E0F4007757E3 /* AddressMapPickerViewModelTests.swift */; }; 024D4E842A1B4B630090E0E6 /* WooAnalyticsEvent+ProductForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024D4E832A1B4B630090E0E6 /* WooAnalyticsEvent+ProductForm.swift */; }; 024DF3052372ADCD006658FE /* KeyboardScrollable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024DF3042372ADCD006658FE /* KeyboardScrollable.swift */; }; 024DF3072372C18D006658FE /* AztecUIConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024DF3062372C18D006658FE /* AztecUIConfigurator.swift */; }; @@ -3449,6 +3450,7 @@ 024A543322BA6F8F00F4F38E /* DeveloperEmailChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperEmailChecker.swift; sourceTree = ""; }; 024A543522BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperEmailCheckerTests.swift; sourceTree = ""; }; 024A8F1E2A588FA500ABF3EB /* EditableImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditableImageView.swift; sourceTree = ""; }; + 024B9F0D2E39E0F4007757E3 /* AddressMapPickerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressMapPickerViewModelTests.swift; sourceTree = ""; }; 024D4E832A1B4B630090E0E6 /* WooAnalyticsEvent+ProductForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+ProductForm.swift"; sourceTree = ""; }; 024DF3042372ADCD006658FE /* KeyboardScrollable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardScrollable.swift; sourceTree = ""; }; 024DF3062372C18D006658FE /* AztecUIConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AztecUIConfigurator.swift; sourceTree = ""; }; @@ -8818,6 +8820,7 @@ children = ( 26C6E8E226E2D85300C7BB0F /* CountrySelector */, AEE2611026E6785400B142A0 /* EditOrderAddressFormViewModelTests.swift */, + 024B9F0D2E39E0F4007757E3 /* AddressMapPickerViewModelTests.swift */, ); path = Addresses; sourceTree = ""; @@ -17554,6 +17557,7 @@ CCE4CD172667EBB100E09FD4 /* ShippingLabelPaymentMethodsViewModelTests.swift in Sources */, EE66BB122B29D65400518DAF /* MockThemeInstaller.swift in Sources */, 45AF9DA5265CEA89001EB794 /* ShippingLabelCarriersViewModelTests.swift in Sources */, + 024B9F0E2E39E0F7007757E3 /* AddressMapPickerViewModelTests.swift in Sources */, DEF8CF0D29A76F7E00800A60 /* JetpackSetupCoordinatorTests.swift in Sources */, 20FCBCE32CE24F5D0082DCA3 /* MockPointOfSaleAggregateModel.swift in Sources */, EE289AFC2C9D9CF0004AB1A6 /* AIToneVoiceViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/Addresses/AddressMapPickerViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/Addresses/AddressMapPickerViewModelTests.swift new file mode 100644 index 00000000000..64d1e7e9b8b --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/Addresses/AddressMapPickerViewModelTests.swift @@ -0,0 +1,190 @@ +import Testing +import MapKit +import Contacts +import Yosemite +@testable import WooCommerce + +struct AddressMapPickerViewModelTests { + private let mockCountryByCode: (String) -> Country? + + init() { + mockCountryByCode = { countryCode in + switch countryCode { + case "US": + return Country(code: "US", name: "USA", states: [ + StateOfACountry(code: "CA", name: "Cali") + ]) + default: + return nil + } + } + } + + // MARK: - Initialization Tests + + @available(iOS 17, *) + @Test func initialization_with_empty_fields_sets_properties_with_default_values() { + // Given + let emptyFields = AddressFormFields() + + // When + let sut = AddressMapPickerViewModel(fields: emptyFields, countryByCode: mockCountryByCode) + + // Then + #expect(sut.searchResults.isEmpty) + #expect(sut.annotations.isEmpty) + #expect(!sut.hasValidSelection) + } + + // MARK: - Selection Tests + + @available(iOS 17, *) + @Test func selectLocation_updates_annotations_and_hasValidSelection() async { + // Given + let fields = AddressFormFields() + let mockSearchProvider = MockAddressMapLocalSearchProvider.withBasicCoordinates() + let sut = AddressMapPickerViewModel(fields: fields, countryByCode: mockCountryByCode, searchProvider: mockSearchProvider) + let searchCompletion = MockMKLocalSearchCompletion() + + // When + await sut.selectLocation(searchCompletion) + + // Then + #expect(sut.annotations.count == 1) + #expect(sut.hasValidSelection == true) + } + + // MARK: - Address Field Updates Tests + + @available(iOS 17, *) + @Test func updateFields_with_no_selected_place_does_not_modify_fields() { + // Given + let sut = AddressMapPickerViewModel(fields: .init(), countryByCode: mockCountryByCode) + var updatedFields = AddressFormFields() + updatedFields.address1 = "Original Address" + updatedFields.city = "Original City" + + // When + sut.updateFields(&updatedFields) + + // Then + #expect(updatedFields.address1 == "Original Address") + #expect(updatedFields.city == "Original City") + } + + @available(iOS 17, *) + @Test func updateFields_when_country_not_found_in_countryByCode_sets_country_and_state_as_strings() async { + // Given + let mockSearchProvider = MockAddressMapLocalSearchProvider.withFrenchAddress() + let sut = AddressMapPickerViewModel(fields: .init(), countryByCode: mockCountryByCode, searchProvider: mockSearchProvider) + let searchCompletion = MockMKLocalSearchCompletion() + + await sut.selectLocation(searchCompletion) + + // When + var updatedFields = AddressFormFields() + sut.updateFields(&updatedFields) + + // Then + #expect(updatedFields.address1 == "Tour Eiffel") + #expect(updatedFields.city == "Paris") + #expect(updatedFields.postcode == "75007") + #expect(updatedFields.country == "FR") + #expect(updatedFields.state == "Île-de-France") + #expect(updatedFields.selectedCountry == nil) // Country is not found in countryByCode dictionary + #expect(updatedFields.selectedState == nil) + } + + @available(iOS 17, *) + @Test func updateFields_when_country_is_found_in_countryByCode_sets_selected_country_and_state() async { + // Given + let mockSearchProvider = MockAddressMapLocalSearchProvider.withUSAddress() + let sut = AddressMapPickerViewModel(fields: .init(), countryByCode: mockCountryByCode, searchProvider: mockSearchProvider) + let searchCompletion = MockMKLocalSearchCompletion() + + await sut.selectLocation(searchCompletion) + + // When + var updatedFields = AddressFormFields() + sut.updateFields(&updatedFields) + + // Then + #expect(updatedFields.address1 == "1 Apple Park Way") + #expect(updatedFields.city == "Cupertino") + #expect(updatedFields.postcode == "95014") + #expect(updatedFields.country == "USA") + #expect(updatedFields.state == "Cali") + #expect(updatedFields.selectedCountry?.code == "US") + #expect(updatedFields.selectedState?.code == "CA") + } +} + +// MARK: - Mock Classes + +final private class MockMKLocalSearchCompletion: MKLocalSearchCompletion {} + +final private class MockAddressMapLocalSearchProvider: AddressMapLocalSearchProviding { + private let mockPlacemark: MKPlacemark + + init(mockPlacemark: MKPlacemark) { + self.mockPlacemark = mockPlacemark + } + + func search(for completion: MKLocalSearchCompletion) async throws -> MKLocalSearch.Response { + let mockMapItem = MKMapItem(placemark: mockPlacemark) + let mockResponse = MockMKLocalSearchResponse(mapItems: [mockMapItem]) + return mockResponse + } +} + +final private class MockMKLocalSearchResponse: MKLocalSearch.Response { + private let _mapItems: [MKMapItem] + + init(mapItems: [MKMapItem]) { + self._mapItems = mapItems + super.init() + } + + override var mapItems: [MKMapItem] { _mapItems } +} + +private extension MockAddressMapLocalSearchProvider { + static func withFrenchAddress() -> MockAddressMapLocalSearchProvider { + let postalAddress = CNMutablePostalAddress() + postalAddress.street = "Tour Eiffel" + postalAddress.city = "Paris" + postalAddress.postalCode = "75007" + postalAddress.country = "France" + postalAddress.isoCountryCode = "FR" + postalAddress.state = "Île-de-France" + + let placemark = MKPlacemark( + coordinate: CLLocationCoordinate2D(latitude: 48.8584, longitude: 2.2945), + postalAddress: postalAddress + ) + + return MockAddressMapLocalSearchProvider(mockPlacemark: placemark) + } + + static func withUSAddress() -> MockAddressMapLocalSearchProvider { + let postalAddress = CNMutablePostalAddress() + postalAddress.street = "1 Apple Park Way" + postalAddress.city = "Cupertino" + postalAddress.postalCode = "95014" + postalAddress.country = "United States" + postalAddress.isoCountryCode = "US" + postalAddress.state = "CA" + + let placemark = MKPlacemark( + coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090), + postalAddress: postalAddress + ) + + return MockAddressMapLocalSearchProvider(mockPlacemark: placemark) + } + + static func withBasicCoordinates() -> MockAddressMapLocalSearchProvider { + let placemark = MKPlacemark(coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)) + return MockAddressMapLocalSearchProvider(mockPlacemark: placemark) + } +}