Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
2 changes: 2 additions & 0 deletions WooCommerce/Classes/Analytics/WooAnalyticsStat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ struct AddressMapPickerView: View {
isSearchFocused = false
viewModel.updateFields(&fields)
dismiss()
ServiceLocator.analytics.track(.orderDetailEditAddressMapPickerUseAddressTapped)
}
.disabled(!viewModel.hasValidSelection)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3449,6 +3450,7 @@
024A543322BA6F8F00F4F38E /* DeveloperEmailChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperEmailChecker.swift; sourceTree = "<group>"; };
024A543522BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperEmailCheckerTests.swift; sourceTree = "<group>"; };
024A8F1E2A588FA500ABF3EB /* EditableImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditableImageView.swift; sourceTree = "<group>"; };
024B9F0D2E39E0F4007757E3 /* AddressMapPickerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressMapPickerViewModelTests.swift; sourceTree = "<group>"; };
024D4E832A1B4B630090E0E6 /* WooAnalyticsEvent+ProductForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+ProductForm.swift"; sourceTree = "<group>"; };
024DF3042372ADCD006658FE /* KeyboardScrollable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardScrollable.swift; sourceTree = "<group>"; };
024DF3062372C18D006658FE /* AztecUIConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AztecUIConfigurator.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -8818,6 +8820,7 @@
children = (
26C6E8E226E2D85300C7BB0F /* CountrySelector */,
AEE2611026E6785400B142A0 /* EditOrderAddressFormViewModelTests.swift */,
024B9F0D2E39E0F4007757E3 /* AddressMapPickerViewModelTests.swift */,
);
path = Addresses;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing the reference here caught my eye, is there a reason for the implementation to accept an inout and modify the reference rather than returning a copy? ( func updateFields(_ fields: AddressFormFields) -> AddressFormFields ). Then the tests would look something like:

// Given
fieldsToUpdate.city = "Original City"

// When
let updatedFields = sut.updateFields(fieldsToUpdate)

// Then
#expect(updatedFields.city == "Original City")

Copy link
Contributor Author

@jaclync jaclync Aug 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used inout here for direct mutation without copying the struct, which works well with SwiftUI's @Binding binding patterns. If using the functional approach (creating a new struct and updating the existing), because the map picker only updates a subset of address properties, other fields like names/email/phone need to be preserved manually with caution. Lemme know what you think!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, thanks! The scenario where we only have to update the subset of properties while preserving the rest definitely makes for a good use case of mutating the object directly to avoid errors.


// 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)
}
}