From d481ccd25f849edc92c0dd86ab97cc63eeda85a6 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 30 Jul 2025 10:31:26 +0800 Subject: [PATCH 1/4] Add a row below order details shipping address to display shipping address in map if available. --- .../OrderDetailsDataSource.swift | 31 +++- .../OrderDetailsMapLauncher.swift | 132 ++++++++++++++++++ .../OrderDetailsShippingAddressMapView.swift | 57 ++++++++ ...erDetailsShippingAddressMapViewModel.swift | 85 +++++++++++ .../Order Details/OrderDetailsViewModel.swift | 15 +- .../OrderDetailsViewController.swift | 7 + WooCommerce/Resources/Info.plist | 3 + .../WooCommerce.xcodeproj/project.pbxproj | 12 ++ 8 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 WooCommerce/Classes/ViewModels/Order Details/OrderDetailsMapLauncher.swift create mode 100644 WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift create mode 100644 WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapViewModel.swift diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift index 43c113f9ce0..004cceba052 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift @@ -448,6 +448,13 @@ private extension OrderDetailsDataSource { switch cell { case let cell as CustomerInfoTableViewCell where row == .shippingAddress: configureShippingAddress(cell: cell) + case let cell where row == .shippingAddressMap: + if #available(iOS 17.0, *) { + guard let cell = cell as? HostingConfigurationTableViewCell else { + return assertionFailure("Expected HostingConfigurationTableViewCell for shippingAddressMap row") + } + configureShippingAddressMap(cell: cell) + } case let cell as CustomerNoteTableViewCell where row == .customerNote: configureCustomerNote(cell: cell) case let cell as WooBasicTableViewCell where row == .billingDetail: @@ -1064,6 +1071,17 @@ private extension OrderDetailsDataSource { cell.configureLayout() } + @available(iOS 17.0, *) + private func configureShippingAddressMap(cell: HostingConfigurationTableViewCell) { + let viewModel = OrderDetailsShippingAddressMapViewModel(shippingAddress: order.shippingAddress) { [weak self] in + self?.onCellAction?(.openShippingAddressMap, nil) + } + + let view = OrderDetailsShippingAddressMapView(viewModel: viewModel) + cell.host(view) + cell.selectionStyle = .none + } + private func configureShippingLine(cell: HostingConfigurationTableViewCell, at indexPath: IndexPath) { guard let shippingLine = shippingLines[safe: indexPath.row] else { ServiceLocator.crashLogging.logMessage( @@ -1550,8 +1568,11 @@ extension OrderDetailsDataSource { }.allSatisfy { $0.virtual == true } - if order.shippingAddress != nil && orderContainsOnlyVirtualProducts == false { + if let shippingAddress = order.shippingAddress, orderContainsOnlyVirtualProducts == false { rows.append(.shippingAddress) + if shippingAddress.formattedPostalAddress != nil, #available(iOS 17.0, *) { + rows.append(.shippingAddressMap) + } } /// Billing Address @@ -2008,6 +2029,7 @@ extension OrderDetailsDataSource { case issueRefundButton case customerNote case shippingAddress + case shippingAddressMap case billingDetail case payment case customerPaid @@ -2062,6 +2084,12 @@ extension OrderDetailsDataSource { return CustomerNoteTableViewCell.reuseIdentifier case .shippingAddress: return CustomerInfoTableViewCell.reuseIdentifier + case .shippingAddressMap: + if #available(iOS 17.0, *) { + return HostingConfigurationTableViewCell.reuseIdentifier + } else { + return UITableViewCell.reuseIdentifier + } case .billingDetail: return WooBasicTableViewCell.reuseIdentifier case .payment: @@ -2144,6 +2172,7 @@ extension OrderDetailsDataSource { case viewAddOns(addOns: [OrderItemProductAddOn]) case editCustomerNote case editShippingAddress + case openShippingAddressMap case trashOrder } diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsMapLauncher.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsMapLauncher.swift new file mode 100644 index 00000000000..738921166ef --- /dev/null +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsMapLauncher.swift @@ -0,0 +1,132 @@ +import Foundation +import UIKit +import MapKit +import CoreLocation +import NetworkingCore + +/// Helper class to handle opening addresses in Maps apps. +final class OrderDetailsMapLauncher { + static func openAddress(_ address: Address, from viewController: UIViewController) { + // Use custom URL scheme approach directly for better control and reliability + openWithCustomURLSchemes(address: address, from: viewController) + } + + /// Opens address using custom URL schemes with app selection + private static func openWithCustomURLSchemes(address: Address, from viewController: UIViewController) { + let addressString = formatAddressForMaps(address) + + let appleURL = createAppleMapsURL(from: addressString) + + // Checks availability for each app explicitly. + var availableOptions: [(URL, String)] = [] + + if let appleURL = appleURL, UIApplication.shared.canOpenURL(appleURL) { + availableOptions.append((appleURL, "Apple Maps")) + } + + // Tries to find an available Google Maps URL + if let googleURL = findAvailableGoogleMapsURL(for: addressString) { + availableOptions.append((googleURL, "Google Maps")) + } + + guard !availableOptions.isEmpty else { + // Fallback to web-based maps if no apps are available + if let webURL = createWebMapsURL(from: addressString) { + UIApplication.shared.open(webURL) + } + return + } + + if availableOptions.count == 1 { + // Only one option available, open directly + UIApplication.shared.open(availableOptions[0].0) + } else { + // Multiple options available, show action sheet + showActionSheet(options: availableOptions, from: viewController) + } + } + + private static func formatAddressForMaps(_ address: Address) -> String { + return [ + address.address1, + address.address2, + address.city, + address.state, + address.postcode, + address.country + ].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ", ") + } + + private static func createAppleMapsURL(from addressString: String) -> URL? { + guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return nil + } + return URL(string: "http://maps.apple.com/?q=\(encodedAddress)") + } + + private static func createGoogleMapsURL(from addressString: String) -> URL? { + guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return nil + } + // Try the newer Google Maps URL scheme first + return URL(string: "googlemaps://?q=\(encodedAddress)") + } + + private static func findAvailableGoogleMapsURL(for addressString: String) -> URL? { + guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return nil + } + + // Try different Google Maps URL schemes in order of preference + let googleMapsSchemes = [ + "googlemaps://?q=\(encodedAddress)", // Modern Google Maps + "comgooglemaps://?q=\(encodedAddress)", // Legacy Google Maps + "gmap://?q=\(encodedAddress)" // Alternative scheme + ] + + for schemeString in googleMapsSchemes { + if let url = URL(string: schemeString), UIApplication.shared.canOpenURL(url) { + return url + } + } + + return nil + } + + private static func createWebMapsURL(from addressString: String) -> URL? { + guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return nil + } + return URL(string: "https://maps.google.com/maps?q=\(encodedAddress)") + } + + private static func showActionSheet(options: [(URL, String)], from viewController: UIViewController) { + let alertController = UIAlertController( + title: NSLocalizedString("Open Address", comment: "Title for the action sheet to open address in maps"), + message: nil, + preferredStyle: .actionSheet + ) + + for (url, name) in options { + let action = UIAlertAction(title: name, style: .default) { _ in + UIApplication.shared.open(url) + } + alertController.addAction(action) + } + + let cancelAction = UIAlertAction( + title: NSLocalizedString("Cancel", comment: "Cancel action for opening address in maps"), + style: .cancel + ) + alertController.addAction(cancelAction) + + // For iPad support + if let popover = alertController.popoverPresentationController { + popover.sourceView = viewController.view + popover.sourceRect = CGRect(x: viewController.view.bounds.midX, y: viewController.view.bounds.midY, width: 0, height: 0) + popover.permittedArrowDirections = [] + } + + viewController.present(alertController, animated: true) + } +} diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift new file mode 100644 index 00000000000..85631c2499e --- /dev/null +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift @@ -0,0 +1,57 @@ +import SwiftUI +import MapKit + +@available(iOS 17.0, *) +struct OrderDetailsShippingAddressMapView: View { + let viewModel: OrderDetailsShippingAddressMapViewModel + + var body: some View { + VStack(spacing: 0) { + if viewModel.isValidAddress { + Group { + if let coordinate = viewModel.coordinate { + Map(position: .constant(viewModel.cameraPosition)) { + Annotation(viewModel.shippingAddress?.fullNameWithCompany ?? "Address", coordinate: coordinate) { + Image(systemName: "mappin.circle.fill") + .foregroundColor(.red) + .font(.title) + .background(Color.white.clipShape(Circle())) + } + } + .mapStyle(.standard) + .mapControlVisibility(.hidden) + .disabled(true) // Disable user interaction (scrolling, zooming) + .frame(height: viewModel.mapHeight) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.onMapTapped?() + } + } else if viewModel.isGeocoding { + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.3)) + .frame(height: viewModel.mapHeight) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + ) + } else { + // Empty state or failed geocoding + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.2)) + .frame(height: viewModel.mapHeight) + .overlay( + Image(systemName: "map") + .foregroundColor(.gray) + .font(.title2) + ) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.onMapTapped?() + } + } + } + } + } + } +} diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapViewModel.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapViewModel.swift new file mode 100644 index 00000000000..75152ef0b04 --- /dev/null +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapViewModel.swift @@ -0,0 +1,85 @@ +import Foundation +import SwiftUI +import MapKit +import CoreLocation +import NetworkingCore + +@available(iOS 17.0, *) +@Observable +final class OrderDetailsShippingAddressMapViewModel { + let shippingAddress: Address? + + private(set) var coordinate: CLLocationCoordinate2D? + private(set) var isGeocoding: Bool = false + var cameraPosition: MapCameraPosition = .automatic + + private let geocoder = CLGeocoder() + + /// The height of the map view - 150px if valid address, 0 if invalid + var mapHeight: CGFloat { + isValidAddress ? 150 : 0 + } + + /// Whether the address is valid for showing a map + var isValidAddress: Bool { + guard let address = shippingAddress else { return false } + + // An address is valid if it has at least city and country, or a street address + let hasMinimalLocation = !address.city.isEmpty && !address.country.isEmpty + let hasStreetAddress = !address.address1.isEmpty + + return hasMinimalLocation || hasStreetAddress + } + + /// Action handler for when the map is tapped + var onMapTapped: (() -> Void)? + + init(shippingAddress: Address?, onMapTapped: (() -> Void)? = nil) { + self.shippingAddress = shippingAddress + self.onMapTapped = onMapTapped + + if isValidAddress { + geocodeAddress() + } + } + + private func geocodeAddress() { + guard let address = shippingAddress else { return } + + isGeocoding = true + + let addressString = [ + address.address1, + address.address2, + address.city, + address.state, + address.postcode, + address.country + ].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ", ") + + geocoder.geocodeAddressString(addressString) { [weak self] placemarks, error in + DispatchQueue.main.async { + self?.isGeocoding = false + + guard let placemark = placemarks?.first, + let location = placemark.location else { + // Fallback to a default coordinate if geocoding fails + self?.coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0) + self?.cameraPosition = .region(MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 0, longitude: 0), + latitudinalMeters: 10000, + longitudinalMeters: 10000 + )) + return + } + let coordinate = location.coordinate + self?.coordinate = coordinate + self?.cameraPosition = .region(MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 1000, + longitudinalMeters: 1000 + )) + } + } + } +} diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift index 19eba8e33e0..cb44fea43bd 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift @@ -448,10 +448,17 @@ extension OrderDetailsViewModel { TitleAndValueTableViewCell.self ] - let cellsWithoutNib = [ - HostingConfigurationTableViewCell.self, - HostingConfigurationTableViewCell.self, - ] + let cellsWithoutNib: [UITableViewCell.Type] = { + let iOS17Cells: [UITableViewCell.Type] = if #available(iOS 17.0, *) { + [HostingConfigurationTableViewCell.self] + } else { + [] + } + return iOS17Cells + [ + HostingConfigurationTableViewCell.self, + HostingConfigurationTableViewCell.self + ] + }() for cellClass in cellsWithNib { tableView.registerNib(for: cellClass) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift index e21ef019ad0..f7f4ddcc784 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift @@ -421,6 +421,8 @@ private extension OrderDetailsViewController { editCustomerNoteTapped() case .editShippingAddress: editShippingAddressTapped() + case .openShippingAddressMap: + openShippingAddressMapTapped() case .trashOrder: trashOrderTapped() } @@ -656,6 +658,11 @@ private extension OrderDetailsViewController { present(navigationController, animated: true, completion: nil) } + func openShippingAddressMapTapped() { + guard let shippingAddress = viewModel.order.shippingAddress else { return } + OrderDetailsMapLauncher.openAddress(shippingAddress, from: self) + } + func trashOrderTapped() { ServiceLocator.analytics.track(.orderDetailTrashButtonTapped) diff --git a/WooCommerce/Resources/Info.plist b/WooCommerce/Resources/Info.plist index 4be39f02441..385534d1da6 100644 --- a/WooCommerce/Resources/Info.plist +++ b/WooCommerce/Resources/Info.plist @@ -61,6 +61,9 @@ fastmail whatsapp tg + googlemaps + comgooglemaps + maps LSRequiresIPhoneOS diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 9d17a69156f..f8dadcdcceb 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -269,6 +269,9 @@ 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 */; }; + 024B9B502E386D46007757E3 /* OrderDetailsShippingAddressMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024B9B4F2E386D3A007757E3 /* OrderDetailsShippingAddressMapView.swift */; }; + 024B9B532E3871A9007757E3 /* OrderDetailsShippingAddressMapViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024B9B522E3871A9007757E3 /* OrderDetailsShippingAddressMapViewModel.swift */; }; + 024B9B542E3871A9007757E3 /* OrderDetailsMapLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024B9B512E3871A9007757E3 /* OrderDetailsMapLauncher.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 */; }; @@ -3447,6 +3450,9 @@ 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 = ""; }; + 024B9B4F2E386D3A007757E3 /* OrderDetailsShippingAddressMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsShippingAddressMapView.swift; sourceTree = ""; }; + 024B9B512E3871A9007757E3 /* OrderDetailsMapLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsMapLauncher.swift; sourceTree = ""; }; + 024B9B522E3871A9007757E3 /* OrderDetailsShippingAddressMapViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsShippingAddressMapViewModel.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 = ""; }; @@ -12873,6 +12879,9 @@ D817586122BB64C300289CFE /* OrderDetailsNotices.swift */, D817586322BDD81600289CFE /* OrderDetailsDataSource.swift */, DE8C63AD2E1E2D1400DA48AC /* OrderDetailsShipmentDetailsView.swift */, + 024B9B4F2E386D3A007757E3 /* OrderDetailsShippingAddressMapView.swift */, + 024B9B512E3871A9007757E3 /* OrderDetailsMapLauncher.swift */, + 024B9B522E3871A9007757E3 /* OrderDetailsShippingAddressMapViewModel.swift */, D8C11A4D22DD235F00D4A88D /* OrderDetailsResultsControllers.swift */, D8C11A5D22E2440400D4A88D /* OrderPaymentDetailsViewModel.swift */, 31316F9B25CB20FD00D9F129 /* OrderStatusListViewModel.swift */, @@ -15045,6 +15054,8 @@ 0205021E27C8B6C600FB1C6B /* InboxEligibilityUseCase.swift in Sources */, 26E7EE6E29300E8100793045 /* AnalyticsTopPerformersCard.swift in Sources */, 03E471DA29424E82001A58AD /* CardPresentModalTapToPaySuccess.swift in Sources */, + 024B9B532E3871A9007757E3 /* OrderDetailsShippingAddressMapViewModel.swift in Sources */, + 024B9B542E3871A9007757E3 /* OrderDetailsMapLauncher.swift in Sources */, 26E1BECE251CD9F80096D0A1 /* RefundItemViewModel.swift in Sources */, DE7B479027A153C20018742E /* CouponSearchUICommand.swift in Sources */, 026826B52BF59E330036F959 /* CardReaderConnectionStatusView.swift in Sources */, @@ -16394,6 +16405,7 @@ DE792E1B26EF37ED0071200C /* DefaultConnectivityObserver.swift in Sources */, 029F29FE24DA5B2D004751CA /* ProductInventorySettingsViewModel.swift in Sources */, 57CFCD28248845B4003F51EC /* PrimarySectionHeaderView.swift in Sources */, + 024B9B502E386D46007757E3 /* OrderDetailsShippingAddressMapView.swift in Sources */, 023A059A24135F2600E3FC99 /* ReviewsViewController.swift in Sources */, CEA455C92BB5DA4C00D932CF /* AnalyticsSessionsReportCard.swift in Sources */, DEC75CC22BC4E53800763801 /* DashboardCustomizationView.swift in Sources */, From 10b7416857086d8e4a5302a888fed343a61c190a Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 30 Jul 2025 16:26:23 +0800 Subject: [PATCH 2/4] Refactor view state with `MapState` enum with SwiftUI previews. --- .../OrderDetailsShippingAddressMapView.swift | 139 ++++++++++++------ ...erDetailsShippingAddressMapViewModel.swift | 72 ++++----- 2 files changed, 135 insertions(+), 76 deletions(-) diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift index 85631c2499e..91235d8b776 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift @@ -7,51 +7,106 @@ struct OrderDetailsShippingAddressMapView: View { var body: some View { VStack(spacing: 0) { - if viewModel.isValidAddress { - Group { - if let coordinate = viewModel.coordinate { - Map(position: .constant(viewModel.cameraPosition)) { - Annotation(viewModel.shippingAddress?.fullNameWithCompany ?? "Address", coordinate: coordinate) { - Image(systemName: "mappin.circle.fill") - .foregroundColor(.red) - .font(.title) - .background(Color.white.clipShape(Circle())) - } - } - .mapStyle(.standard) - .mapControlVisibility(.hidden) - .disabled(true) // Disable user interaction (scrolling, zooming) - .frame(height: viewModel.mapHeight) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.onMapTapped?() - } - } else if viewModel.isGeocoding { - RoundedRectangle(cornerRadius: 8) - .fill(Color.gray.opacity(0.3)) - .frame(height: viewModel.mapHeight) - .overlay( - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - ) - } else { - // Empty state or failed geocoding - RoundedRectangle(cornerRadius: 8) - .fill(Color.gray.opacity(0.2)) - .frame(height: viewModel.mapHeight) - .overlay( - Image(systemName: "map") - .foregroundColor(.gray) - .font(.title2) - ) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.onMapTapped?() - } + switch viewModel.mapState { + case let .loaded(coordinate, cameraPosition): + Map(position: .constant(cameraPosition)) { + Annotation("", coordinate: coordinate) { + Image(systemName: "mappin.circle.fill") + .foregroundColor(.red) + .font(.title) + .background(Color.white.clipShape(Circle())) } } + .mapStyle(.standard) + .mapControlVisibility(.hidden) + .disabled(true) // Disable user interaction (scrolling, zooming) + .clipShape(RoundedRectangle(cornerRadius: Layout.cornerRadius)) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.onMapTapped?() + } + case .loading: + RoundedRectangle(cornerRadius: Layout.cornerRadius) + .fill(Color.gray.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + ) + case .failed, .none: + RoundedRectangle(cornerRadius: Layout.cornerRadius) + .fill(Color.gray.opacity(0.2)) + .overlay( + Image(systemName: "map") + .foregroundColor(.gray) + .font(.title2) + ) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.onMapTapped?() + } } } + .frame(height: viewModel.mapHeight) + .renderedIf(viewModel.isValidAddress) } } + +@available(iOS 17.0, *) +private extension OrderDetailsShippingAddressMapView { + enum Layout { + static let cornerRadius: CGFloat = 8 + } +} + +#if DEBUG + +import struct Yosemite.Address + +@available(iOS 17.0, *) +#Preview { + let sampleAddress = Address( + firstName: "", + lastName: "", + company: "", + address1: "60 29th Street #343", + address2: "Suite 100", + city: "San Francisco", + state: "CA", + postcode: "94102", + country: "US", + phone: "+1-555-0123", + email: "woo@example.com" + ) + let viewModel = OrderDetailsShippingAddressMapViewModel(shippingAddress: sampleAddress) + return OrderDetailsShippingAddressMapView(viewModel: viewModel) + .padding() +} + +@available(iOS 17.0, *) +#Preview("Invalid address") { + let sampleAddress = Address( + firstName: "", + lastName: "", + company: "", + address1: "", + address2: "", + city: "ZZ", + state: "", + postcode: "", + country: "US", + phone: "+1-555-0123", + email: "woo@example.com" + ) + let viewModel = OrderDetailsShippingAddressMapViewModel(shippingAddress: sampleAddress) + return OrderDetailsShippingAddressMapView(viewModel: viewModel) + .padding() +} + +@available(iOS 17.0, *) +#Preview("No address") { + let viewModel = OrderDetailsShippingAddressMapViewModel(shippingAddress: nil) + return OrderDetailsShippingAddressMapView(viewModel: viewModel) + .padding() +} + +#endif diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapViewModel.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapViewModel.swift index 75152ef0b04..03e0cbb0cef 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapViewModel.swift @@ -2,20 +2,19 @@ import Foundation import SwiftUI import MapKit import CoreLocation -import NetworkingCore +import struct Yosemite.Address @available(iOS 17.0, *) @Observable final class OrderDetailsShippingAddressMapViewModel { - let shippingAddress: Address? - - private(set) var coordinate: CLLocationCoordinate2D? - private(set) var isGeocoding: Bool = false - var cameraPosition: MapCameraPosition = .automatic - - private let geocoder = CLGeocoder() + enum MapState { + case loading + case loaded(coordinate: CLLocationCoordinate2D, cameraPosition: MapCameraPosition) + case failed + } + private(set) var mapState: MapState? - /// The height of the map view - 150px if valid address, 0 if invalid + /// The height of the map view - 150px if valid address, 0 if invalid. var mapHeight: CGFloat { isValidAddress ? 150 : 0 } @@ -34,19 +33,28 @@ final class OrderDetailsShippingAddressMapViewModel { /// Action handler for when the map is tapped var onMapTapped: (() -> Void)? + private let shippingAddress: Address? + private let geocoder = CLGeocoder() + init(shippingAddress: Address?, onMapTapped: (() -> Void)? = nil) { self.shippingAddress = shippingAddress self.onMapTapped = onMapTapped if isValidAddress { - geocodeAddress() + Task { + await geocodeAddress() + } } } +} - private func geocodeAddress() { +@available(iOS 17.0, *) +private extension OrderDetailsShippingAddressMapViewModel { + @MainActor + func geocodeAddress() async { guard let address = shippingAddress else { return } - isGeocoding = true + mapState = .loading let addressString = [ address.address1, @@ -57,29 +65,25 @@ final class OrderDetailsShippingAddressMapViewModel { address.country ].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ", ") - geocoder.geocodeAddressString(addressString) { [weak self] placemarks, error in - DispatchQueue.main.async { - self?.isGeocoding = false - - guard let placemark = placemarks?.first, - let location = placemark.location else { - // Fallback to a default coordinate if geocoding fails - self?.coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0) - self?.cameraPosition = .region(MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 0, longitude: 0), - latitudinalMeters: 10000, - longitudinalMeters: 10000 - )) - return - } - let coordinate = location.coordinate - self?.coordinate = coordinate - self?.cameraPosition = .region(MKCoordinateRegion( - center: coordinate, - latitudinalMeters: 1000, - longitudinalMeters: 1000 - )) + do { + let placemarks = try await geocoder.geocodeAddressString(addressString) + + guard let placemark = placemarks.first, + let location = placemark.location else { + mapState = .failed + return } + + let coordinate = location.coordinate + let cameraPosition = MapCameraPosition.region(MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 1000, + longitudinalMeters: 1000 + )) + + mapState = .loaded(coordinate: coordinate, cameraPosition: cameraPosition) + } catch { + mapState = .failed } } } From 010a401adeded9751233f4bc4d6047b61a6eaa9f Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 31 Jul 2025 09:36:42 +0800 Subject: [PATCH 3/4] Localize strings. --- .../OrderDetailsMapLauncher.swift | 86 ++++++++++--------- .../OrderDetailsShippingAddressMapView.swift | 8 ++ 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsMapLauncher.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsMapLauncher.swift index 738921166ef..6ea218d94d7 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsMapLauncher.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsMapLauncher.swift @@ -1,52 +1,49 @@ import Foundation import UIKit -import MapKit -import CoreLocation import NetworkingCore /// Helper class to handle opening addresses in Maps apps. final class OrderDetailsMapLauncher { static func openAddress(_ address: Address, from viewController: UIViewController) { - // Use custom URL scheme approach directly for better control and reliability openWithCustomURLSchemes(address: address, from: viewController) } +} - /// Opens address using custom URL schemes with app selection - private static func openWithCustomURLSchemes(address: Address, from viewController: UIViewController) { +private extension OrderDetailsMapLauncher { + /// Opens address using custom URL schemes with app selection if more than one maps app is available. + static func openWithCustomURLSchemes(address: Address, from viewController: UIViewController) { let addressString = formatAddressForMaps(address) - + let appleURL = createAppleMapsURL(from: addressString) - + // Checks availability for each app explicitly. var availableOptions: [(URL, String)] = [] - + if let appleURL = appleURL, UIApplication.shared.canOpenURL(appleURL) { - availableOptions.append((appleURL, "Apple Maps")) + availableOptions.append((appleURL, Localization.appleMaps)) } - - // Tries to find an available Google Maps URL + + // Tries to find an available Google Maps URL. if let googleURL = findAvailableGoogleMapsURL(for: addressString) { - availableOptions.append((googleURL, "Google Maps")) + availableOptions.append((googleURL, Localization.googleMaps)) } - + guard !availableOptions.isEmpty else { - // Fallback to web-based maps if no apps are available + // Fallback to web-based maps if no apps are available. if let webURL = createWebMapsURL(from: addressString) { UIApplication.shared.open(webURL) } return } - + if availableOptions.count == 1 { - // Only one option available, open directly UIApplication.shared.open(availableOptions[0].0) } else { - // Multiple options available, show action sheet showActionSheet(options: availableOptions, from: viewController) } } - - private static func formatAddressForMaps(_ address: Address) -> String { + + static func formatAddressForMaps(_ address: Address) -> String { return [ address.address1, address.address2, @@ -56,77 +53,82 @@ final class OrderDetailsMapLauncher { address.country ].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ", ") } - - private static func createAppleMapsURL(from addressString: String) -> URL? { + + static func createAppleMapsURL(from addressString: String) -> URL? { guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return nil } return URL(string: "http://maps.apple.com/?q=\(encodedAddress)") } - - private static func createGoogleMapsURL(from addressString: String) -> URL? { - guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { - return nil - } - // Try the newer Google Maps URL scheme first - return URL(string: "googlemaps://?q=\(encodedAddress)") - } - - private static func findAvailableGoogleMapsURL(for addressString: String) -> URL? { + + static func findAvailableGoogleMapsURL(for addressString: String) -> URL? { guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return nil } - + // Try different Google Maps URL schemes in order of preference let googleMapsSchemes = [ "googlemaps://?q=\(encodedAddress)", // Modern Google Maps "comgooglemaps://?q=\(encodedAddress)", // Legacy Google Maps "gmap://?q=\(encodedAddress)" // Alternative scheme ] - + for schemeString in googleMapsSchemes { if let url = URL(string: schemeString), UIApplication.shared.canOpenURL(url) { return url } } - + return nil } - - private static func createWebMapsURL(from addressString: String) -> URL? { + + static func createWebMapsURL(from addressString: String) -> URL? { guard let encodedAddress = addressString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return nil } return URL(string: "https://maps.google.com/maps?q=\(encodedAddress)") } - - private static func showActionSheet(options: [(URL, String)], from viewController: UIViewController) { + + static func showActionSheet(options: [(URL, String)], from viewController: UIViewController) { let alertController = UIAlertController( title: NSLocalizedString("Open Address", comment: "Title for the action sheet to open address in maps"), message: nil, preferredStyle: .actionSheet ) - + for (url, name) in options { let action = UIAlertAction(title: name, style: .default) { _ in UIApplication.shared.open(url) } alertController.addAction(action) } - + let cancelAction = UIAlertAction( title: NSLocalizedString("Cancel", comment: "Cancel action for opening address in maps"), style: .cancel ) alertController.addAction(cancelAction) - + // For iPad support if let popover = alertController.popoverPresentationController { popover.sourceView = viewController.view popover.sourceRect = CGRect(x: viewController.view.bounds.midX, y: viewController.view.bounds.midY, width: 0, height: 0) popover.permittedArrowDirections = [] } - + viewController.present(alertController, animated: true) } } + +private enum Localization { + static let appleMaps = NSLocalizedString( + "orderDetails.mapLauncher.appleMaps", + value: "Apple Maps", + comment: "Name of Apple Maps app in action sheet" + ) + static let googleMaps = NSLocalizedString( + "orderDetails.mapLauncher.googleMaps", + value: "Google Maps", + comment: "Name of Google Maps app in action sheet" + ) +} diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift index 91235d8b776..10092663d03 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift @@ -56,6 +56,14 @@ private extension OrderDetailsShippingAddressMapView { enum Layout { static let cornerRadius: CGFloat = 8 } + + enum Localization { + static let tapToOpenInMaps = NSLocalizedString( + "orderDetails.shippingAddress.map.tapToOpen", + value: "Tap to open in Maps", + comment: "Text shown to indicate users can tap the map to open the address in Maps app" + ) + } } #if DEBUG From 589f33a8e14eeb6536d43dc407a0587a168b94a8 Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Thu, 31 Jul 2025 09:42:45 +0800 Subject: [PATCH 4/4] Show instruction text when geocoding fails or is unavailable. --- .../OrderDetailsShippingAddressMapView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift index 10092663d03..1761026014b 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift @@ -36,9 +36,14 @@ struct OrderDetailsShippingAddressMapView: View { RoundedRectangle(cornerRadius: Layout.cornerRadius) .fill(Color.gray.opacity(0.2)) .overlay( - Image(systemName: "map") - .foregroundColor(.gray) - .font(.title2) + VStack(spacing: 8) { + Image(systemName: "map") + .foregroundColor(.gray) + .font(.title2) + Text(Localization.tapToOpenInMaps) + .font(.caption2) + .foregroundColor(.secondary) + } ) .contentShape(Rectangle()) .onTapGesture {