Skip to content

Commit d481ccd

Browse files
committed
Add a row below order details shipping address to display shipping address in map if available.
1 parent c9c8b48 commit d481ccd

File tree

8 files changed

+337
-5
lines changed

8 files changed

+337
-5
lines changed

WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,13 @@ private extension OrderDetailsDataSource {
448448
switch cell {
449449
case let cell as CustomerInfoTableViewCell where row == .shippingAddress:
450450
configureShippingAddress(cell: cell)
451+
case let cell where row == .shippingAddressMap:
452+
if #available(iOS 17.0, *) {
453+
guard let cell = cell as? HostingConfigurationTableViewCell<OrderDetailsShippingAddressMapView> else {
454+
return assertionFailure("Expected HostingConfigurationTableViewCell<OrderDetailsShippingAddressMapView> for shippingAddressMap row")
455+
}
456+
configureShippingAddressMap(cell: cell)
457+
}
451458
case let cell as CustomerNoteTableViewCell where row == .customerNote:
452459
configureCustomerNote(cell: cell)
453460
case let cell as WooBasicTableViewCell where row == .billingDetail:
@@ -1064,6 +1071,17 @@ private extension OrderDetailsDataSource {
10641071
cell.configureLayout()
10651072
}
10661073

1074+
@available(iOS 17.0, *)
1075+
private func configureShippingAddressMap(cell: HostingConfigurationTableViewCell<OrderDetailsShippingAddressMapView>) {
1076+
let viewModel = OrderDetailsShippingAddressMapViewModel(shippingAddress: order.shippingAddress) { [weak self] in
1077+
self?.onCellAction?(.openShippingAddressMap, nil)
1078+
}
1079+
1080+
let view = OrderDetailsShippingAddressMapView(viewModel: viewModel)
1081+
cell.host(view)
1082+
cell.selectionStyle = .none
1083+
}
1084+
10671085
private func configureShippingLine(cell: HostingConfigurationTableViewCell<ShippingLineRowView>, at indexPath: IndexPath) {
10681086
guard let shippingLine = shippingLines[safe: indexPath.row] else {
10691087
ServiceLocator.crashLogging.logMessage(
@@ -1550,8 +1568,11 @@ extension OrderDetailsDataSource {
15501568
}.allSatisfy { $0.virtual == true }
15511569

15521570

1553-
if order.shippingAddress != nil && orderContainsOnlyVirtualProducts == false {
1571+
if let shippingAddress = order.shippingAddress, orderContainsOnlyVirtualProducts == false {
15541572
rows.append(.shippingAddress)
1573+
if shippingAddress.formattedPostalAddress != nil, #available(iOS 17.0, *) {
1574+
rows.append(.shippingAddressMap)
1575+
}
15551576
}
15561577

15571578
/// Billing Address
@@ -2008,6 +2029,7 @@ extension OrderDetailsDataSource {
20082029
case issueRefundButton
20092030
case customerNote
20102031
case shippingAddress
2032+
case shippingAddressMap
20112033
case billingDetail
20122034
case payment
20132035
case customerPaid
@@ -2062,6 +2084,12 @@ extension OrderDetailsDataSource {
20622084
return CustomerNoteTableViewCell.reuseIdentifier
20632085
case .shippingAddress:
20642086
return CustomerInfoTableViewCell.reuseIdentifier
2087+
case .shippingAddressMap:
2088+
if #available(iOS 17.0, *) {
2089+
return HostingConfigurationTableViewCell<OrderDetailsShippingAddressMapView>.reuseIdentifier
2090+
} else {
2091+
return UITableViewCell.reuseIdentifier
2092+
}
20652093
case .billingDetail:
20662094
return WooBasicTableViewCell.reuseIdentifier
20672095
case .payment:
@@ -2144,6 +2172,7 @@ extension OrderDetailsDataSource {
21442172
case viewAddOns(addOns: [OrderItemProductAddOn])
21452173
case editCustomerNote
21462174
case editShippingAddress
2175+
case openShippingAddressMap
21472176
case trashOrder
21482177
}
21492178

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import Foundation
2+
import UIKit
3+
import MapKit
4+
import CoreLocation
5+
import NetworkingCore
6+
7+
/// Helper class to handle opening addresses in Maps apps.
8+
final class OrderDetailsMapLauncher {
9+
static func openAddress(_ address: Address, from viewController: UIViewController) {
10+
// Use custom URL scheme approach directly for better control and reliability
11+
openWithCustomURLSchemes(address: address, from: viewController)
12+
}
13+
14+
/// Opens address using custom URL schemes with app selection
15+
private static func openWithCustomURLSchemes(address: Address, from viewController: UIViewController) {
16+
let addressString = formatAddressForMaps(address)
17+
18+
let appleURL = createAppleMapsURL(from: addressString)
19+
20+
// Checks availability for each app explicitly.
21+
var availableOptions: [(URL, String)] = []
22+
23+
if let appleURL = appleURL, UIApplication.shared.canOpenURL(appleURL) {
24+
availableOptions.append((appleURL, "Apple Maps"))
25+
}
26+
27+
// Tries to find an available Google Maps URL
28+
if let googleURL = findAvailableGoogleMapsURL(for: addressString) {
29+
availableOptions.append((googleURL, "Google Maps"))
30+
}
31+
32+
guard !availableOptions.isEmpty else {
33+
// Fallback to web-based maps if no apps are available
34+
if let webURL = createWebMapsURL(from: addressString) {
35+
UIApplication.shared.open(webURL)
36+
}
37+
return
38+
}
39+
40+
if availableOptions.count == 1 {
41+
// Only one option available, open directly
42+
UIApplication.shared.open(availableOptions[0].0)
43+
} else {
44+
// Multiple options available, show action sheet
45+
showActionSheet(options: availableOptions, from: viewController)
46+
}
47+
}
48+
49+
private static func formatAddressForMaps(_ address: Address) -> String {
50+
return [
51+
address.address1,
52+
address.address2,
53+
address.city,
54+
address.state,
55+
address.postcode,
56+
address.country
57+
].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ", ")
58+
}
59+
60+
private static func createAppleMapsURL(from addressString: String) -> URL? {
61+
guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
62+
return nil
63+
}
64+
return URL(string: "http://maps.apple.com/?q=\(encodedAddress)")
65+
}
66+
67+
private static func createGoogleMapsURL(from addressString: String) -> URL? {
68+
guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
69+
return nil
70+
}
71+
// Try the newer Google Maps URL scheme first
72+
return URL(string: "googlemaps://?q=\(encodedAddress)")
73+
}
74+
75+
private static func findAvailableGoogleMapsURL(for addressString: String) -> URL? {
76+
guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
77+
return nil
78+
}
79+
80+
// Try different Google Maps URL schemes in order of preference
81+
let googleMapsSchemes = [
82+
"googlemaps://?q=\(encodedAddress)", // Modern Google Maps
83+
"comgooglemaps://?q=\(encodedAddress)", // Legacy Google Maps
84+
"gmap://?q=\(encodedAddress)" // Alternative scheme
85+
]
86+
87+
for schemeString in googleMapsSchemes {
88+
if let url = URL(string: schemeString), UIApplication.shared.canOpenURL(url) {
89+
return url
90+
}
91+
}
92+
93+
return nil
94+
}
95+
96+
private static func createWebMapsURL(from addressString: String) -> URL? {
97+
guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
98+
return nil
99+
}
100+
return URL(string: "https://maps.google.com/maps?q=\(encodedAddress)")
101+
}
102+
103+
private static func showActionSheet(options: [(URL, String)], from viewController: UIViewController) {
104+
let alertController = UIAlertController(
105+
title: NSLocalizedString("Open Address", comment: "Title for the action sheet to open address in maps"),
106+
message: nil,
107+
preferredStyle: .actionSheet
108+
)
109+
110+
for (url, name) in options {
111+
let action = UIAlertAction(title: name, style: .default) { _ in
112+
UIApplication.shared.open(url)
113+
}
114+
alertController.addAction(action)
115+
}
116+
117+
let cancelAction = UIAlertAction(
118+
title: NSLocalizedString("Cancel", comment: "Cancel action for opening address in maps"),
119+
style: .cancel
120+
)
121+
alertController.addAction(cancelAction)
122+
123+
// For iPad support
124+
if let popover = alertController.popoverPresentationController {
125+
popover.sourceView = viewController.view
126+
popover.sourceRect = CGRect(x: viewController.view.bounds.midX, y: viewController.view.bounds.midY, width: 0, height: 0)
127+
popover.permittedArrowDirections = []
128+
}
129+
130+
viewController.present(alertController, animated: true)
131+
}
132+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import SwiftUI
2+
import MapKit
3+
4+
@available(iOS 17.0, *)
5+
struct OrderDetailsShippingAddressMapView: View {
6+
let viewModel: OrderDetailsShippingAddressMapViewModel
7+
8+
var body: some View {
9+
VStack(spacing: 0) {
10+
if viewModel.isValidAddress {
11+
Group {
12+
if let coordinate = viewModel.coordinate {
13+
Map(position: .constant(viewModel.cameraPosition)) {
14+
Annotation(viewModel.shippingAddress?.fullNameWithCompany ?? "Address", coordinate: coordinate) {
15+
Image(systemName: "mappin.circle.fill")
16+
.foregroundColor(.red)
17+
.font(.title)
18+
.background(Color.white.clipShape(Circle()))
19+
}
20+
}
21+
.mapStyle(.standard)
22+
.mapControlVisibility(.hidden)
23+
.disabled(true) // Disable user interaction (scrolling, zooming)
24+
.frame(height: viewModel.mapHeight)
25+
.clipShape(RoundedRectangle(cornerRadius: 8))
26+
.contentShape(Rectangle())
27+
.onTapGesture {
28+
viewModel.onMapTapped?()
29+
}
30+
} else if viewModel.isGeocoding {
31+
RoundedRectangle(cornerRadius: 8)
32+
.fill(Color.gray.opacity(0.3))
33+
.frame(height: viewModel.mapHeight)
34+
.overlay(
35+
ProgressView()
36+
.progressViewStyle(CircularProgressViewStyle())
37+
)
38+
} else {
39+
// Empty state or failed geocoding
40+
RoundedRectangle(cornerRadius: 8)
41+
.fill(Color.gray.opacity(0.2))
42+
.frame(height: viewModel.mapHeight)
43+
.overlay(
44+
Image(systemName: "map")
45+
.foregroundColor(.gray)
46+
.font(.title2)
47+
)
48+
.contentShape(Rectangle())
49+
.onTapGesture {
50+
viewModel.onMapTapped?()
51+
}
52+
}
53+
}
54+
}
55+
}
56+
}
57+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import Foundation
2+
import SwiftUI
3+
import MapKit
4+
import CoreLocation
5+
import NetworkingCore
6+
7+
@available(iOS 17.0, *)
8+
@Observable
9+
final class OrderDetailsShippingAddressMapViewModel {
10+
let shippingAddress: Address?
11+
12+
private(set) var coordinate: CLLocationCoordinate2D?
13+
private(set) var isGeocoding: Bool = false
14+
var cameraPosition: MapCameraPosition = .automatic
15+
16+
private let geocoder = CLGeocoder()
17+
18+
/// The height of the map view - 150px if valid address, 0 if invalid
19+
var mapHeight: CGFloat {
20+
isValidAddress ? 150 : 0
21+
}
22+
23+
/// Whether the address is valid for showing a map
24+
var isValidAddress: Bool {
25+
guard let address = shippingAddress else { return false }
26+
27+
// An address is valid if it has at least city and country, or a street address
28+
let hasMinimalLocation = !address.city.isEmpty && !address.country.isEmpty
29+
let hasStreetAddress = !address.address1.isEmpty
30+
31+
return hasMinimalLocation || hasStreetAddress
32+
}
33+
34+
/// Action handler for when the map is tapped
35+
var onMapTapped: (() -> Void)?
36+
37+
init(shippingAddress: Address?, onMapTapped: (() -> Void)? = nil) {
38+
self.shippingAddress = shippingAddress
39+
self.onMapTapped = onMapTapped
40+
41+
if isValidAddress {
42+
geocodeAddress()
43+
}
44+
}
45+
46+
private func geocodeAddress() {
47+
guard let address = shippingAddress else { return }
48+
49+
isGeocoding = true
50+
51+
let addressString = [
52+
address.address1,
53+
address.address2,
54+
address.city,
55+
address.state,
56+
address.postcode,
57+
address.country
58+
].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ", ")
59+
60+
geocoder.geocodeAddressString(addressString) { [weak self] placemarks, error in
61+
DispatchQueue.main.async {
62+
self?.isGeocoding = false
63+
64+
guard let placemark = placemarks?.first,
65+
let location = placemark.location else {
66+
// Fallback to a default coordinate if geocoding fails
67+
self?.coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0)
68+
self?.cameraPosition = .region(MKCoordinateRegion(
69+
center: CLLocationCoordinate2D(latitude: 0, longitude: 0),
70+
latitudinalMeters: 10000,
71+
longitudinalMeters: 10000
72+
))
73+
return
74+
}
75+
let coordinate = location.coordinate
76+
self?.coordinate = coordinate
77+
self?.cameraPosition = .region(MKCoordinateRegion(
78+
center: coordinate,
79+
latitudinalMeters: 1000,
80+
longitudinalMeters: 1000
81+
))
82+
}
83+
}
84+
}
85+
}

WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -448,10 +448,17 @@ extension OrderDetailsViewModel {
448448
TitleAndValueTableViewCell.self
449449
]
450450

451-
let cellsWithoutNib = [
452-
HostingConfigurationTableViewCell<ShippingLineRowView>.self,
453-
HostingConfigurationTableViewCell<OrderDetailsShipmentDetailsView>.self,
454-
]
451+
let cellsWithoutNib: [UITableViewCell.Type] = {
452+
let iOS17Cells: [UITableViewCell.Type] = if #available(iOS 17.0, *) {
453+
[HostingConfigurationTableViewCell<OrderDetailsShippingAddressMapView>.self]
454+
} else {
455+
[]
456+
}
457+
return iOS17Cells + [
458+
HostingConfigurationTableViewCell<ShippingLineRowView>.self,
459+
HostingConfigurationTableViewCell<OrderDetailsShipmentDetailsView>.self
460+
]
461+
}()
455462

456463
for cellClass in cellsWithNib {
457464
tableView.registerNib(for: cellClass)

WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,8 @@ private extension OrderDetailsViewController {
421421
editCustomerNoteTapped()
422422
case .editShippingAddress:
423423
editShippingAddressTapped()
424+
case .openShippingAddressMap:
425+
openShippingAddressMapTapped()
424426
case .trashOrder:
425427
trashOrderTapped()
426428
}
@@ -656,6 +658,11 @@ private extension OrderDetailsViewController {
656658
present(navigationController, animated: true, completion: nil)
657659
}
658660

661+
func openShippingAddressMapTapped() {
662+
guard let shippingAddress = viewModel.order.shippingAddress else { return }
663+
OrderDetailsMapLauncher.openAddress(shippingAddress, from: self)
664+
}
665+
659666
func trashOrderTapped() {
660667
ServiceLocator.analytics.track(.orderDetailTrashButtonTapped)
661668

0 commit comments

Comments
 (0)