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..6ea218d94d7 --- /dev/null +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsMapLauncher.swift @@ -0,0 +1,134 @@ +import Foundation +import UIKit +import NetworkingCore + +/// Helper class to handle opening addresses in Maps apps. +final class OrderDetailsMapLauncher { + static func openAddress(_ address: Address, from viewController: UIViewController) { + openWithCustomURLSchemes(address: address, from: viewController) + } +} + +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, Localization.appleMaps)) + } + + // Tries to find an available Google Maps URL. + if let googleURL = findAvailableGoogleMapsURL(for: addressString) { + availableOptions.append((googleURL, Localization.googleMaps)) + } + + 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 { + UIApplication.shared.open(availableOptions[0].0) + } else { + showActionSheet(options: availableOptions, from: viewController) + } + } + + 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: ", ") + } + + 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)") + } + + 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 + } + + 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)") + } + + 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 new file mode 100644 index 00000000000..1761026014b --- /dev/null +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import MapKit + +@available(iOS 17.0, *) +struct OrderDetailsShippingAddressMapView: View { + let viewModel: OrderDetailsShippingAddressMapViewModel + + var body: some View { + VStack(spacing: 0) { + 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( + VStack(spacing: 8) { + Image(systemName: "map") + .foregroundColor(.gray) + .font(.title2) + Text(Localization.tapToOpenInMaps) + .font(.caption2) + .foregroundColor(.secondary) + } + ) + .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 + } + + 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 + +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 new file mode 100644 index 00000000000..03e0cbb0cef --- /dev/null +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShippingAddressMapViewModel.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftUI +import MapKit +import CoreLocation +import struct Yosemite.Address + +@available(iOS 17.0, *) +@Observable +final class OrderDetailsShippingAddressMapViewModel { + 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. + 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)? + + private let shippingAddress: Address? + private let geocoder = CLGeocoder() + + init(shippingAddress: Address?, onMapTapped: (() -> Void)? = nil) { + self.shippingAddress = shippingAddress + self.onMapTapped = onMapTapped + + if isValidAddress { + Task { + await geocodeAddress() + } + } + } +} + +@available(iOS 17.0, *) +private extension OrderDetailsShippingAddressMapViewModel { + @MainActor + func geocodeAddress() async { + guard let address = shippingAddress else { return } + + mapState = .loading + + let addressString = [ + address.address1, + address.address2, + address.city, + address.state, + address.postcode, + address.country + ].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ", ") + + 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 + } + } +} 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 */,