Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:58:55 +01:00
parent 4af19165ec
commit 68073add76
12458 changed files with 12350765 additions and 2 deletions

View file

@ -0,0 +1,244 @@
protocol ActionBarViewControllerDelegate: AnyObject {
func actionBar(_ actionBar: ActionBarViewController, didPressButton type: ActionBarButtonType)
}
final class ActionBarViewController: UIViewController {
@IBOutlet var stackView: UIStackView!
private(set) var downloadButton: ActionBarButton? = nil
private(set) var bookmarkButton: ActionBarButton? = nil
private var popoverSourceView: UIView? {
stackView.arrangedSubviews.last
}
var placePageData: PlacePageData!
var isRoutePlanning = false
var canAddStop = false
private var visibleButtons: [ActionBarButtonType] = []
private var additionalButtons: [ActionBarButtonType] = []
weak var delegate: ActionBarViewControllerDelegate?
override func viewDidLoad() {
super.viewDidLoad()
configureButtons()
}
// MARK: - Private methods
private func configureButtons() {
if placePageData.isRoutePoint {
visibleButtons.append(.routeRemoveStop)
} else if placePageData.roadType != .none {
switch placePageData.roadType {
case .toll:
visibleButtons.append(.avoidToll)
case .ferry:
visibleButtons.append(.avoidFerry)
case .dirty:
visibleButtons.append(.avoidDirty)
default:
fatalError()
}
} else {
configButton1()
configButton2()
configButton3()
configButton4()
}
setupButtonsState()
}
private func configButton1() {
if let mapNodeAttributes = placePageData.mapNodeAttributes {
switch mapNodeAttributes.nodeStatus {
case .onDiskOutOfDate, .onDisk, .undefined:
break
case .downloading, .applying, .inQueue, .error, .notDownloaded, .partly:
visibleButtons.append(.download)
return
@unknown default:
fatalError()
}
}
var buttons: [ActionBarButtonType] = []
switch placePageData.objectType {
case .POI, .bookmark, .track:
if isRoutePlanning {
buttons.append(.routeFrom)
}
let hasAnyPhones = !(placePageData.infoData?.phones ?? []).isEmpty
if hasAnyPhones, AppInfo.shared().canMakeCalls {
buttons.append(.call)
}
if !isRoutePlanning {
buttons.append(.routeFrom)
}
case .trackRecording:
break
@unknown default:
fatalError()
}
guard !buttons.isEmpty else { return }
visibleButtons.append(buttons[0])
if buttons.count > 1 {
additionalButtons.append(contentsOf: buttons.suffix(from: 1))
}
}
private func configButton2() {
var buttons: [ActionBarButtonType] = []
switch placePageData.objectType {
case .POI, .bookmark:
if canAddStop {
buttons.append(.routeAddStop)
}
buttons.append(.bookmark)
case .track:
if canAddStop {
buttons.append(.routeAddStop)
}
buttons.append(.track)
case .trackRecording:
buttons.append(.notSaveTrackRecording)
buttons.append(.saveTrackRecording)
@unknown default:
fatalError()
}
assert(buttons.count > 0)
visibleButtons.append(buttons[0])
if buttons.count > 1 {
additionalButtons.append(contentsOf: buttons.suffix(from: 1))
}
}
private func configButton3() {
switch placePageData.objectType {
case .POI, .bookmark, .track:
visibleButtons.append(.routeTo)
case .trackRecording:
break
@unknown default:
fatalError()
}
}
private func configButton4() {
guard !additionalButtons.isEmpty else { return }
additionalButtons.count == 1 ? visibleButtons.append(additionalButtons[0]) : visibleButtons.append(.more)
}
private func setupButtonsState() {
for buttonType in visibleButtons {
let (selected, enabled) = buttonState(buttonType)
let button = ActionBarButton(delegate: self,
buttonType: buttonType,
isSelected: selected,
isEnabled: enabled)
stackView.addArrangedSubview(button)
switch buttonType {
case .download:
downloadButton = button
updateDownloadButtonState(placePageData.mapNodeAttributes!.nodeStatus)
case .bookmark:
bookmarkButton = button
default:
break
}
}
}
private func buttonState(_ buttonType: ActionBarButtonType) -> (selected: Bool, enabled: Bool) {
var selected = false
let enabled = true
switch buttonType {
case .bookmark:
selected = placePageData.bookmarkData != nil
case .track:
selected = placePageData.trackData != nil
default:
break
}
return (selected, enabled)
}
private func showMore() {
let actionSheet = UIAlertController(title: placePageData.previewData.title,
message: placePageData.previewData.subtitle,
preferredStyle: .actionSheet)
for button in additionalButtons {
let (selected, enabled) = buttonState(button)
let action = UIAlertAction(title: titleForButton(button, selected),
style: .default,
handler: { [weak self] _ in
guard let self = self else { return }
self.delegate?.actionBar(self, didPressButton: button)
})
action.isEnabled = enabled
actionSheet.addAction(action)
}
actionSheet.addAction(UIAlertAction(title: L("cancel"), style: .cancel))
if let popover = actionSheet.popoverPresentationController, let sourceView = stackView.arrangedSubviews.last {
popover.sourceView = sourceView
popover.sourceRect = sourceView.bounds
}
present(actionSheet, animated: true)
}
// MARK: - Public methods
func resetButtons() {
stackView.arrangedSubviews.forEach {
stackView.removeArrangedSubview($0)
$0.removeFromSuperview()
}
visibleButtons.removeAll()
additionalButtons.removeAll()
downloadButton = nil
bookmarkButton = nil
configureButtons()
}
func updateDownloadButtonState(_ nodeStatus: MapNodeStatus) {
guard let downloadButton = downloadButton, let mapNodeAttributes = placePageData.mapNodeAttributes else { return }
switch mapNodeAttributes.nodeStatus {
case .downloading:
downloadButton.mapDownloadProgress?.state = .progress
case .applying, .inQueue:
downloadButton.mapDownloadProgress?.state = .spinner
case .error:
downloadButton.mapDownloadProgress?.state = .failed
case .onDisk, .undefined, .onDiskOutOfDate:
downloadButton.mapDownloadProgress?.state = .completed
case .notDownloaded, .partly:
downloadButton.mapDownloadProgress?.state = .normal
@unknown default:
fatalError()
}
}
func updateBookmarkButtonState(isSelected: Bool) {
guard let bookmarkButton else { return }
if !isSelected && BookmarksManager.shared().hasRecentlyDeletedBookmark() {
bookmarkButton.setBookmarkButtonState(.recover)
return
}
bookmarkButton.setBookmarkButtonState(isSelected ? .delete : .save)
}
}
extension ActionBarViewController: ActionBarButtonDelegate {
func tapOnButton(with type: ActionBarButtonType) {
switch type {
case .more:
showMore()
default:
delegate?.actionBar(self, didPressButton: type)
}
}
}

View file

@ -0,0 +1,13 @@
@objc class ElevationDetailsBuilder: NSObject {
@objc static func build(data: PlacePageData) -> UIViewController {
guard let elevationProfileData = data.trackData?.elevationProfileData else {
LOG(.critical, "Elevation profile data should not be nil when building elevation details")
fatalError()
}
let viewController = ElevationDetailsViewController(nibName: toString(ElevationDetailsViewController.self), bundle: nil)
let router = ElevationDetailsRouter(viewController: viewController)
let presenter = ElevationDetailsPresenter(view: viewController, router: router, data: elevationProfileData)
viewController.presenter = presenter
return viewController
}
}

View file

@ -0,0 +1,28 @@
protocol ElevationDetailsPresenterProtocol: AnyObject {
func configure()
func onOkButtonPressed()
}
class ElevationDetailsPresenter {
private weak var view: ElevationDetailsViewProtocol?
private let router: ElevationDetailsRouterProtocol
private let data: ElevationProfileData
init(view: ElevationDetailsViewProtocol,
router: ElevationDetailsRouterProtocol,
data: ElevationProfileData) {
self.view = view
self.router = router
self.data = data
}
}
extension ElevationDetailsPresenter: ElevationDetailsPresenterProtocol {
func configure() {
view?.setDifficulty(data.difficulty)
}
func onOkButtonPressed() {
router.close()
}
}

View file

@ -0,0 +1,17 @@
protocol ElevationDetailsRouterProtocol: AnyObject {
func close()
}
class ElevationDetailsRouter {
private weak var viewController: UIViewController?
init(viewController: UIViewController) {
self.viewController = viewController
}
}
extension ElevationDetailsRouter: ElevationDetailsRouterProtocol {
func close() {
viewController?.dismiss(animated: true, completion: nil)
}
}

View file

@ -0,0 +1,60 @@
protocol ElevationDetailsViewProtocol: AnyObject {
var presenter: ElevationDetailsPresenterProtocol? { get set }
func setExtendedDifficultyGrade (_ value: String)
func setDifficulty(_ value: ElevationDifficulty)
func setDifficultyDescription(_ value: String)
}
class ElevationDetailsViewController: MWMViewController {
private let transitioning = FadeTransitioning<AlertPresentationController>()
var presenter: ElevationDetailsPresenterProtocol?
@IBOutlet var headerTitle: UILabel!
@IBOutlet var difficultyView: DifficultyView!
@IBOutlet var difficultyLabel: UILabel!
@IBOutlet private var extendedDifficultyGradeLabel: UILabel!
@IBOutlet var difficultyDescriptionLabel: UILabel!
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
transitioningDelegate = transitioning
modalPresentationStyle = .custom
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
presenter?.configure()
}
@IBAction func onOkButtonPressed(_ sender: Any) {
presenter?.onOkButtonPressed()
}
}
extension ElevationDetailsViewController: ElevationDetailsViewProtocol {
func setExtendedDifficultyGrade (_ value: String) {
extendedDifficultyGradeLabel.text = value
}
func setDifficulty(_ value: ElevationDifficulty) {
difficultyView.difficulty = value
switch value {
case .easy:
difficultyLabel.text = L("elevation_profile_diff_level_easy")
case .medium:
difficultyLabel.text = L("elevation_profile_diff_level_moderate")
case .hard:
difficultyLabel.text = L("elevation_profile_diff_level_hard")
default:
break;
}
}
func setDifficultyDescription(_ value: String) {
difficultyDescriptionLabel.text = value
}
}

View file

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23094" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23084"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ElevationDetailsViewController">
<connections>
<outlet property="difficultyDescriptionLabel" destination="7Ed-wc-w74" id="9Bw-DB-bu8"/>
<outlet property="difficultyLabel" destination="kDg-1H-1c1" id="azu-cC-qCf"/>
<outlet property="difficultyView" destination="sd4-IT-eto" id="eB9-Dr-I51"/>
<outlet property="extendedDifficultyGradeLabel" destination="Dke-As-9Jm" id="Tcj-7G-nQ6"/>
<outlet property="headerTitle" destination="AUN-AX-DfY" id="taz-UO-a7n"/>
<outlet property="view" destination="iN0-l3-epB" id="qok-Te-Q7Q"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="SolidTouchView">
<rect key="frame" x="0.0" y="0.0" width="312" height="484"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Difficulty" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="AUN-AX-DfY">
<rect key="frame" x="16" y="68" width="280" height="21"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="localizedText" value="elevation_profile_diff_level"/>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="semibold18:blackPrimaryText"/>
</userDefinedRuntimeAttributes>
</label>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="sd4-IT-eto" customClass="DifficultyView" customModule="CoMaps" customModuleProvider="target">
<rect key="frame" x="16" y="105" width="40" height="10"/>
<color key="backgroundColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" constant="40" id="Nq9-uQ-GCS"/>
<constraint firstAttribute="height" constant="10" id="gSS-Bl-tXK"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Moderate difficulty" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="kDg-1H-1c1">
<rect key="frame" x="16" y="125" width="280" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="regular14:blackSecondaryText"/>
</userDefinedRuntimeAttributes>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="S1" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Dke-As-9Jm" customClass="InsetsLabel" customModule="CoMaps" customModuleProvider="target">
<rect key="frame" x="16" y="158" width="15.5" height="17"/>
<color key="backgroundColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="ElevationProfileExtendedDifficulty"/>
</userDefinedRuntimeAttributes>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" ambiguous="YES" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7Ed-wc-w74">
<rect key="frame" x="16" y="183" width="280" height="221"/>
<string key="text">Vivamus eu mattis lectus. Phasellus eu ex risus. Quisque ornare augue lectus, eget dignissim turpis ultrices quis. In sit amet sapien laoreet, gravida lorem eget, pharetra ipsum. Morbi ut massa dui. Aenean placerat libero ac ante finibus semper. Nullam semper nibh eget mauris vestibulum, eu cursus nunc finibus. Aliquam fringilla fermentum libero fringilla dictum. Donec eu semper ipsum. Sed in purus neque.</string>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="regular14:blackSecondaryText"/>
</userDefinedRuntimeAttributes>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="vDr-Ie-c5L">
<rect key="frame" x="16" y="386" width="280" height="48"/>
<color key="backgroundColor" systemColor="linkColor"/>
<constraints>
<constraint firstAttribute="height" constant="48" id="DsE-3h-I1o"/>
<constraint firstAttribute="width" constant="280" id="JhA-fQ-QGN"/>
</constraints>
<state key="normal" title="Button">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</state>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="localizedText" value="ok"/>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="FlatNormalButton"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="onOkButtonPressed:" destination="-1" eventType="touchUpInside" id="V2n-kF-R7p"/>
</connections>
</button>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="vDr-Ie-c5L" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="03I-DI-Xet"/>
<constraint firstItem="sd4-IT-eto" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="4Fs-jA-Nr5"/>
<constraint firstItem="kDg-1H-1c1" firstAttribute="top" secondItem="sd4-IT-eto" secondAttribute="bottom" constant="10" id="590-Gb-F57"/>
<constraint firstItem="AUN-AX-DfY" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" constant="24" id="5Df-xg-87e"/>
<constraint firstItem="7Ed-wc-w74" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="6RJ-WV-Wku"/>
<constraint firstItem="vDr-Ie-c5L" firstAttribute="top" secondItem="7Ed-wc-w74" secondAttribute="bottom" constant="16" id="6ma-Oj-SbC"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="vDr-Ie-c5L" secondAttribute="bottom" constant="16" id="93T-wX-CAW"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vDr-Ie-c5L" secondAttribute="trailing" constant="16" id="KPu-Wp-KPH"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="7Ed-wc-w74" secondAttribute="trailing" constant="16" id="NxM-48-Xf1"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="kDg-1H-1c1" secondAttribute="trailing" constant="16" id="PrX-vn-5lX"/>
<constraint firstItem="Dke-As-9Jm" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="TA1-c3-FjG"/>
<constraint firstItem="Dke-As-9Jm" firstAttribute="top" secondItem="kDg-1H-1c1" secondAttribute="bottom" constant="16" id="fIj-xm-f9G"/>
<constraint firstItem="kDg-1H-1c1" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="fuT-eE-uMX"/>
<constraint firstItem="sd4-IT-eto" firstAttribute="top" secondItem="AUN-AX-DfY" secondAttribute="bottom" constant="16" id="heF-rz-x1k"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="AUN-AX-DfY" secondAttribute="trailing" constant="16" id="kmB-vg-oQY"/>
<constraint firstItem="7Ed-wc-w74" firstAttribute="top" secondItem="Dke-As-9Jm" secondAttribute="bottom" constant="8" id="mRe-aS-Zva"/>
<constraint firstItem="AUN-AX-DfY" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="wm3-Eh-4No"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="styleName" value="Background"/>
</userDefinedRuntimeAttributes>
<point key="canvasLocation" x="131.8840579710145" y="291.29464285714283"/>
</view>
</objects>
<resources>
<systemColor name="linkColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View file

@ -0,0 +1,13 @@
import CoreApi
class ElevationProfileBuilder {
static func build(trackData: PlacePageTrackData,
delegate: ElevationProfileViewControllerDelegate?) -> ElevationProfileViewController {
let viewController = ElevationProfileViewController();
let presenter = ElevationProfilePresenter(view: viewController,
trackData: trackData,
delegate: delegate)
viewController.presenter = presenter
return viewController
}
}

View file

@ -0,0 +1,82 @@
final class ElevationProfileDescriptionCell: UICollectionViewCell {
private enum Constants {
static let insets = UIEdgeInsets(top: 2, left: 0, bottom: -2, right: 0)
static let valueSpacing: CGFloat = 8.0
static let imageSize: CGSize = CGSize(width: 20, height: 20)
}
private let valueLabel = UILabel()
private let subtitleLabel = UILabel()
private let imageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
layoutViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
super.init(coder: coder)
setupViews()
layoutViews()
}
private func setupViews() {
valueLabel.font = .medium14()
valueLabel.styleName = "blackSecondaryText"
valueLabel.numberOfLines = 1
valueLabel.minimumScaleFactor = 0.1
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.allowsDefaultTighteningForTruncation = true
subtitleLabel.font = .regular10()
subtitleLabel.styleName = "blackSecondaryText"
subtitleLabel.numberOfLines = 1
subtitleLabel.minimumScaleFactor = 0.1
subtitleLabel.adjustsFontSizeToFitWidth = true
subtitleLabel.allowsDefaultTighteningForTruncation = true
imageView.contentMode = .scaleAspectFit
imageView.styleName = "MWMBlack"
}
private func layoutViews() {
contentView.addSubview(imageView)
contentView.addSubview(valueLabel)
contentView.addSubview(subtitleLabel)
imageView.translatesAutoresizingMaskIntoConstraints = false
valueLabel.translatesAutoresizingMaskIntoConstraints = false
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Constants.insets.top),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
imageView.widthAnchor.constraint(equalToConstant: Constants.imageSize.width),
imageView.heightAnchor.constraint(equalToConstant: Constants.imageSize.height),
valueLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: Constants.valueSpacing),
valueLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
valueLabel.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
subtitleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor),
subtitleLabel.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
subtitleLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: Constants.insets.bottom)
])
subtitleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical)
}
func configure(subtitle: String, value: String, imageName: String) {
subtitleLabel.text = subtitle
valueLabel.text = value
imageView.image = UIImage(named: imageName)
}
override func prepareForReuse() {
super.prepareForReuse()
valueLabel.text = ""
subtitleLabel.text = ""
imageView.image = nil
}
}

View file

@ -0,0 +1,67 @@
import Chart
import CoreApi
final class ElevationProfileFormatter {
private enum Constants {
static let metricToImperialMultiplier: CGFloat = 0.3048
static var metricAltitudeStep: CGFloat = 50
static var imperialAltitudeStep: CGFloat = 100
}
private let distanceFormatter: DistanceFormatter.Type
private let altitudeFormatter: AltitudeFormatter.Type
private let unitSystemMultiplier: CGFloat
private let altitudeStep: CGFloat
private let units: Units
init(units: Units = SettingsBridge.measurementUnits()) {
self.units = units
self.distanceFormatter = DistanceFormatter.self
self.altitudeFormatter = AltitudeFormatter.self
switch units {
case .metric:
self.altitudeStep = Constants.metricAltitudeStep
self.unitSystemMultiplier = 1
case .imperial:
self.altitudeStep = Constants.imperialAltitudeStep
self.unitSystemMultiplier = Constants.metricToImperialMultiplier
@unknown default:
fatalError("Unsupported units")
}
}
}
extension ElevationProfileFormatter: ChartFormatter {
func xAxisString(from value: Double) -> String {
distanceFormatter.distanceString(fromMeters: value)
}
func yAxisString(from value: Double) -> String {
altitudeFormatter.altitudeString(fromMeters: value)
}
func yAxisLowerBound(from value: CGFloat) -> CGFloat {
floor((value / unitSystemMultiplier) / altitudeStep) * altitudeStep * unitSystemMultiplier
}
func yAxisUpperBound(from value: CGFloat) -> CGFloat {
ceil((value / unitSystemMultiplier) / altitudeStep) * altitudeStep * unitSystemMultiplier
}
func yAxisSteps(lowerBound: CGFloat, upperBound: CGFloat) -> [CGFloat] {
let lower = yAxisLowerBound(from: lowerBound)
let upper = yAxisUpperBound(from: upperBound)
let range = upper - lower
var stepSize = altitudeStep
var stepsCount = Int((range / stepSize).rounded(.up))
while stepsCount > 6 {
stepSize *= 2 // Double the step size to reduce the step count
stepsCount = Int((range / stepSize).rounded(.up))
}
let steps = stride(from: lower, through: upper, by: stepSize)
return Array(steps)
}
}

View file

@ -0,0 +1,191 @@
import Chart
import CoreApi
protocol TrackActivePointPresenter: AnyObject {
func updateActivePointDistance(_ distance: Double)
func updateMyPositionDistance(_ distance: Double)
}
protocol ElevationProfilePresenterProtocol: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, TrackActivePointPresenter {
func configure()
func update(with trackData: PlacePageTrackData)
func onDifficultyButtonPressed()
func onSelectedPointChanged(_ point: CGFloat)
}
protocol ElevationProfileViewControllerDelegate: AnyObject {
func openDifficultyPopup()
func updateMapPoint(_ point: CLLocationCoordinate2D, distance: Double)
}
fileprivate struct DescriptionsViewModel {
let title: String
let value: String
let imageName: String
}
final class ElevationProfilePresenter: NSObject {
private weak var view: ElevationProfileViewProtocol?
private weak var trackData: PlacePageTrackData?
private weak var delegate: ElevationProfileViewControllerDelegate?
private let bookmarkManager: BookmarksManager = .shared()
private let cellSpacing: CGFloat = 8
private var descriptionModels: [DescriptionsViewModel]
private var chartData: ElevationProfileChartData?
private let formatter: ElevationProfileFormatter
init(view: ElevationProfileViewProtocol,
trackData: PlacePageTrackData,
formatter: ElevationProfileFormatter = ElevationProfileFormatter(),
delegate: ElevationProfileViewControllerDelegate?) {
self.view = view
self.delegate = delegate
self.formatter = formatter
self.trackData = trackData
if let profileData = trackData.elevationProfileData {
self.chartData = ElevationProfileChartData(profileData)
}
self.descriptionModels = Self.descriptionModels(for: trackData.trackInfo)
}
private static func descriptionModels(for trackInfo: TrackInfo) -> [DescriptionsViewModel] {
[
DescriptionsViewModel(title: L("elevation_profile_ascent"), value: trackInfo.ascent, imageName: "ic_em_ascent_24"),
DescriptionsViewModel(title: L("elevation_profile_descent"), value: trackInfo.descent, imageName: "ic_em_descent_24"),
DescriptionsViewModel(title: L("elevation_profile_max_elevation"), value: trackInfo.maxElevation, imageName: "ic_em_max_attitude_24"),
DescriptionsViewModel(title: L("elevation_profile_min_elevation"), value: trackInfo.minElevation, imageName: "ic_em_min_attitude_24")
]
}
}
extension ElevationProfilePresenter: ElevationProfilePresenterProtocol {
func update(with trackData: PlacePageTrackData) {
self.trackData = trackData
if let profileData = trackData.elevationProfileData {
self.chartData = ElevationProfileChartData(profileData)
} else {
self.chartData = nil
}
descriptionModels = Self.descriptionModels(for: trackData.trackInfo)
configure()
}
func updateActivePointDistance(_ distance: Double) {
guard let view, view.canReceiveUpdates else { return }
view.setActivePointDistance(distance)
}
func updateMyPositionDistance(_ distance: Double) {
guard let view, view.canReceiveUpdates else { return }
view.setMyPositionDistance(distance)
}
func configure() {
view?.isChartViewHidden = false
let kMinPointsToDraw = 2
guard let trackData = trackData,
let profileData = trackData.elevationProfileData,
let chartData,
chartData.points.count >= kMinPointsToDraw else {
view?.userInteractionEnabled = false
return
}
view?.setChartData(ChartPresentationData(chartData, formatter: formatter))
view?.reloadDescription()
guard !profileData.isTrackRecording else {
view?.isChartViewInfoHidden = true
return
}
view?.userInteractionEnabled = true
view?.setActivePointDistance(trackData.activePointDistance)
view?.setMyPositionDistance(trackData.myPositionDistance)
}
func onDifficultyButtonPressed() {
delegate?.openDifficultyPopup()
}
func onSelectedPointChanged(_ point: CGFloat) {
guard let chartData else { return }
let distance: Double = floor(point) / CGFloat(chartData.points.count) * chartData.maxDistance
let point = chartData.points.first { $0.distance >= distance } ?? chartData.points[0]
delegate?.updateMapPoint(point.coordinates, distance: point.distance)
}
}
// MARK: - UICollectionDataSource
extension ElevationProfilePresenter {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return descriptionModels.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(cell: ElevationProfileDescriptionCell.self, indexPath: indexPath)
let model = descriptionModels[indexPath.row]
cell.configure(subtitle: model.title, value: model.value, imageName: model.imageName)
return cell
}
}
// MARK: - UICollectionViewDelegateFlowLayout
extension ElevationProfilePresenter {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = collectionView.width
let cellHeight = collectionView.height
let modelsCount = CGFloat(descriptionModels.count)
let cellWidth = (width - cellSpacing * (modelsCount - 1) - collectionView.contentInset.right - collectionView.contentInset.left) / modelsCount
return CGSize(width: cellWidth, height: cellHeight)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return cellSpacing
}
}
fileprivate struct ElevationProfileChartData {
struct Line: ChartLine {
var values: [ChartValue]
var color: UIColor
var type: ChartLineType
}
fileprivate let chartValues: [ChartValue]
fileprivate let chartLines: [Line]
fileprivate let distances: [Double]
fileprivate let maxDistance: Double
fileprivate let points: [ElevationHeightPoint]
init(_ elevationData: ElevationProfileData) {
self.points = elevationData.points
self.chartValues = points.map { ChartValue(xValues: $0.distance, y: $0.altitude) }
self.distances = points.map { $0.distance }
self.maxDistance = distances.last ?? 0
let lineColor = StyleManager.shared.theme?.colors.chartLine ?? .blue
let lineShadowColor = StyleManager.shared.theme?.colors.chartShadow ?? .lightGray
let l1 = Line(values: chartValues, color: lineColor, type: .line)
let l2 = Line(values: chartValues, color: lineShadowColor, type: .lineArea)
chartLines = [l1, l2]
}
private static func altBetweenPoints(_ p1: ElevationHeightPoint,
_ p2: ElevationHeightPoint,
at distance: Double) -> Double {
assert(distance > p1.distance && distance < p2.distance, "distance must be between points")
let d = (distance - p1.distance) / (p2.distance - p1.distance)
return p1.altitude + round(Double(p2.altitude - p1.altitude) * d)
}
}
extension ElevationProfileChartData: ChartData {
public var xAxisValues: [Double] { distances }
public var lines: [ChartLine] { chartLines }
public var type: ChartType { .regular }
}

View file

@ -0,0 +1,162 @@
import Chart
protocol ElevationProfileViewProtocol: AnyObject {
var presenter: ElevationProfilePresenterProtocol? { get set }
var userInteractionEnabled: Bool { get set }
var isChartViewHidden: Bool { get set }
var isChartViewInfoHidden: Bool { get set }
var canReceiveUpdates: Bool { get }
func setChartData(_ data: ChartPresentationData)
func setActivePointDistance(_ distance: Double)
func setMyPositionDistance(_ distance: Double)
func reloadDescription()
}
final class ElevationProfileViewController: UIViewController {
private enum Constants {
static let descriptionCollectionViewHeight: CGFloat = 52
static let descriptionCollectionViewContentInsets = UIEdgeInsets(top: 20, left: 16, bottom: 4, right: 16)
static let graphViewContainerInsets = UIEdgeInsets(top: -4, left: 0, bottom: 0, right: 0)
static let chartViewInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: -16)
static let chartViewVisibleHeight: CGFloat = 176
static let chartViewHiddenHeight: CGFloat = .zero
}
var presenter: ElevationProfilePresenterProtocol?
init() {
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var chartView = ChartView()
private var graphViewContainer = UIView()
private var descriptionCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumInteritemSpacing = 0
return UICollectionView(frame: .zero, collectionViewLayout: layout)
}()
private var chartViewHeightConstraint: NSLayoutConstraint!
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
layoutViews()
presenter?.configure()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
descriptionCollectionView.reloadData()
}
// MARK: - Private methods
private func setupViews() {
view.setStyle(.background)
setupDescriptionCollectionView()
setupChartView()
}
private func setupChartView() {
graphViewContainer.translatesAutoresizingMaskIntoConstraints = false
chartView.translatesAutoresizingMaskIntoConstraints = false
chartView.onSelectedPointChanged = { [weak self] in
self?.presenter?.onSelectedPointChanged($0)
}
}
private func setupDescriptionCollectionView() {
descriptionCollectionView.backgroundColor = .clear
descriptionCollectionView.register(cell: ElevationProfileDescriptionCell.self)
descriptionCollectionView.dataSource = presenter
descriptionCollectionView.delegate = presenter
descriptionCollectionView.isScrollEnabled = false
descriptionCollectionView.contentInset = Constants.descriptionCollectionViewContentInsets
descriptionCollectionView.translatesAutoresizingMaskIntoConstraints = false
descriptionCollectionView.showsHorizontalScrollIndicator = false
descriptionCollectionView.showsVerticalScrollIndicator = false
}
private func layoutViews() {
view.addSubview(descriptionCollectionView)
graphViewContainer.addSubview(chartView)
view.addSubview(graphViewContainer)
chartViewHeightConstraint = chartView.heightAnchor.constraint(equalToConstant: Constants.chartViewVisibleHeight)
NSLayoutConstraint.activate([
descriptionCollectionView.topAnchor.constraint(equalTo: view.topAnchor),
descriptionCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
descriptionCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
descriptionCollectionView.heightAnchor.constraint(equalToConstant: Constants.descriptionCollectionViewHeight),
descriptionCollectionView.bottomAnchor.constraint(equalTo: graphViewContainer.topAnchor, constant: Constants.graphViewContainerInsets.top),
graphViewContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
graphViewContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
graphViewContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor),
chartView.topAnchor.constraint(equalTo: graphViewContainer.topAnchor),
chartView.leadingAnchor.constraint(equalTo: graphViewContainer.leadingAnchor, constant: Constants.chartViewInsets.left),
chartView.trailingAnchor.constraint(equalTo: graphViewContainer.trailingAnchor, constant: Constants.chartViewInsets.right),
chartView.bottomAnchor.constraint(equalTo: graphViewContainer.bottomAnchor),
chartViewHeightConstraint,
])
}
private func getPreviewHeight() -> CGFloat {
view.height - descriptionCollectionView.frame.minY
}
}
// MARK: - ElevationProfileViewProtocol
extension ElevationProfileViewController: ElevationProfileViewProtocol {
var userInteractionEnabled: Bool {
get { chartView.isUserInteractionEnabled }
set { chartView.isUserInteractionEnabled = newValue }
}
var isChartViewHidden: Bool {
get { chartView.isHidden }
set {
chartView.isHidden = newValue
graphViewContainer.isHidden = newValue
chartViewHeightConstraint.constant = newValue ? Constants.chartViewHiddenHeight : Constants.chartViewVisibleHeight
}
}
var isChartViewInfoHidden: Bool {
get { chartView.isChartViewInfoHidden }
set { chartView.isChartViewInfoHidden = newValue }
}
var canReceiveUpdates: Bool {
chartView.chartData != nil
}
func setChartData(_ data: ChartPresentationData) {
chartView.chartData = data
}
func setActivePointDistance(_ distance: Double) {
chartView.setSelectedPoint(distance)
}
func setMyPositionDistance(_ distance: Double) {
chartView.myPosition = distance
}
func reloadDescription() {
descriptionCollectionView.reloadData()
}
}

View file

@ -0,0 +1,114 @@
enum OpenInApplication: Int, CaseIterable {
case osm
case googleMaps
case appleMaps
case osmAnd
case yandexMaps
case dGis
case cityMapper
case moovit
case uber
case waze
case goMap
}
extension OpenInApplication {
static var availableApps: [OpenInApplication] {
// OSM should always be first in the list.
let sortedApps: [OpenInApplication] = [.osm] + allCases.filter { $0 != .osm }.sorted(by: { $0.name < $1.name })
return sortedApps.filter { UIApplication.shared.canOpenURL(URL(string: $0.scheme)!) }
}
var name: String {
switch self {
case .osm:
return "OpenStreetMap"
case .googleMaps:
return "Google Maps"
case .appleMaps:
return "Apple Maps"
case .osmAnd:
return "OsmAnd"
case .yandexMaps:
return "Yandex Maps"
case .dGis:
return "2GIS"
case .cityMapper:
return "Citymapper"
case .moovit:
return "Moovit"
case .uber:
return "Uber"
case .waze:
return "Waze"
case .goMap:
return "Go Map!!"
}
}
// Schemes should be registered in LSApplicationQueriesSchemes - see Info.plist.
var scheme: String {
switch self {
case .osm:
return "https://osm.org/go/"
case .googleMaps:
return "comgooglemaps://"
case .appleMaps:
return "https://maps.apple.com/"
case .osmAnd:
return "osmandmaps://"
case .yandexMaps:
return "yandexmaps://"
case .dGis:
return "dgis://"
case .cityMapper:
return "citymapper://"
case .moovit:
return "moovit://"
case .uber:
return "uber://"
case .waze:
return "waze://"
case .goMap:
return "gomaposm://"
}
}
func linkWith(coordinates: CLLocationCoordinate2D, zoomLevel: Int = Int(FrameworkHelper.currentZoomLevel()), destinationName: String? = nil) -> String {
let latitude = String(format: "%.6f", coordinates.latitude)
let longitude = String(format: "%.6f", coordinates.longitude)
switch self {
case .osm:
return GeoUtil.formattedOsmLink(for: coordinates, zoomLevel: Int32(zoomLevel))
case .googleMaps:
return "\(scheme)?&q=\(latitude),\(longitude)&z=\(zoomLevel)"
case .appleMaps:
if let destinationName {
return "\(scheme)?q=\(destinationName)&ll=\(latitude),\(longitude)&z=\(zoomLevel)"
}
return "\(scheme)?ll=\(latitude),\(longitude)&z=\(zoomLevel)"
case .osmAnd:
if let destinationName {
return "\(scheme)?lat=\(latitude)&lon=\(longitude)&z=\(zoomLevel)&title=\(destinationName)"
}
return "\(scheme)?lat=\(latitude)&lon=\(longitude)&z=\(zoomLevel)"
case .yandexMaps:
return "\(scheme)maps.yandex.ru/?pt=\(longitude),\(latitude)&z=\(zoomLevel)"
case .dGis:
return "\(scheme)2gis.ru/geo/\(longitude),\(latitude)"
case .cityMapper:
return "\(scheme)directions?endcoord=\(latitude),\(longitude)&endname=\(destinationName ?? "")"
case .moovit:
if let destinationName {
return "\(scheme)directions?dest_lat=\(latitude)&dest_lon=\(longitude)&dest_name=\(destinationName)"
}
return "\(scheme)directions?dest_lat=\(latitude)&dest_lon=\(longitude)"
case .uber:
return "\(scheme)?client_id=&action=setPickup&pickup=my_location&dropoff[latitude]=\(latitude)&dropoff[longitude]=\(longitude)"
case .waze:
return "\(scheme)?ll=\(latitude),\(longitude)"
case .goMap:
return "\(scheme)edit?center=\(latitude),\(longitude)&zoom=\(zoomLevel)"
}
}
}

View file

@ -0,0 +1,25 @@
typealias OpenInApplicationCompletionHandler = (OpenInApplication) -> Void
extension UIAlertController {
static func presentInAppActionSheet(from sourceView: UIView,
apps: [OpenInApplication] = OpenInApplication.availableApps,
didSelectApp: @escaping OpenInApplicationCompletionHandler) -> UIAlertController {
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
apps.forEach { app in
let action = UIAlertAction(title: app.name, style: .default) { _ in
didSelectApp(app)
}
alertController.addAction(action)
}
let cancelAction = UIAlertAction(title: L("cancel"), style: .cancel)
alertController.addAction(cancelAction)
iPadSpecific {
alertController.popoverPresentationController?.sourceView = sourceView
alertController.popoverPresentationController?.sourceRect = sourceView.bounds
}
return alertController
}
}

View file

@ -0,0 +1,122 @@
import UIKit
class OpeningHoursTodayViewController: UIViewController {
@IBOutlet var todayLabel: UILabel!
@IBOutlet var scheduleLabel: UILabel!
@IBOutlet var breaksLabel: UILabel!
@IBOutlet var closedLabel: UILabel!
@IBOutlet var arrowImageView: UIImageView!
var workingDay: WorkingDay!
var closedNow = false
var onExpand: MWMVoidBlock?
override func viewDidLoad() {
super.viewDidLoad()
todayLabel.text = workingDay.workingDays
scheduleLabel.text = workingDay.workingTimes
breaksLabel.text = workingDay.breaks
closedLabel.isHidden = !closedNow
}
@IBAction func onTap(_ sender: UITapGestureRecognizer) {
onExpand?()
}
}
class OpeningHoursDayViewController: UIViewController {
@IBOutlet var todayLabel: UILabel!
@IBOutlet var scheduleLabel: UILabel!
@IBOutlet var breaksLabel: UILabel!
var workingDay: WorkingDay!
override func viewDidLoad() {
super.viewDidLoad()
todayLabel.text = workingDay.workingDays
scheduleLabel.text = workingDay.workingTimes
breaksLabel.text = workingDay.breaks
}
}
class OpeningHoursViewController: UIViewController {
@IBOutlet var stackView: UIStackView!
@IBOutlet var checkDateLabel: UILabel!
@IBOutlet var checkDateLabelTopLayoutConstraint: NSLayoutConstraint!
@IBOutlet var checkDateLabelBottomLayoutConstraint: NSLayoutConstraint!
private var otherDaysViews: [OpeningHoursDayViewController] = []
private lazy var todayView: OpeningHoursTodayViewController = {
let vc = storyboard!.instantiateViewController(ofType: OpeningHoursTodayViewController.self)
vc.workingDay = openingHours.days[0]
vc.closedNow = openingHours.isClosedNow
return vc
}()
private var expanded = false
var openingHours: OpeningHours!
var openingHoursCheckDate: Date?
override func viewDidLoad() {
super.viewDidLoad()
addToStack(todayView)
if openingHours.days.count == 1 {
todayView.arrowImageView.isHidden = true
return
}
openingHours.days.suffix(from: 1).forEach {
let vc = createDayItem($0)
otherDaysViews.append(vc)
addToStack(vc)
}
todayView.onExpand = { [unowned self] in
self.expanded = !self.expanded
UIView.animate(withDuration: kDefaultAnimationDuration) {
self.otherDaysViews.forEach { vc in
vc.view.isHidden = !self.expanded
}
if let checkDate = self.openingHoursCheckDate, self.expanded {
let checkDateFormatter = RelativeDateTimeFormatter()
checkDateFormatter.unitsStyle = .spellOut
checkDateFormatter.localizedString(for: checkDate, relativeTo: Date.now)
self.checkDateLabel.text = String(format: L("hours_confirmed_time_ago"), checkDateFormatter.localizedString(for: checkDate, relativeTo: Date.now))
NSLayoutConstraint.activate([self.checkDateLabelTopLayoutConstraint])
NSLayoutConstraint.activate([self.checkDateLabelBottomLayoutConstraint])
} else {
self.checkDateLabel.text = String()
NSLayoutConstraint.deactivate([self.checkDateLabelTopLayoutConstraint])
NSLayoutConstraint.deactivate([self.checkDateLabelBottomLayoutConstraint])
}
self.checkDateLabel.isHidden = !self.expanded
self.todayView.arrowImageView.transform = self.expanded ? CGAffineTransform(rotationAngle: -CGFloat.pi + 0.01)
: CGAffineTransform.identity
self.view.layoutIfNeeded()
}
}
}
private func createDayItem(_ workingDay: WorkingDay) -> OpeningHoursDayViewController {
let vc = storyboard!.instantiateViewController(ofType: OpeningHoursDayViewController.self)
vc.workingDay = workingDay
vc.view.isHidden = true
return vc
}
private func addToStack(_ viewController: UIViewController) {
addChild(viewController)
stackView.addArrangedSubview(viewController.view)
viewController.didMove(toParent: self)
}
}

View file

@ -0,0 +1,42 @@
protocol PlacePageButtonsViewControllerDelegate: AnyObject {
func didPressAddPlace()
func didPressEditPlace()
}
class PlacePageButtonsViewController: UIViewController {
@IBOutlet var addPlaceButton: UIButton!
@IBOutlet var editPlaceButton: UIButton!
private var buttons: [UIButton?] {
[addPlaceButton, editPlaceButton]
}
var buttonsData: PlacePageButtonsData!
var buttonsEnabled = true {
didSet {
buttons.forEach {
$0?.isEnabled = buttonsEnabled
}
}
}
weak var delegate: PlacePageButtonsViewControllerDelegate?
override func viewDidLoad() {
super.viewDidLoad()
addPlaceButton.isHidden = !buttonsData.showAddPlace
editPlaceButton.isHidden = !buttonsData.showEditPlace
addPlaceButton.isEnabled = buttonsData.enableAddPlace
editPlaceButton.isEnabled = buttonsData.enableEditPlace
}
@IBAction func onAddPlace(_ sender: UIButton) {
delegate?.didPressAddPlace()
}
@IBAction func onEditPlace(_ sender: UIButton) {
delegate?.didPressEditPlace()
}
}

View file

@ -0,0 +1,29 @@
final class PlacePageDescriptionViewController: WebViewController {
override func configuredHtml(withText htmlText: String) -> String {
let scale = UIScreen.main.scale
let styleTags = """
<head>
<style type=\"text/css\">
body{font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji"; font-size:\(14 * scale); line-height:1.5em; color: \(UIColor.blackPrimaryText().hexString); padding: 24px 16px;}
</style>
</head>
<body>
"""
var html = htmlText.replacingOccurrences(of: "<body>", with: styleTags)
html = html.replacingOccurrences(of: "</body>", with: "<p><b>wikipedia.org</b></p></body>")
return html
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle {
guard let m_htmlText else { return }
webView.loadHTMLString(configuredHtml(withText: m_htmlText), baseURL: nil)
}
}
private func isOnBottom(_ scrollView: UIScrollView) -> Bool {
let bottom = scrollView.contentSize.height + scrollView.contentInset.bottom - scrollView.bounds.height
return scrollView.contentOffset.y >= bottom
}
}

View file

@ -0,0 +1,186 @@
protocol PlacePageEditBookmarkOrTrackViewControllerDelegate: AnyObject {
func didUpdate(color: UIColor, category: MWMMarkGroupID, for data: PlacePageEditData)
func didPressEdit(_ data: PlacePageEditData)
}
enum PlacePageEditData {
case bookmark(PlacePageBookmarkData)
case track(PlacePageTrackData)
}
final class PlacePageEditBookmarkOrTrackViewController: UIViewController {
@IBOutlet var stackView: UIStackView!
@IBOutlet var editView: InfoItemView!
@IBOutlet var expandableLabelContainer: UIView!
@IBOutlet var expandableLabel: ExpandableLabel! {
didSet {
updateExpandableLabelStyle()
}
}
var data: PlacePageEditData? {
didSet {
updateViews()
}
}
weak var delegate: PlacePageEditBookmarkOrTrackViewControllerDelegate?
override func viewDidLoad() {
super.viewDidLoad()
updateViews()
}
override func applyTheme() {
super.applyTheme()
updateViews()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
applyTheme()
}
}
// MARK: - Private methods
private func updateViews() {
guard let data else { return }
let iconColor: UIColor
let category: String?
let description: String?
let isHtmlDescription: Bool
switch data {
case .bookmark(let bookmarkData):
iconColor = bookmarkData.color.color
category = bookmarkData.bookmarkCategory
description = bookmarkData.bookmarkDescription
isHtmlDescription = bookmarkData.isHtmlDescription
case .track(let trackData):
iconColor = trackData.color ?? UIColor.buttonRed()
category = trackData.trackCategory
description = trackData.trackDescription
isHtmlDescription = false
}
let editColorImage = circleImageForColor(iconColor, frameSize: 28, diameter: 22, iconName: "ic_bm_none")
editView.iconButton.setImage(editColorImage, for: .normal)
editView.infoLabel.text = category
editView.setStyle(.link)
editView.iconButtonTapHandler = { [weak self] in
guard let self else { return }
self.showColorPicker()
}
editView.infoLabelTapHandler = { [weak self] in
guard let self else { return }
self.showGroupPicker()
}
editView.setAccessory(image: UIImage(resource: .ic24PxEdit), tapHandler: { [weak self] in
guard let self, let data = self.data else { return }
self.delegate?.didPressEdit(data)
})
if let description, !description.isEmpty {
expandableLabelContainer.isHidden = false
if isHtmlDescription {
setHtmlDescription(description)
} else {
expandableLabel.text = description
}
updateExpandableLabelStyle()
} else {
expandableLabelContainer.isHidden = true
}
}
private func updateExpandableLabelStyle() {
expandableLabel.font = UIFont.regular14()
expandableLabel.textColor = UIColor.blackPrimaryText()
expandableLabel.numberOfLines = 5
expandableLabel.expandColor = UIColor.linkBlue()
expandableLabel.expandText = L("placepage_more_button")
}
private func showColorPicker() {
guard let data else { return }
switch data {
case .bookmark(let bookmarkData):
ColorPicker.shared.present(from: self, pickerType: .bookmarkColorPicker(bookmarkData.color)) { [weak self] color in
self?.update(color: color)
}
case .track(let trackData):
ColorPicker.shared.present(from: self, pickerType: .defaultColorPicker(trackData.color ?? .buttonRed())) { [weak self] color in
self?.update(color: color)
}
}
}
private func showGroupPicker() {
guard let data else { return }
let groupId: MWMMarkGroupID
let groupName: String?
switch data {
case .bookmark(let bookmarkData):
groupId = bookmarkData.bookmarkGroupId
groupName = bookmarkData.bookmarkCategory
case .track(let trackData):
groupId = trackData.groupId
groupName = trackData.trackCategory
}
let groupViewController = SelectBookmarkGroupViewController(groupName: groupName ?? "", groupId: groupId)
let navigationController = UINavigationController(rootViewController: groupViewController)
groupViewController.delegate = self
present(navigationController, animated: true, completion: nil)
}
private func update(color: UIColor? = nil, category: MWMMarkGroupID? = nil) {
guard let data else { return }
switch data {
case .bookmark(let bookmarkData):
delegate?.didUpdate(color: color ?? bookmarkData.color.color, category: category ?? bookmarkData.bookmarkGroupId, for: data)
case .track(let trackData):
delegate?.didUpdate(color: color ?? trackData.color!, category: category ?? trackData.groupId, for: data)
}
}
private func setHtmlDescription(_ htmlDescription: String) {
DispatchQueue.global().async {
let font = UIFont.regular14()
let color = UIColor.blackPrimaryText()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 4
let attributedString: NSAttributedString
if let str = NSMutableAttributedString(htmlString: htmlDescription, baseFont: font, paragraphStyle: paragraphStyle) {
str.addAttribute(NSAttributedString.Key.foregroundColor,
value: color,
range: NSRange(location: 0, length: str.length))
attributedString = str;
} else {
attributedString = NSAttributedString(string: htmlDescription,
attributes: [NSAttributedString.Key.font : font,
NSAttributedString.Key.foregroundColor: color,
NSAttributedString.Key.paragraphStyle: paragraphStyle])
}
DispatchQueue.main.async {
self.expandableLabel.attributedText = attributedString
}
}
}
}
// MARK: - SelectBookmarkGroupViewControllerDelegate
extension PlacePageEditBookmarkOrTrackViewController: SelectBookmarkGroupViewControllerDelegate {
func bookmarkGroupViewController(_ viewController: SelectBookmarkGroupViewController,
didSelect groupTitle: String,
groupId: MWMMarkGroupID) {
viewController.dismiss(animated: true)
update(category: groupId)
}
}

View file

@ -0,0 +1,59 @@
final class CircleImageButton: UIButton {
private static let expandedTappableAreaInsets = UIEdgeInsets(top: -5, left: -5, bottom: -5, right: -5)
private let circleImageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
super.init(coder: coder)
setupView()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
circleImageView.applyTheme()
}
}
private func setupView() {
backgroundColor = .clear
circleImageView.setStyle(.ppHeaderCircleIcon)
circleImageView.contentMode = .scaleAspectFill
circleImageView.clipsToBounds = true
circleImageView.isUserInteractionEnabled = false
circleImageView.layer.masksToBounds = true
circleImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(circleImageView)
let aspectRatioConstraint = circleImageView.widthAnchor.constraint(equalTo: circleImageView.heightAnchor)
aspectRatioConstraint.priority = .defaultHigh
NSLayoutConstraint.activate([
circleImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
circleImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
circleImageView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor),
circleImageView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor),
aspectRatioConstraint
])
}
override func layoutSubviews() {
super.layoutSubviews()
circleImageView.layer.cornerRadius = circleImageView.bounds.width / 2.0
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let expandedBounds = bounds.inset(by: Self.expandedTappableAreaInsets)
return expandedBounds.contains(point)
}
func setImage(_ image: UIImage?) {
circleImageView.image = image
}
}

View file

@ -0,0 +1,17 @@
class PlacePageHeaderBuilder {
static func build(data: PlacePageData,
delegate: PlacePageHeaderViewControllerDelegate?,
headerType: PlacePageHeaderPresenter.HeaderType) -> PlacePageHeaderViewController {
let storyboard = UIStoryboard.instance(.placePage)
let viewController = storyboard.instantiateViewController(ofType: PlacePageHeaderViewController.self);
let presenter = PlacePageHeaderPresenter(view: viewController,
placePagePreviewData: data.previewData,
objectType: data.objectType,
delegate: delegate,
headerType: headerType)
viewController.presenter = presenter
return viewController
}
}

View file

@ -0,0 +1,71 @@
protocol PlacePageHeaderPresenterProtocol: AnyObject {
var objectType: PlacePageObjectType { get }
func configure()
func onClosePress()
func onExpandPress()
func onShareButtonPress(from sourceView: UIView)
func onExportTrackButtonPress(_ type: KmlFileType, from sourceView: UIView)
}
protocol PlacePageHeaderViewControllerDelegate: AnyObject {
func previewDidPressClose()
func previewDidPressExpand()
func previewDidPressShare(from sourceView: UIView)
func previewDidPressExportTrack(_ type: KmlFileType, from sourceView: UIView)
}
class PlacePageHeaderPresenter {
enum HeaderType {
case flexible
case fixed
}
private weak var view: PlacePageHeaderViewProtocol?
private let placePagePreviewData: PlacePagePreviewData
let objectType: PlacePageObjectType
private weak var delegate: PlacePageHeaderViewControllerDelegate?
private let headerType: HeaderType
init(view: PlacePageHeaderViewProtocol,
placePagePreviewData: PlacePagePreviewData,
objectType: PlacePageObjectType,
delegate: PlacePageHeaderViewControllerDelegate?,
headerType: HeaderType) {
self.view = view
self.delegate = delegate
self.placePagePreviewData = placePagePreviewData
self.objectType = objectType
self.headerType = headerType
}
}
extension PlacePageHeaderPresenter: PlacePageHeaderPresenterProtocol {
func configure() {
view?.setTitle(placePagePreviewData.title, secondaryTitle: placePagePreviewData.secondaryTitle, branch: placePagePreviewData.branch)
switch headerType {
case .flexible:
view?.isExpandViewHidden = false
view?.isShadowViewHidden = true
case .fixed:
view?.isExpandViewHidden = true
view?.isShadowViewHidden = false
}
}
func onClosePress() {
delegate?.previewDidPressClose()
}
func onExpandPress() {
delegate?.previewDidPressExpand()
}
func onShareButtonPress(from sourceView: UIView) {
delegate?.previewDidPressShare(from: sourceView)
}
func onExportTrackButtonPress(_ type: KmlFileType, from sourceView: UIView) {
delegate?.previewDidPressExportTrack(type, from: sourceView)
}
}

View file

@ -0,0 +1,10 @@
class PlacePageHeaderView: UIView {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for subview in subviews {
if !subview.isHidden && subview.isUserInteractionEnabled && subview.point(inside: convert(point, to: subview), with: event) {
return true
}
}
return false
}
}

View file

@ -0,0 +1,157 @@
protocol PlacePageHeaderViewProtocol: AnyObject {
var presenter: PlacePageHeaderPresenterProtocol? { get set }
var isExpandViewHidden: Bool { get set }
var isShadowViewHidden: Bool { get set }
func setTitle(_ title: String?, secondaryTitle: String?, branch: String?)
func showShareTrackMenu()
}
class PlacePageHeaderViewController: UIViewController {
var presenter: PlacePageHeaderPresenterProtocol?
@IBOutlet private var headerView: PlacePageHeaderView!
@IBOutlet private var titleLabel: UILabel?
@IBOutlet private var expandView: UIView!
@IBOutlet private var shadowView: UIView!
@IBOutlet private var grabberView: UIView!
@IBOutlet weak var closeButton: CircleImageButton!
@IBOutlet weak var shareButton: CircleImageButton!
private var titleText: String?
private var secondaryText: String?
private var branchText: String?
override func viewDidLoad() {
super.viewDidLoad()
presenter?.configure()
let tap = UITapGestureRecognizer(target: self, action: #selector(onExpandPressed(sender:)))
expandView.addGestureRecognizer(tap)
headerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
iPadSpecific { [weak self] in
self?.grabberView.isHidden = true
}
closeButton.setImage(UIImage(named: "ic_close")!)
shareButton.setImage(UIImage(named: "ic_share")!)
if presenter?.objectType == .track {
configureTrackSharingMenu()
}
let interaction = UIContextMenuInteraction(delegate: self)
titleLabel?.addInteraction(interaction)
}
@objc func onExpandPressed(sender: UITapGestureRecognizer) {
presenter?.onExpandPress()
}
@IBAction private func onCloseButtonPressed(_ sender: Any) {
presenter?.onClosePress()
}
@IBAction func onShareButtonPressed(_ sender: Any) {
presenter?.onShareButtonPress(from: shareButton)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
setTitle(titleText, secondaryTitle: secondaryText, branch: branchText)
}
}
extension PlacePageHeaderViewController: PlacePageHeaderViewProtocol {
var isExpandViewHidden: Bool {
get {
expandView.isHidden
}
set {
expandView.isHidden = newValue
}
}
var isShadowViewHidden: Bool {
get {
shadowView.isHidden
}
set {
shadowView.isHidden = newValue
}
}
func setTitle(_ title: String?, secondaryTitle: String?, branch: String? = nil) {
titleText = title
secondaryText = secondaryTitle
branchText = branch
// XCode 13 is not smart enough to detect that title is used below, and requires explicit unwrapped variable.
guard let unwrappedTitle = title else {
titleLabel?.attributedText = nil
return
}
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: StyleManager.shared.theme!.fonts.semibold20,
.foregroundColor: UIColor.blackPrimaryText()
]
let attributedText = NSMutableAttributedString(string: unwrappedTitle, attributes: titleAttributes)
// Add branch with thinner font weight if present and not already in title
if let branch = branch, !branch.isEmpty, !unwrappedTitle.contains(branch) {
let branchAttributes: [NSAttributedString.Key: Any] = [
.font: StyleManager.shared.theme!.fonts.regular20,
.foregroundColor: UIColor.blackPrimaryText()
]
attributedText.append(NSAttributedString(string: " \(branch)", attributes: branchAttributes))
}
guard let unwrappedSecondaryTitle = secondaryTitle else {
titleLabel?.attributedText = attributedText
return
}
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.paragraphSpacingBefore = 2
let secondaryTitleAttributes: [NSAttributedString.Key: Any] = [
.font: StyleManager.shared.theme!.fonts.medium16,
.foregroundColor: UIColor.blackPrimaryText(),
.paragraphStyle: paragraphStyle
]
attributedText.append(NSAttributedString(string: "\n" + unwrappedSecondaryTitle, attributes: secondaryTitleAttributes))
titleLabel?.attributedText = attributedText
}
func showShareTrackMenu() {
// The menu will be shown by the shareButton itself
}
private func configureTrackSharingMenu() {
let menu = UIMenu(title: "", image: nil, children: [
UIAction(title: L("export_file"), image: nil, handler: { [weak self] _ in
guard let self else { return }
self.presenter?.onExportTrackButtonPress(.text, from: self.shareButton)
}),
UIAction(title: L("export_file_gpx"), image: nil, handler: { [weak self] _ in
guard let self else { return }
self.presenter?.onExportTrackButtonPress(.gpx, from: self.shareButton)
}),
])
shareButton.menu = menu
shareButton.showsMenuAsPrimaryAction = true
}
}
extension PlacePageHeaderViewController: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { suggestedActions in
let copyAction = UIAction(title: L("copy_to_clipboard"), image: UIImage(systemName: "document.on.clipboard")) { action in
UIPasteboard.general.string = self.titleLabel?.text
}
return UIMenu(title: "", children: [copyAction])
})
}
}

View file

@ -0,0 +1,568 @@
final class InfoItemView: UIView {
private enum Constants {
static let viewHeight: CGFloat = 44
static let stackViewSpacing: CGFloat = 0
static let iconButtonSize: CGFloat = 56
static let iconButtonEdgeInsets = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
static let infoLabelFontSize: CGFloat = 16
static let infoLabelTopBottomSpacing: CGFloat = 10
static let accessoryButtonSize: CGFloat = 44
}
enum Style {
case regular
case link
}
typealias TapHandler = () -> Void
let iconButton = UIButton()
let infoLabel = UILabel()
let accessoryButton = UIButton()
var infoLabelTapHandler: TapHandler?
var infoLabelLongPressHandler: TapHandler?
var iconButtonTapHandler: TapHandler?
var accessoryImageTapHandler: TapHandler?
private var style: Style = .regular
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
layout()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupView()
layout()
}
private func setupView() {
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onInfoLabelTap)))
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(onInfoLabelLongPress(_:))))
infoLabel.lineBreakMode = .byTruncatingTail
infoLabel.numberOfLines = 1
infoLabel.allowsDefaultTighteningForTruncation = true
infoLabel.isUserInteractionEnabled = false
iconButton.imageView?.contentMode = .scaleAspectFit
iconButton.addTarget(self, action: #selector(onIconButtonTap), for: .touchUpInside)
iconButton.contentEdgeInsets = Constants.iconButtonEdgeInsets
accessoryButton.addTarget(self, action: #selector(onAccessoryButtonTap), for: .touchUpInside)
}
private func layout() {
addSubview(iconButton)
addSubview(infoLabel)
addSubview(accessoryButton)
translatesAutoresizingMaskIntoConstraints = false
iconButton.translatesAutoresizingMaskIntoConstraints = false
infoLabel.translatesAutoresizingMaskIntoConstraints = false
accessoryButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
heightAnchor.constraint(equalToConstant: Constants.viewHeight),
iconButton.leadingAnchor.constraint(equalTo: leadingAnchor),
iconButton.centerYAnchor.constraint(equalTo: centerYAnchor),
iconButton.widthAnchor.constraint(equalToConstant: Constants.iconButtonSize),
iconButton.topAnchor.constraint(equalTo: topAnchor),
iconButton.bottomAnchor.constraint(equalTo: bottomAnchor),
infoLabel.leadingAnchor.constraint(equalTo: iconButton.trailingAnchor),
infoLabel.topAnchor.constraint(equalTo: topAnchor),
infoLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
infoLabel.trailingAnchor.constraint(equalTo: accessoryButton.leadingAnchor),
accessoryButton.trailingAnchor.constraint(equalTo: trailingAnchor),
accessoryButton.centerYAnchor.constraint(equalTo: centerYAnchor),
accessoryButton.widthAnchor.constraint(equalToConstant: Constants.accessoryButtonSize),
accessoryButton.topAnchor.constraint(equalTo: topAnchor),
accessoryButton.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
@objc
private func onInfoLabelTap() {
infoLabelTapHandler?()
}
@objc
private func onInfoLabelLongPress(_ sender: UILongPressGestureRecognizer) {
guard sender.state == .began else { return }
infoLabelLongPressHandler?()
}
@objc
private func onIconButtonTap() {
iconButtonTapHandler?()
}
@objc
private func onAccessoryButtonTap() {
accessoryImageTapHandler?()
}
func setStyle(_ style: Style) {
switch style {
case .regular:
iconButton.setStyleAndApply(.black)
infoLabel.setFontStyleAndApply(.regular16, color: .blackPrimary)
case .link:
iconButton.setStyleAndApply(.blue)
infoLabel.setFontStyleAndApply(.regular16, color: .linkBlue)
}
accessoryButton.setStyleAndApply(.black)
self.style = style
}
func setAccessory(image: UIImage?, tapHandler: TapHandler? = nil) {
accessoryButton.setTitle("", for: .normal)
accessoryButton.setImage(image, for: .normal)
accessoryButton.isHidden = image == nil
accessoryImageTapHandler = tapHandler
}
}
protocol PlacePageInfoViewControllerDelegate: AnyObject {
var shouldShowOpenInApp: Bool { get }
func didPressCall(to phone: PlacePagePhone)
func didPressWebsite()
func didPressWebsiteMenu()
func didPressWikipedia()
func didPressWikimediaCommons()
func didPressFediverse()
func didPressFacebook()
func didPressInstagram()
func didPressTwitter()
func didPressVk()
func didPressLine()
func didPressBluesky()
func didPressPanoramax()
func didPressEmail()
func didPressOpenInApp(from sourceView: UIView)
func didCopy(_ content: String)
}
class PlacePageInfoViewController: UIViewController {
private struct Constants {
static let coordFormatIdKey = "PlacePageInfoViewController_coordFormatIdKey"
}
private typealias TapHandler = InfoItemView.TapHandler
private typealias Style = InfoItemView.Style
@IBOutlet var stackView: UIStackView!
@IBOutlet var checkDateLabel: UILabel!
@IBOutlet var checkDateLabelLayoutConstraint: NSLayoutConstraint!
private lazy var openingHoursViewController: OpeningHoursViewController = {
storyboard!.instantiateViewController(ofType: OpeningHoursViewController.self)
}()
private var rawOpeningHoursView: InfoItemView?
private var phoneViews: [InfoItemView] = []
private var websiteView: InfoItemView?
private var websiteMenuView: InfoItemView?
private var wikipediaView: InfoItemView?
private var wikimediaCommonsView: InfoItemView?
private var emailView: InfoItemView?
private var fediverseView: InfoItemView?
private var facebookView: InfoItemView?
private var instagramView: InfoItemView?
private var twitterView: InfoItemView?
private var vkView: InfoItemView?
private var lineView: InfoItemView?
private var blueskyView: InfoItemView?
private var panoramaxView: InfoItemView?
private var cuisineView: InfoItemView?
private var operatorView: InfoItemView?
private var wifiView: InfoItemView?
private var atmView: InfoItemView?
private var addressView: InfoItemView?
private var levelView: InfoItemView?
private var coordinatesView: InfoItemView?
private var openWithAppView: InfoItemView?
private var capacityView: InfoItemView?
private var wheelchairView: InfoItemView?
private var selfServiceView: InfoItemView?
private var outdoorSeatingView: InfoItemView?
private var driveThroughView: InfoItemView?
private var networkView: InfoItemView?
weak var placePageInfoData: PlacePageInfoData!
weak var delegate: PlacePageInfoViewControllerDelegate?
var coordinatesFormatId: Int {
get { UserDefaults.standard.integer(forKey: Constants.coordFormatIdKey) }
set { UserDefaults.standard.set(newValue, forKey: Constants.coordFormatIdKey) }
}
override func viewDidLoad() {
super.viewDidLoad()
stackView.axis = .vertical
stackView.alignment = .fill
stackView.spacing = 0
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addSeparator(.bottom)
view.addSubview(stackView)
setupViews()
}
// MARK: private
private func setupViews() {
if let openingHours = placePageInfoData.openingHours {
openingHoursViewController.openingHours = openingHours
openingHoursViewController.openingHoursCheckDate = placePageInfoData.checkDateOpeningHours
addChild(openingHoursViewController)
addToStack(openingHoursViewController.view)
openingHoursViewController.didMove(toParent: self)
} else if let openingHoursString = placePageInfoData.openingHoursString {
rawOpeningHoursView = createInfoItem(openingHoursString, icon: UIImage(named: "ic_placepage_open_hours"))
rawOpeningHoursView?.infoLabel.numberOfLines = 0
}
if let cuisine = placePageInfoData.cuisine {
cuisineView = createInfoItem(cuisine, icon: UIImage(named: "ic_placepage_cuisine"))
}
/// @todo Entrance is missing compared with Android. It's shown in title, but anyway ..
phoneViews = placePageInfoData.phones.map({ phone in
var cellStyle: Style = .regular
if let phoneUrl = phone.url, UIApplication.shared.canOpenURL(phoneUrl) {
cellStyle = .link
}
return createInfoItem(phone.phone,
icon: UIImage(named: "ic_placepage_phone_number"),
style: cellStyle,
tapHandler: { [weak self] in
self?.delegate?.didPressCall(to: phone)
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(phone.phone)
})
})
if let ppOperator = placePageInfoData.ppOperator {
operatorView = createInfoItem(ppOperator, icon: UIImage(named: "ic_placepage_operator"))
}
if let network = placePageInfoData.network {
networkView = createInfoItem(network, icon: UIImage(named: "ic_placepage_network"))
}
if let website = placePageInfoData.website {
// Strip website url only when the value is displayed, to avoid issues when it's opened or edited.
websiteView = createInfoItem(stripUrl(str: website),
icon: UIImage(named: "ic_placepage_website"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressWebsite()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(website)
})
}
if let websiteMenu = placePageInfoData.websiteMenu {
websiteView = createInfoItem(L("website_menu"),
icon: UIImage(named: "ic_placepage_website_menu"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressWebsiteMenu()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(websiteMenu)
})
}
if let wikipedia = placePageInfoData.wikipedia {
wikipediaView = createInfoItem(L("read_in_wikipedia"),
icon: UIImage(named: "ic_placepage_wiki"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressWikipedia()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(wikipedia)
})
}
if let wikimediaCommons = placePageInfoData.wikimediaCommons {
wikimediaCommonsView = createInfoItem(L("wikimedia_commons"),
icon: UIImage(named: "ic_placepage_wikimedia_commons"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressWikimediaCommons()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(wikimediaCommons)
})
}
if let wifi = placePageInfoData.wifiAvailable {
wifiView = createInfoItem(wifi, icon: UIImage(named: "ic_placepage_wifi"))
}
if let atm = placePageInfoData.atm {
atmView = createInfoItem(atm, icon: UIImage(named: "ic_placepage_atm"))
}
if let level = placePageInfoData.level {
levelView = createInfoItem(level, icon: UIImage(named: "ic_placepage_level"))
}
if let capacity = placePageInfoData.capacity {
capacityView = createInfoItem(capacity, icon: UIImage(named: "ic_placepage_capacity"))
}
if let wheelchair = placePageInfoData.wheelchair {
wheelchairView = createInfoItem(wheelchair, icon: UIImage(named: "ic_placepage_wheelchair"))
}
if let selfService = placePageInfoData.selfService {
selfServiceView = createInfoItem(selfService, icon: UIImage(named: "ic_placepage_self_service"))
}
if let outdoorSeating = placePageInfoData.outdoorSeating {
outdoorSeatingView = createInfoItem(outdoorSeating, icon: UIImage(named: "ic_placepage_outdoor_seating"))
}
if let driveThrough = placePageInfoData.driveThrough {
driveThroughView = createInfoItem(driveThrough, icon: UIImage(named: "ic_placepage_drive_through"))
}
if let email = placePageInfoData.email {
emailView = createInfoItem(email,
icon: UIImage(named: "ic_placepage_email"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressEmail()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(email)
})
}
if let fediverse = placePageInfoData.fediverse {
fediverseView = createInfoItem(fediverse,
icon: UIImage(named: "ic_placepage_fediverse"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressFediverse()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(fediverse)
})
}
if let facebook = placePageInfoData.facebook {
facebookView = createInfoItem(facebook,
icon: UIImage(named: "ic_placepage_facebook"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressFacebook()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(facebook)
})
}
if let instagram = placePageInfoData.instagram {
instagramView = createInfoItem(instagram,
icon: UIImage(named: "ic_placepage_instagram"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressInstagram()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(instagram)
})
}
if let twitter = placePageInfoData.twitter {
twitterView = createInfoItem(twitter,
icon: UIImage(named: "ic_placepage_twitter"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressTwitter()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(twitter)
})
}
if let vk = placePageInfoData.vk {
vkView = createInfoItem(vk,
icon: UIImage(named: "ic_placepage_vk"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressVk()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(vk)
})
}
if let line = placePageInfoData.line {
lineView = createInfoItem(line,
icon: UIImage(named: "ic_placepage_line"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressLine()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(line)
})
}
if let bluesky = placePageInfoData.bluesky {
blueskyView = createInfoItem(bluesky,
icon: UIImage(named: "ic_placepage_bluesky"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressBluesky()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(bluesky)
})
}
if let panoramax = placePageInfoData.panoramax {
panoramaxView = createInfoItem(L("panoramax_picture"),
icon: UIImage(named: "ic_placepage_panoramax"),
style: .link,
tapHandler: { [weak self] in
self?.delegate?.didPressPanoramax()
},
longPressHandler: { [weak self] in
self?.delegate?.didCopy(panoramax)
})
}
if let address = placePageInfoData.address {
addressView = createInfoItem(address,
icon: UIImage(named: "ic_placepage_address"),
longPressHandler: { [weak self] in
self?.delegate?.didCopy(address)
})
}
setupCoordinatesView()
setupOpenWithAppView()
if let checkDate = placePageInfoData.checkDate {
let checkDateFormatter = RelativeDateTimeFormatter()
checkDateFormatter.unitsStyle = .spellOut
checkDateFormatter.localizedString(for: checkDate, relativeTo: Date.now)
self.checkDateLabel.text = String(format: L("existence_confirmed_time_ago"), checkDateFormatter.localizedString(for: checkDate, relativeTo: Date.now))
checkDateLabel.isHidden = false
NSLayoutConstraint.activate([checkDateLabelLayoutConstraint])
} else {
checkDateLabel.text = String()
checkDateLabel.isHidden = true
NSLayoutConstraint.deactivate([checkDateLabelLayoutConstraint])
}
}
private func setupCoordinatesView() {
guard let coordFormats = placePageInfoData.coordFormats as? Array<String> else { return }
var formatId = coordinatesFormatId
if formatId >= coordFormats.count {
formatId = 0
}
coordinatesView = createInfoItem(coordFormats[formatId],
icon: UIImage(named: "ic_placepage_coordinate"),
accessoryImage: UIImage(named: "ic_placepage_change"),
tapHandler: { [weak self] in
guard let self else { return }
let formatId = (self.coordinatesFormatId + 1) % coordFormats.count
self.setCoordinatesSelected(formatId: formatId)
},
longPressHandler: { [weak self] in
self?.copyCoordinatesToPasteboard()
})
let menu = UIMenu(children: coordFormats.enumerated().map { (index, format) in
UIAction(title: format, handler: { [weak self] _ in
self?.setCoordinatesSelected(formatId: index)
self?.copyCoordinatesToPasteboard()
})
})
coordinatesView?.accessoryButton.menu = menu
coordinatesView?.accessoryButton.showsMenuAsPrimaryAction = true
}
private func setCoordinatesSelected(formatId: Int) {
guard let coordFormats = placePageInfoData.coordFormats as? Array<String> else { return }
coordinatesFormatId = formatId
let coordinates: String = coordFormats[formatId]
coordinatesView?.infoLabel.text = coordinates
}
private func copyCoordinatesToPasteboard() {
guard let coordFormats = placePageInfoData.coordFormats as? Array<String> else { return }
let coordinates: String = coordFormats[coordinatesFormatId]
delegate?.didCopy(coordinates)
}
private func setupOpenWithAppView() {
guard let delegate, delegate.shouldShowOpenInApp else { return }
openWithAppView = createInfoItem(L("open_in_app"),
icon: UIImage(named: "ic_open_in_app"),
style: .link,
tapHandler: { [weak self] in
guard let self, let openWithAppView else { return }
self.delegate?.didPressOpenInApp(from: openWithAppView)
})
}
private func createInfoItem(_ info: String,
icon: UIImage?,
tapIconHandler: TapHandler? = nil,
style: Style = .regular,
accessoryImage: UIImage? = nil,
tapHandler: TapHandler? = nil,
longPressHandler: TapHandler? = nil,
accessoryImageTapHandler: TapHandler? = nil) -> InfoItemView {
let view = InfoItemView()
addToStack(view)
view.iconButton.setImage(icon?.withRenderingMode(.alwaysTemplate), for: .normal)
view.iconButtonTapHandler = tapIconHandler
view.infoLabel.text = info
view.setStyle(style)
view.infoLabelTapHandler = tapHandler
view.infoLabelLongPressHandler = longPressHandler
view.setAccessory(image: accessoryImage, tapHandler: accessoryImageTapHandler)
return view
}
private func addToStack(_ view: UIView) {
stackView.addArrangedSubviewWithSeparator(view, insets: UIEdgeInsets(top: 0, left: 56, bottom: 0, right: 0))
}
private static let kHttp = "http://"
private static let kHttps = "https://"
private func stripUrl(str: String) -> String {
let dropFromStart = str.hasPrefix(PlacePageInfoViewController.kHttps) ? PlacePageInfoViewController.kHttps.count
: (str.hasPrefix(PlacePageInfoViewController.kHttp) ? PlacePageInfoViewController.kHttp.count : 0);
let dropFromEnd = str.hasSuffix("/") ? 1 : 0;
return String(str.dropFirst(dropFromStart).dropLast(dropFromEnd))
}
}
private extension UIStackView {
func addArrangedSubviewWithSeparator(_ view: UIView, insets: UIEdgeInsets = .zero) {
if !arrangedSubviews.isEmpty {
view.addSeparator(thickness: CGFloat(1.0), insets: insets)
}
addArrangedSubview(view)
}
}

View file

@ -0,0 +1,269 @@
final class PlacePageDirectionView: UIView {
@IBOutlet var imageView: UIImageView!
@IBOutlet var label: UILabel!
}
final class PlacePagePreviewViewController: UIViewController {
@IBOutlet var stackView: UIStackView!
@IBOutlet var popularView: UIView!
@IBOutlet var subtitleLabel: UILabel! {
didSet {
subtitleLabel.textColor = UIColor.blackSecondaryText()
subtitleLabel.font = UIFont.regular14()
}
}
@IBOutlet var subtitleContainerView: UIStackView!
@IBOutlet var scheduleLabel: UILabel!
@IBOutlet var reviewsLabel: UILabel!
@IBOutlet var addReviewButton: UIButton! {
didSet {
addReviewButton.setTitle("+ \(L("leave_a_review"))", for: .normal)
}
}
@IBOutlet var addressLabel: UILabel!
@IBOutlet var addressContainerView: UIStackView!
@IBOutlet var scheduleContainerView: UIStackView!
@IBOutlet var subtitleDirectionView: PlacePageDirectionView!
@IBOutlet var addressDirectionView: PlacePageDirectionView!
var placePageDirectionView: PlacePageDirectionView?
lazy var fullScreenDirectionView: DirectionView = {
return Bundle.main.load(viewClass: DirectionView.self)!
}()
var placePagePreviewData: PlacePagePreviewData! {
didSet {
if isViewLoaded {
updateViews()
}
}
}
private var distance: String? = nil
private var speedAndAltitude: String? = nil
private var heading: CGFloat? = nil
override func viewDidLoad() {
super.viewDidLoad()
updateViews()
if let distance = distance {
placePageDirectionView?.isHidden = false
placePageDirectionView?.label.text = distance
}
if let heading = heading {
updateHeading(heading)
} else {
placePageDirectionView?.imageView.isHidden = true
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
updateViews()
}
func updateViews() {
if placePagePreviewData.isMyPosition {
if let speedAndAltitude = speedAndAltitude {
subtitleLabel.text = speedAndAltitude
}
} else {
let subtitleString = NSMutableAttributedString()
// if placePagePreviewData.isPopular {
// subtitleString.append(NSAttributedString(string: L("popular_place"),
// attributes: [.foregroundColor : UIColor.linkBlue(),
// .font : UIFont.regular14()]))
// }
if let subtitle = placePagePreviewData.subtitle ?? placePagePreviewData.coordinates {
subtitleString.append(NSAttributedString(string: !subtitleString.string.isEmpty ? "" + subtitle : subtitle,
attributes: [.foregroundColor : UIColor.blackSecondaryText(),
.font : UIFont.regular14()]))
subtitleLabel.attributedText = subtitleString
subtitleContainerView.isHidden = false
} else {
subtitleLabel.text = nil
subtitleContainerView.isHidden = true
}
}
placePageDirectionView = subtitleDirectionView
if let address = placePagePreviewData.secondarySubtitle {
addressLabel.text = address
placePageDirectionView = addressDirectionView
} else {
addressContainerView.isHidden = true
}
placePageDirectionView?.imageView.changeColoringToOpposite()
configSchedule()
}
func updateDistance(_ distance: String) {
self.distance = distance
placePageDirectionView?.isHidden = false
placePageDirectionView?.label.text = distance
fullScreenDirectionView.updateDistance(distance)
}
func updateHeading(_ angle: CGFloat) {
placePageDirectionView?.imageView.isHidden = false
let duration = heading == nil ? .zero : kDefaultAnimationDuration // skip the initial setup animation
UIView.animate(withDuration: duration,
delay: 0,
options: [.beginFromCurrentState, .curveEaseInOut],
animations: { [unowned self] in
self.placePageDirectionView?.imageView.transform = CGAffineTransform(rotationAngle: CGFloat.pi / 2 - angle)
})
fullScreenDirectionView.updateHeading(angle)
heading = angle
}
func updateSpeedAndAltitude(_ speedAndAltitude: String) {
self.speedAndAltitude = speedAndAltitude
subtitleLabel?.text = speedAndAltitude
}
@IBAction func onDirectionPressed(_ sender: Any) {
guard let heading = heading else {
return
}
fullScreenDirectionView.updateTitle(placePagePreviewData.title,
subtitle: placePagePreviewData.subtitle ?? placePagePreviewData.coordinates)
fullScreenDirectionView.updateHeading(heading)
fullScreenDirectionView.updateDistance(distance)
fullScreenDirectionView.show()
}
// MARK: private
private func configSchedule() {
let now = time_t(Date().timeIntervalSince1970)
func stringFromTime(_ time: Int) -> String {
DateTimeFormatter.dateString(from: Date(timeIntervalSince1970: TimeInterval(time)),
dateStyle: .none,
timeStyle: .short)
}
switch placePagePreviewData.schedule.state {
case .unknown:
scheduleContainerView.isHidden = true
case .allDay:
setScheduleLabel(state: L("twentyfour_seven"),
stateColor: UIColor.BaseColors.green,
details: nil)
case .open:
let nextTimeClosed = placePagePreviewData.schedule.nextTimeClosed
let minutesUntilClosed = (nextTimeClosed - now) / 60
let stringTimeInterval = getTimeIntervalString(minutes: minutesUntilClosed)
let stringTime = stringFromTime(nextTimeClosed)
var state: String = L("editor_time_open")
var stateColor = UIColor.BaseColors.green
let details: String?
if (minutesUntilClosed < 60) // Less than 1 hour
{
state = String(format: L("closes_in"), stringTimeInterval)
stateColor = UIColor.BaseColors.yellow
details = stringTime
}
else if (minutesUntilClosed < 3 * 60) // Less than 3 hours
{
details = String(format: L("closes_in"), stringTimeInterval) + "" + stringTime
}
else if (minutesUntilClosed < 24 * 60) // Less than 24 hours
{
details = String(format: L("closes_at"), stringTime)
}
else
{
details = nil
}
setScheduleLabel(state: state,
stateColor: stateColor,
details: details)
case .closed:
let nextTimeOpen = placePagePreviewData.schedule.nextTimeOpen
let nextTimeOpenDate = Date(timeIntervalSince1970: TimeInterval(nextTimeOpen))
let minutesUntilOpen = (nextTimeOpen - now) / 60
let stringTimeInterval = getTimeIntervalString(minutes: minutesUntilOpen)
let stringTime = stringFromTime(nextTimeOpen)
var state: String = L("closed_now")
var stateColor = UIColor.BaseColors.red
let details: String?
if (minutesUntilOpen < 15) { // Less than 15 min
state = String(format: L("opens_in"), stringTimeInterval)
stateColor = UIColor.BaseColors.yellow
details = stringTime
}
else if (minutesUntilOpen < 3 * 60) // Less than 3 hours
{
details = String(format: L("opens_in"), stringTimeInterval) + "" + stringTime
}
else if (Calendar.current.isDateInToday(nextTimeOpenDate)) // Today
{
details = String(format: L("opens_at"), stringTime)
}
else if (minutesUntilOpen < 24 * 60) // Less than 24 hours
{
details = String(format: L("opens_tomorrow_at"), stringTime)
}
else if (minutesUntilOpen < 7 * 24 * 60) // Less than 1 week
{
let dayOfWeek = DateTimeFormatter.dateString(from: nextTimeOpenDate, format: "EEEE")
details = String(format: L("opens_dayoftheweek_at"), dayOfWeek, stringTime)
}
else
{
details = nil
}
setScheduleLabel(state: state,
stateColor: stateColor,
details: details)
@unknown default:
fatalError()
}
}
private func getTimeIntervalString(minutes: Int) -> String {
var str = ""
if (minutes >= 60)
{
str = String(minutes / 60) + " " + L("hour") + " "
}
str += String(minutes % 60) + " " + L("minute")
return str
}
private func setScheduleLabel(state: String, stateColor: UIColor, details: String?) {
let attributedString = NSMutableAttributedString()
let stateString = NSAttributedString(string: state,
attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14, weight: .semibold),
NSAttributedString.Key.foregroundColor: stateColor])
attributedString.append(stateString)
if (details != nil)
{
let detailsString = NSAttributedString(string: "" + details!,
attributes: [NSAttributedString.Key.font: UIFont.regular14(),
NSAttributedString.Key.foregroundColor: UIColor.blackSecondaryText()])
attributedString.append(detailsString)
}
scheduleLabel.attributedText = attributedString
}
}

View file

@ -0,0 +1,70 @@
protocol WikiDescriptionViewControllerDelegate: AnyObject {
func didPressMore()
}
class WikiDescriptionViewController: UIViewController {
@IBOutlet var descriptionTextView: UITextView!
@IBOutlet var moreButton: UIButton!
var descriptionHtml: String? {
didSet{
updateDescription()
}
}
weak var delegate: WikiDescriptionViewControllerDelegate?
override func viewDidLoad() {
super.viewDidLoad()
descriptionTextView.textContainerInset = .zero
updateDescription()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
updateDescription()
}
private func updateDescription() {
guard let descriptionHtml = descriptionHtml else { return }
DispatchQueue.global().async {
let font = UIFont.regular14()
let color = UIColor.blackPrimaryText()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 4
let attributedString: NSAttributedString
if let str = NSMutableAttributedString(htmlString: descriptionHtml, baseFont: font, paragraphStyle: paragraphStyle) {
str.addAttribute(NSAttributedString.Key.foregroundColor,
value: color,
range: NSRange(location: 0, length: str.length))
attributedString = str;
} else {
attributedString = NSAttributedString(string: descriptionHtml,
attributes: [NSAttributedString.Key.font : font,
NSAttributedString.Key.foregroundColor: color,
NSAttributedString.Key.paragraphStyle: paragraphStyle])
}
DispatchQueue.main.async {
if attributedString.length > 500 {
self.descriptionTextView.attributedText = attributedString.attributedSubstring(from: NSRange(location: 0,
length: 500))
} else {
self.descriptionTextView.attributedText = attributedString
}
}
}
}
@IBAction func onMore(_ sender: UIButton) {
delegate?.didPressMore()
}
override func applyTheme() {
super.applyTheme()
updateDescription()
}
}