Skip to content

Commit e85b607

Browse files
authored
Merge branch 'trunk' into woomob-926-woo-posbarcodes-cft-updates
2 parents c845df2 + fe2f5ac commit e85b607

File tree

8 files changed

+228
-7
lines changed

8 files changed

+228
-7
lines changed

Modules/Sources/Experiments/DefaultFeatureFlagService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
104104
case .pointOfSaleBarcodeScanningi2:
105105
return buildConfig == .localDeveloper || buildConfig == .alpha
106106
case .orderAddressMapSearch:
107-
return buildConfig == .localDeveloper || buildConfig == .alpha
107+
return true
108108
default:
109109
return true
110110
}

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [*] Increased decimal sensitivity in order creation to mitigate tax rounding issues [https://github.com/woocommerce/woocommerce-ios/pull/15957]
77
- [*] Fix initialization of authenticator to avoid crashes during login [https://github.com/woocommerce/woocommerce-ios/pull/15953]
88
- [*] Shipping Labels: Made HS tariff number field required in customs form for EU destinations [https://github.com/woocommerce/woocommerce-ios/pull/15946]
9+
- [*] Order Details > Edit Shipping/Billing Address: Added map-based address lookup support for iOS 17+. [https://github.com/woocommerce/woocommerce-ios/pull/15964]
910
- [*] Order Creation: Prevent subscription products to be added to an order [https://github.com/woocommerce/woocommerce-ios/pull/15960]
1011
- [internal] Replace COTS_DEVICE reader model name with TAP_TO_PAY_DEVICE. [https://github.com/woocommerce/woocommerce-ios/pull/15961]
1112

WooCommerce/Classes/Analytics/WooAnalyticsStat.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,8 @@ enum WooAnalyticsStat: String {
450450
case orderDetailEditFlowFailed = "order_detail_edit_flow_failed"
451451
case orderDetailPaymentLinkShared = "order_detail_payment_link_shared"
452452
case orderDetailTrashButtonTapped = "order_detail_trash_tapped"
453+
case orderDetailEditAddressMapPickerTapped = "order_detail_edit_address_map_picker_tapped"
454+
case orderDetailEditAddressMapPickerUseAddressTapped = "order_detail_edit_address_map_picker_use_address_tapped"
453455

454456
// MARK: Test order
455457
//

WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ struct AddressMapPickerView: View {
9393
isSearchFocused = false
9494
viewModel.updateFields(&fields)
9595
dismiss()
96+
ServiceLocator.analytics.track(.orderDetailEditAddressMapPickerUseAddressTapped)
9697
}
9798
.disabled(!viewModel.hasValidSelection)
9899
}

WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/AddressMapPickerViewModel.swift

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ import AsyncAlgorithms
55
import CoreLocation
66
import struct Yosemite.Country
77

8+
/// Protocol for providing local search functionality for address map picker.
9+
/// Abstracts MKLocalSearch to enable testing with mock implementations.
10+
protocol AddressMapLocalSearchProviding {
11+
/// Performs a local search based on the provided search completion.
12+
/// - Parameter completion: The search completion containing location query information.
13+
/// - Returns: A search response containing map items with placemarks.
14+
/// - Throws: MapKit errors if the search fails.
15+
func search(for completion: MKLocalSearchCompletion) async throws -> MKLocalSearch.Response
16+
}
17+
18+
/// Default implementation of AddressMapLocalSearchProviding that uses MKLocalSearch.
19+
/// This is the production implementation that makes real network requests to MapKit services.
20+
final class DefaultAddressMapLocalSearchProvider: AddressMapLocalSearchProviding {
21+
func search(for completion: MKLocalSearchCompletion) async throws -> MKLocalSearch.Response {
22+
let searchRequest = MKLocalSearch.Request(completion: completion)
23+
let search = MKLocalSearch(request: searchRequest)
24+
return try await search.start()
25+
}
26+
}
27+
828
@available(iOS 17, *)
929
@Observable
1030
final class AddressMapPickerViewModel: NSObject {
@@ -13,7 +33,7 @@ final class AddressMapPickerViewModel: NSObject {
1333
searchQueryContinuation.yield(newValue)
1434
}
1535
}
16-
var searchResults: [MKLocalSearchCompletion] = []
36+
private(set) var searchResults: [MKLocalSearchCompletion] = []
1737
var region = MKCoordinateRegion(
1838
center: CLLocationCoordinate2D(latitude: 37.3361, longitude: -122.0380),
1939
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
@@ -37,9 +57,13 @@ final class AddressMapPickerViewModel: NSObject {
3757
private var selectedPlace: MKPlacemark?
3858

3959
private let countryByCode: (_ countryCode: String) -> Country?
60+
private let searchProvider: AddressMapLocalSearchProviding
4061

41-
init(fields: AddressFormFields, countryByCode: @escaping (_ countryCode: String) -> Country?) {
62+
init(fields: AddressFormFields,
63+
countryByCode: @escaping (_ countryCode: String) -> Country?,
64+
searchProvider: AddressMapLocalSearchProviding = DefaultAddressMapLocalSearchProvider()) {
4265
self.countryByCode = countryByCode
66+
self.searchProvider = searchProvider
4367
super.init()
4468
configureSearchCompleter()
4569
configureMap(with: fields)
@@ -65,11 +89,8 @@ final class AddressMapPickerViewModel: NSObject {
6589
/// - Parameter result: The selected search completion result.
6690
@MainActor
6791
func selectLocation(_ result: MKLocalSearchCompletion) async {
68-
let searchRequest = MKLocalSearch.Request(completion: result)
69-
let search = MKLocalSearch(request: searchRequest)
70-
7192
do {
72-
let response = try await search.start()
93+
let response = try await searchProvider.search(for: result)
7394
guard let firstPlacemark = response.mapItems.first?.placemark else {
7495
return
7596
}

WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/EditOrderAddressForm.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,8 @@ struct SingleAddressForm: View {
329329
if #available(iOS 17, *), ServiceLocator.featureFlagService.isFeatureFlagEnabled(.orderAddressMapSearch) {
330330
Button(action: {
331331
showMapPicker = true
332+
ServiceLocator.analytics.track(.orderDetailEditAddressMapPickerTapped,
333+
withProperties: ["locale": Locale.current.identifier])
332334
}) {
333335
HStack {
334336
Image(systemName: "map")

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@
269269
024A543422BA6F8F00F4F38E /* DeveloperEmailChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024A543322BA6F8F00F4F38E /* DeveloperEmailChecker.swift */; };
270270
024A543622BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024A543522BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift */; };
271271
024A8F1F2A588FA500ABF3EB /* EditableImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024A8F1E2A588FA500ABF3EB /* EditableImageView.swift */; };
272+
024B9F0E2E39E0F7007757E3 /* AddressMapPickerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024B9F0D2E39E0F4007757E3 /* AddressMapPickerViewModelTests.swift */; };
272273
024D4E842A1B4B630090E0E6 /* WooAnalyticsEvent+ProductForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024D4E832A1B4B630090E0E6 /* WooAnalyticsEvent+ProductForm.swift */; };
273274
024DF3052372ADCD006658FE /* KeyboardScrollable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024DF3042372ADCD006658FE /* KeyboardScrollable.swift */; };
274275
024DF3072372C18D006658FE /* AztecUIConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024DF3062372C18D006658FE /* AztecUIConfigurator.swift */; };
@@ -3449,6 +3450,7 @@
34493450
024A543322BA6F8F00F4F38E /* DeveloperEmailChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperEmailChecker.swift; sourceTree = "<group>"; };
34503451
024A543522BA84DB00F4F38E /* DeveloperEmailCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperEmailCheckerTests.swift; sourceTree = "<group>"; };
34513452
024A8F1E2A588FA500ABF3EB /* EditableImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditableImageView.swift; sourceTree = "<group>"; };
3453+
024B9F0D2E39E0F4007757E3 /* AddressMapPickerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressMapPickerViewModelTests.swift; sourceTree = "<group>"; };
34523454
024D4E832A1B4B630090E0E6 /* WooAnalyticsEvent+ProductForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+ProductForm.swift"; sourceTree = "<group>"; };
34533455
024DF3042372ADCD006658FE /* KeyboardScrollable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardScrollable.swift; sourceTree = "<group>"; };
34543456
024DF3062372C18D006658FE /* AztecUIConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AztecUIConfigurator.swift; sourceTree = "<group>"; };
@@ -8818,6 +8820,7 @@
88188820
children = (
88198821
26C6E8E226E2D85300C7BB0F /* CountrySelector */,
88208822
AEE2611026E6785400B142A0 /* EditOrderAddressFormViewModelTests.swift */,
8823+
024B9F0D2E39E0F4007757E3 /* AddressMapPickerViewModelTests.swift */,
88218824
);
88228825
path = Addresses;
88238826
sourceTree = "<group>";
@@ -17554,6 +17557,7 @@
1755417557
CCE4CD172667EBB100E09FD4 /* ShippingLabelPaymentMethodsViewModelTests.swift in Sources */,
1755517558
EE66BB122B29D65400518DAF /* MockThemeInstaller.swift in Sources */,
1755617559
45AF9DA5265CEA89001EB794 /* ShippingLabelCarriersViewModelTests.swift in Sources */,
17560+
024B9F0E2E39E0F7007757E3 /* AddressMapPickerViewModelTests.swift in Sources */,
1755717561
DEF8CF0D29A76F7E00800A60 /* JetpackSetupCoordinatorTests.swift in Sources */,
1755817562
20FCBCE32CE24F5D0082DCA3 /* MockPointOfSaleAggregateModel.swift in Sources */,
1755917563
EE289AFC2C9D9CF0004AB1A6 /* AIToneVoiceViewModelTests.swift in Sources */,
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import Testing
2+
import MapKit
3+
import Contacts
4+
import Yosemite
5+
@testable import WooCommerce
6+
7+
struct AddressMapPickerViewModelTests {
8+
private let mockCountryByCode: (String) -> Country?
9+
10+
init() {
11+
mockCountryByCode = { countryCode in
12+
switch countryCode {
13+
case "US":
14+
return Country(code: "US", name: "USA", states: [
15+
StateOfACountry(code: "CA", name: "Cali")
16+
])
17+
default:
18+
return nil
19+
}
20+
}
21+
}
22+
23+
// MARK: - Initialization Tests
24+
25+
@available(iOS 17, *)
26+
@Test func initialization_with_empty_fields_sets_properties_with_default_values() {
27+
// Given
28+
let emptyFields = AddressFormFields()
29+
30+
// When
31+
let sut = AddressMapPickerViewModel(fields: emptyFields, countryByCode: mockCountryByCode)
32+
33+
// Then
34+
#expect(sut.searchResults.isEmpty)
35+
#expect(sut.annotations.isEmpty)
36+
#expect(!sut.hasValidSelection)
37+
}
38+
39+
// MARK: - Selection Tests
40+
41+
@available(iOS 17, *)
42+
@Test func selectLocation_updates_annotations_and_hasValidSelection() async {
43+
// Given
44+
let fields = AddressFormFields()
45+
let mockSearchProvider = MockAddressMapLocalSearchProvider.withBasicCoordinates()
46+
let sut = AddressMapPickerViewModel(fields: fields, countryByCode: mockCountryByCode, searchProvider: mockSearchProvider)
47+
let searchCompletion = MockMKLocalSearchCompletion()
48+
49+
// When
50+
await sut.selectLocation(searchCompletion)
51+
52+
// Then
53+
#expect(sut.annotations.count == 1)
54+
#expect(sut.hasValidSelection == true)
55+
}
56+
57+
// MARK: - Address Field Updates Tests
58+
59+
@available(iOS 17, *)
60+
@Test func updateFields_with_no_selected_place_does_not_modify_fields() {
61+
// Given
62+
let sut = AddressMapPickerViewModel(fields: .init(), countryByCode: mockCountryByCode)
63+
var updatedFields = AddressFormFields()
64+
updatedFields.address1 = "Original Address"
65+
updatedFields.city = "Original City"
66+
67+
// When
68+
sut.updateFields(&updatedFields)
69+
70+
// Then
71+
#expect(updatedFields.address1 == "Original Address")
72+
#expect(updatedFields.city == "Original City")
73+
}
74+
75+
@available(iOS 17, *)
76+
@Test func updateFields_when_country_not_found_in_countryByCode_sets_country_and_state_as_strings() async {
77+
// Given
78+
let mockSearchProvider = MockAddressMapLocalSearchProvider.withFrenchAddress()
79+
let sut = AddressMapPickerViewModel(fields: .init(), countryByCode: mockCountryByCode, searchProvider: mockSearchProvider)
80+
let searchCompletion = MockMKLocalSearchCompletion()
81+
82+
await sut.selectLocation(searchCompletion)
83+
84+
// When
85+
var updatedFields = AddressFormFields()
86+
sut.updateFields(&updatedFields)
87+
88+
// Then
89+
#expect(updatedFields.address1 == "Tour Eiffel")
90+
#expect(updatedFields.city == "Paris")
91+
#expect(updatedFields.postcode == "75007")
92+
#expect(updatedFields.country == "FR")
93+
#expect(updatedFields.state == "Île-de-France")
94+
#expect(updatedFields.selectedCountry == nil) // Country is not found in countryByCode dictionary
95+
#expect(updatedFields.selectedState == nil)
96+
}
97+
98+
@available(iOS 17, *)
99+
@Test func updateFields_when_country_is_found_in_countryByCode_sets_selected_country_and_state() async {
100+
// Given
101+
let mockSearchProvider = MockAddressMapLocalSearchProvider.withUSAddress()
102+
let sut = AddressMapPickerViewModel(fields: .init(), countryByCode: mockCountryByCode, searchProvider: mockSearchProvider)
103+
let searchCompletion = MockMKLocalSearchCompletion()
104+
105+
await sut.selectLocation(searchCompletion)
106+
107+
// When
108+
var updatedFields = AddressFormFields()
109+
sut.updateFields(&updatedFields)
110+
111+
// Then
112+
#expect(updatedFields.address1 == "1 Apple Park Way")
113+
#expect(updatedFields.city == "Cupertino")
114+
#expect(updatedFields.postcode == "95014")
115+
#expect(updatedFields.country == "USA")
116+
#expect(updatedFields.state == "Cali")
117+
#expect(updatedFields.selectedCountry?.code == "US")
118+
#expect(updatedFields.selectedState?.code == "CA")
119+
}
120+
}
121+
122+
// MARK: - Mock Classes
123+
124+
final private class MockMKLocalSearchCompletion: MKLocalSearchCompletion {}
125+
126+
final private class MockAddressMapLocalSearchProvider: AddressMapLocalSearchProviding {
127+
private let mockPlacemark: MKPlacemark
128+
129+
init(mockPlacemark: MKPlacemark) {
130+
self.mockPlacemark = mockPlacemark
131+
}
132+
133+
func search(for completion: MKLocalSearchCompletion) async throws -> MKLocalSearch.Response {
134+
let mockMapItem = MKMapItem(placemark: mockPlacemark)
135+
let mockResponse = MockMKLocalSearchResponse(mapItems: [mockMapItem])
136+
return mockResponse
137+
}
138+
}
139+
140+
final private class MockMKLocalSearchResponse: MKLocalSearch.Response {
141+
private let _mapItems: [MKMapItem]
142+
143+
init(mapItems: [MKMapItem]) {
144+
self._mapItems = mapItems
145+
super.init()
146+
}
147+
148+
override var mapItems: [MKMapItem] { _mapItems }
149+
}
150+
151+
private extension MockAddressMapLocalSearchProvider {
152+
static func withFrenchAddress() -> MockAddressMapLocalSearchProvider {
153+
let postalAddress = CNMutablePostalAddress()
154+
postalAddress.street = "Tour Eiffel"
155+
postalAddress.city = "Paris"
156+
postalAddress.postalCode = "75007"
157+
postalAddress.country = "France"
158+
postalAddress.isoCountryCode = "FR"
159+
postalAddress.state = "Île-de-France"
160+
161+
let placemark = MKPlacemark(
162+
coordinate: CLLocationCoordinate2D(latitude: 48.8584, longitude: 2.2945),
163+
postalAddress: postalAddress
164+
)
165+
166+
return MockAddressMapLocalSearchProvider(mockPlacemark: placemark)
167+
}
168+
169+
static func withUSAddress() -> MockAddressMapLocalSearchProvider {
170+
let postalAddress = CNMutablePostalAddress()
171+
postalAddress.street = "1 Apple Park Way"
172+
postalAddress.city = "Cupertino"
173+
postalAddress.postalCode = "95014"
174+
postalAddress.country = "United States"
175+
postalAddress.isoCountryCode = "US"
176+
postalAddress.state = "CA"
177+
178+
let placemark = MKPlacemark(
179+
coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090),
180+
postalAddress: postalAddress
181+
)
182+
183+
return MockAddressMapLocalSearchProvider(mockPlacemark: placemark)
184+
}
185+
186+
static func withBasicCoordinates() -> MockAddressMapLocalSearchProvider {
187+
let placemark = MKPlacemark(coordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194))
188+
return MockAddressMapLocalSearchProvider(mockPlacemark: placemark)
189+
}
190+
}

0 commit comments

Comments
 (0)