Files
comaps/iphone/Maps/UI/PlacePage/PlacePageInteractor.swift
Eugene Nikolsky 50e6376afd [ios] Support multiple phone numbers
Multiple phone numbers should be [separated with `;`][parsing] in OSM
`phone` values. This commit adds support for parsing and displaying such
phone numbers individually. Example POI with three phone numbers:
https://www.openstreetmap.org/way/233417266.

Before this change, the phone was displayed as one
value and trying to call it would fail because all the digits were
concatenated together, resulting in an invalid number. For the POI
above, the program tried to call
`tel://+150332563111503325879018008756807`.

This change fixes the parsing of `FMD_PHONE_NUMBER` into an array of
phone numbers. That required updates in a few areas:

- the POI details view (`PlacePageInfoViewController`) now displays
  every phone number as a separate row, each with a clickable link for
  that number; long-click to copy also works.

- the share info preparation (`MWMShareActivityItem`) displays phone
  numbers separated with `; `, which provides a better phone detection
  for iOS.

- the Call button (`PlacePageInteractor`) now has to ask the user which
  number to call if there are more than one.

I tested this on an iPhone 15 Pro, iOS 17.2 simulator (temporarily
commenting the "can make phone call" checks).

Note: the Editing screen wasn't updated in order to keep this PR
smaller.

Fixes https://git.omaps.dev/organicmaps/organicmaps/issues/2458. The
corresponding fix for Android was in
https://github.com/organicmaps/organicmaps/pull/845.

[parsing]: https://wiki.openstreetmap.org/wiki/Key:phone#Parsing_phone_numbers

Signed-off-by: Eugene Nikolsky <omaps@egeek.me>
2025-05-19 10:52:42 +02:00

357 lines
12 KiB
Swift

protocol PlacePageInteractorProtocol: AnyObject {
func viewWillAppear()
func updateTopBound(_ bound: CGFloat, duration: TimeInterval)
}
class PlacePageInteractor: NSObject {
var presenter: PlacePagePresenterProtocol?
weak var viewController: UIViewController?
weak var mapViewController: MapViewController?
private let bookmarksManager = BookmarksManager.shared()
private var placePageData: PlacePageData
private var viewWillAppearIsCalledForTheFirstTime = false
init(viewController: UIViewController, data: PlacePageData, mapViewController: MapViewController) {
self.placePageData = data
self.viewController = viewController
self.mapViewController = mapViewController
super.init()
addToBookmarksManagerObserverList()
}
deinit {
removeFromBookmarksManagerObserverList()
}
private func updatePlacePageIfNeeded() {
let isBookmark = placePageData.bookmarkData != nil && bookmarksManager.hasBookmark(placePageData.bookmarkData!.bookmarkId)
let isTrack = placePageData.trackData != nil && bookmarksManager.hasTrack(placePageData.trackData!.trackId)
guard isBookmark || isTrack else {
presenter?.closeAnimated()
return
}
FrameworkHelper.updatePlacePageData()
placePageData.updateBookmarkStatus()
}
private func addToBookmarksManagerObserverList() {
bookmarksManager.add(self)
}
private func removeFromBookmarksManagerObserverList() {
bookmarksManager.remove(self)
}
}
extension PlacePageInteractor: PlacePageInteractorProtocol {
func viewWillAppear() {
// Skip data reloading on the first appearance, to avoid unnecessary updates.
guard viewWillAppearIsCalledForTheFirstTime else {
viewWillAppearIsCalledForTheFirstTime = true
return
}
updatePlacePageIfNeeded()
}
func updateTopBound(_ bound: CGFloat, duration: TimeInterval) {
mapViewController?.setPlacePageTopBound(bound, duration: duration)
}
}
// MARK: - PlacePageInfoViewControllerDelegate
extension PlacePageInteractor: PlacePageInfoViewControllerDelegate {
var shouldShowOpenInApp: Bool {
!OpenInApplication.availableApps.isEmpty
}
func didPressCall(to phone: PlacePagePhone) {
MWMPlacePageManagerHelper.call(phone)
}
func didPressWebsite() {
MWMPlacePageManagerHelper.openWebsite(placePageData)
}
func didPressWebsiteMenu() {
MWMPlacePageManagerHelper.openWebsiteMenu(placePageData)
}
func didPressWikipedia() {
MWMPlacePageManagerHelper.openWikipedia(placePageData)
}
func didPressWikimediaCommons() {
MWMPlacePageManagerHelper.openWikimediaCommons(placePageData)
}
func didPressFacebook() {
MWMPlacePageManagerHelper.openFacebook(placePageData)
}
func didPressInstagram() {
MWMPlacePageManagerHelper.openInstagram(placePageData)
}
func didPressTwitter() {
MWMPlacePageManagerHelper.openTwitter(placePageData)
}
func didPressVk() {
MWMPlacePageManagerHelper.openVk(placePageData)
}
func didPressLine() {
MWMPlacePageManagerHelper.openLine(placePageData)
}
func didPressEmail() {
MWMPlacePageManagerHelper.openEmail(placePageData)
}
func didCopy(_ content: String) {
UIPasteboard.general.string = content
let message = String(format: L("copied_to_clipboard"), content)
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
Toast.toast(withText: message).show(withAlignment: .bottom)
}
func didPressOpenInApp(from sourceView: UIView) {
let availableApps = OpenInApplication.availableApps
guard !availableApps.isEmpty else {
LOG(.warning, "Applications selection sheet should not be presented when the list of available applications is empty.")
return
}
let openInAppActionSheet = UIAlertController.presentInAppActionSheet(from: sourceView, apps: availableApps) { [weak self] selectedApp in
guard let self else { return }
let link = selectedApp.linkWith(coordinates: self.placePageData.locationCoordinate, destinationName: self.placePageData.previewData.title)
self.mapViewController?.openUrl(link, externally: true)
}
presenter?.showAlert(openInAppActionSheet)
}
}
// MARK: - WikiDescriptionViewControllerDelegate
extension PlacePageInteractor: WikiDescriptionViewControllerDelegate {
func didPressMore() {
MWMPlacePageManagerHelper.showPlaceDescription(placePageData.wikiDescriptionHtml)
}
}
// MARK: - PlacePageButtonsViewControllerDelegate
extension PlacePageInteractor: PlacePageButtonsViewControllerDelegate {
func didPressHotels() {
MWMPlacePageManagerHelper.openDescriptionUrl(placePageData)
}
func didPressAddPlace() {
MWMPlacePageManagerHelper.addPlace(placePageData.locationCoordinate)
}
func didPressEditPlace() {
MWMPlacePageManagerHelper.editPlace()
}
func didPressAddBusiness() {
MWMPlacePageManagerHelper.addBusiness()
}
}
// MARK: - PlacePageEditBookmarkOrTrackViewControllerDelegate
extension PlacePageInteractor: PlacePageEditBookmarkOrTrackViewControllerDelegate {
func didPressEdit(_ data: PlacePageEditData) {
switch data {
case .bookmark:
MWMPlacePageManagerHelper.editBookmark(placePageData)
case .track:
MWMPlacePageManagerHelper.editTrack(placePageData)
}
}
}
// MARK: - ActionBarViewControllerDelegate
extension PlacePageInteractor: ActionBarViewControllerDelegate {
func actionBar(_ actionBar: ActionBarViewController, didPressButton type: ActionBarButtonType) {
switch type {
case .booking:
MWMPlacePageManagerHelper.book(placePageData)
case .bookingSearch:
MWMPlacePageManagerHelper.searchBookingHotels(placePageData)
case .bookmark:
if placePageData.bookmarkData != nil {
MWMPlacePageManagerHelper.removeBookmark(placePageData)
} else {
MWMPlacePageManagerHelper.addBookmark(placePageData)
}
case .call:
// since `.call` is a case in an obj-c enum, it can't have associated data, so there is no easy way to
// pass the exact phone, and we have to ask the user here which one to use, if there are multiple ones
let phones = placePageData.infoData?.phones ?? []
let hasOnePhoneNumber = phones.count == 1
if hasOnePhoneNumber {
MWMPlacePageManagerHelper.call(phones[0])
} else if (phones.count > 1) {
showPhoneNumberPicker(phones, handler: MWMPlacePageManagerHelper.call)
}
case .download:
guard let mapNodeAttributes = placePageData.mapNodeAttributes else {
fatalError("Download button can't be displayed if mapNodeAttributes is empty")
}
switch mapNodeAttributes.nodeStatus {
case .downloading, .inQueue, .applying:
Storage.shared().cancelDownloadNode(mapNodeAttributes.countryId)
case .notDownloaded, .partly, .error:
Storage.shared().downloadNode(mapNodeAttributes.countryId)
case .undefined, .onDiskOutOfDate, .onDisk:
fatalError("Download button shouldn't be displayed when node is in these states")
@unknown default:
fatalError()
}
case .opentable:
fatalError("Opentable is not supported and will be deleted")
case .routeAddStop:
MWMPlacePageManagerHelper.routeAddStop(placePageData)
case .routeFrom:
MWMPlacePageManagerHelper.route(from: placePageData)
case .routeRemoveStop:
MWMPlacePageManagerHelper.routeRemoveStop(placePageData)
case .routeTo:
MWMPlacePageManagerHelper.route(to: placePageData)
case .avoidToll:
MWMPlacePageManagerHelper.avoidToll()
case .avoidDirty:
MWMPlacePageManagerHelper.avoidDirty()
case .avoidFerry:
MWMPlacePageManagerHelper.avoidFerry()
case .more:
fatalError("More button should've been handled in ActionBarViewContoller")
case .track:
guard placePageData.trackData != nil else { return }
// TODO: This is temporary solution. Remove the dialog and use the MWMPlacePageManagerHelper.removeTrack
// directly here when the track recovery mechanism will be implemented.
showTrackDeletionConfirmationDialog()
@unknown default:
fatalError()
}
}
private func showTrackDeletionConfirmationDialog() {
let alert = UIAlertController(title: nil, message: L("placepage_delete_track_confirmation_alert_message"), preferredStyle: .actionSheet)
let deleteAction = UIAlertAction(title: L("delete"), style: .destructive) { [weak self] _ in
guard let self = self else { return }
guard self.placePageData.trackData != nil else {
fatalError("The track data should not be nil during the track deletion")
}
MWMPlacePageManagerHelper.removeTrack(self.placePageData)
self.presenter?.closeAnimated()
}
let cancelAction = UIAlertAction(title: L("cancel"), style: .cancel)
alert.addAction(deleteAction)
alert.addAction(cancelAction)
guard let viewController else { return }
iPadSpecific {
alert.popoverPresentationController?.sourceView = viewController.view
alert.popoverPresentationController?.sourceRect = viewController.view.frame
}
viewController.present(alert, animated: true)
}
private func showPhoneNumberPicker(_ phones: [PlacePagePhone], handler: @escaping (PlacePagePhone) -> Void) {
guard let viewController else { return }
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
phones.forEach({phone in
alert.addAction(UIAlertAction(title: phone.phone, style: .default, handler: { _ in
handler(phone)
}))
})
alert.addAction(UIAlertAction(title: L("cancel"), style: .cancel))
viewController.present(alert, animated: true)
}
}
// MARK: - ElevationProfileViewControllerDelegate
extension PlacePageInteractor: ElevationProfileViewControllerDelegate {
func openDifficultyPopup() {
MWMPlacePageManagerHelper.openElevationDifficultPopup(placePageData)
}
func updateMapPoint(_ point: CLLocationCoordinate2D, distance: Double) {
guard let trackId = placePageData.trackData?.trackId else { return }
BookmarksManager.shared().setElevationActivePoint(point, distance: distance, trackId: trackId)
}
}
// MARK: - PlacePageHeaderViewController
extension PlacePageInteractor: PlacePageHeaderViewControllerDelegate {
func previewDidPressClose() {
presenter?.closeAnimated()
}
func previewDidPressExpand() {
presenter?.showNextStop()
}
func previewDidPressShare(from sourceView: UIView) {
guard let mapViewController else { return }
switch placePageData.objectType {
case .POI, .bookmark:
let shareViewController = ActivityViewController.share(forPlacePage: placePageData)
shareViewController.present(inParentViewController: mapViewController, anchorView: sourceView)
case .track:
presenter?.showShareTrackMenu()
default:
fatalError()
}
}
func previewDidPressExportTrack(_ type: KmlFileType, from sourceView: UIView) {
guard let trackId = placePageData.trackData?.trackId else {
fatalError("Track data should not be nil during the track export")
}
bookmarksManager.shareTrack(trackId, fileType: type) { [weak self] status, url in
guard let self, let mapViewController else { return }
switch status {
case .success:
guard let url else { fatalError("Invalid sharing url") }
let shareViewController = ActivityViewController.share(for: url, message: self.placePageData.previewData.title!) { _,_,_,_ in
self.bookmarksManager.finishSharing()
}
shareViewController.present(inParentViewController: mapViewController, anchorView: sourceView)
case .emptyCategory:
self.showAlert(withTitle: L("bookmarks_error_title_share_empty"),
message: L("bookmarks_error_message_share_empty"))
case .archiveError, .fileError:
self.showAlert(withTitle: L("dialog_routing_system_error"),
message: L("bookmarks_error_message_share_general"))
}
}
}
private func showAlert(withTitle title: String, message: String) {
MWMAlertViewController.activeAlert().presentInfoAlert(title, text: message)
}
}
// MARK: - BookmarksObserver
extension PlacePageInteractor: BookmarksObserver {
func onBookmarksLoadFinished() {
updatePlacePageIfNeeded()
}
func onBookmarksCategoryDeleted(_ groupId: MWMMarkGroupID) {
guard let bookmarkGroupId = placePageData.bookmarkData?.bookmarkGroupId else { return }
if bookmarkGroupId == groupId {
presenter?.closeAnimated()
}
}
}