diff --git a/CriticalMaps.xcodeproj/project.pbxproj b/CriticalMaps.xcodeproj/project.pbxproj index e1f47bed6..f39d5ef91 100644 --- a/CriticalMaps.xcodeproj/project.pbxproj +++ b/CriticalMaps.xcodeproj/project.pbxproj @@ -124,10 +124,13 @@ 9838F28E227B771F00744EDD /* IDStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9838F28D227B771F00744EDD /* IDStore.swift */; }; 98401AF521FB79F400064DAE /* ChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98401AF421FB79F400064DAE /* ChatViewController.swift */; }; 98401AF721FBCA1100064DAE /* ChatInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98401AF621FBCA1100064DAE /* ChatInputView.swift */; }; + 9841B477237E9239000A4922 /* AnnotationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841B476237E9239000A4922 /* AnnotationController.swift */; }; 9843FAA1236CD7F000457930 /* NetworkDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9843FAA0236CD7F000457930 /* NetworkDataProvider.swift */; }; 9843FAA3236CDC0D00457930 /* NetworkOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9843FAA2236CDC0D00457930 /* NetworkOperatorTests.swift */; }; 9843FAA5236CDC6E00457930 /* MockNetworkDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9843FAA4236CDC6E00457930 /* MockNetworkDataProvider.swift */; }; 9844438123429354005468B2 /* UIView+Autolayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9844438023429354005468B2 /* UIView+Autolayout.swift */; }; + 9853B50F237E0E8D00F46AB6 /* MKMapView+Register.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9853B50E237E0E8D00F46AB6 /* MKMapView+Register.swift */; }; + 9853B511237E101300F46AB6 /* BikeAnnotationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9853B510237E101300F46AB6 /* BikeAnnotationController.swift */; }; 985E94B9220796CB00AA991B /* NavigationOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985E94B8220796CB00AA991B /* NavigationOverlayViewController.swift */; }; 986749F8235230CB00BF5D6E /* Crypto in Frameworks */ = {isa = PBXBuildFile; productRef = 986749F7235230CB00BF5D6E /* Crypto */; }; 98790021222DA7E600880334 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98790020222DA7E600880334 /* AppDelegate.swift */; }; @@ -310,10 +313,13 @@ 9838F28D227B771F00744EDD /* IDStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDStore.swift; sourceTree = ""; }; 98401AF421FB79F400064DAE /* ChatViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewController.swift; sourceTree = ""; }; 98401AF621FBCA1100064DAE /* ChatInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputView.swift; sourceTree = ""; }; + 9841B476237E9239000A4922 /* AnnotationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationController.swift; sourceTree = ""; }; 9843FAA0236CD7F000457930 /* NetworkDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDataProvider.swift; sourceTree = ""; }; 9843FAA2236CDC0D00457930 /* NetworkOperatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkOperatorTests.swift; sourceTree = ""; }; 9843FAA4236CDC6E00457930 /* MockNetworkDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkDataProvider.swift; sourceTree = ""; }; 9844438023429354005468B2 /* UIView+Autolayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Autolayout.swift"; sourceTree = ""; }; + 9853B50E237E0E8D00F46AB6 /* MKMapView+Register.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MKMapView+Register.swift"; sourceTree = ""; }; + 9853B510237E101300F46AB6 /* BikeAnnotationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BikeAnnotationController.swift; sourceTree = ""; }; 985E94B8220796CB00AA991B /* NavigationOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationOverlayViewController.swift; sourceTree = ""; }; 98790020222DA7E600880334 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 987E3BD72368C6E100BAEFE7 /* MemoryDataStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MemoryDataStore.swift; sourceTree = ""; }; @@ -463,6 +469,7 @@ 948509A222C09F9A0034FAF1 /* UIViewController+Management.swift */, 940643D72307270B00AB1BCB /* GCD+Util.swift */, 9844438023429354005468B2 /* UIView+Autolayout.swift */, + 9853B50E237E0E8D00F46AB6 /* MKMapView+Register.swift */, ); name = Utility; sourceTree = ""; @@ -547,7 +554,7 @@ 9485A92D23756A3B006DC7B5 /* ContentState */, 989F9FEF21C7E5FC00E71085 /* Rules */, 985E94BC220891F200AA991B /* Social */, - 9822A16721F24CA7007F5994 /* MapViewController.swift */, + 9841B475237E9229000A4922 /* Map */, 982DA8D92203842D00E2A51B /* SettingsViewController.swift */, 985E94B8220796CB00AA991B /* NavigationOverlayViewController.swift */, ); @@ -726,6 +733,16 @@ name = DeviceID; sourceTree = ""; }; + 9841B475237E9229000A4922 /* Map */ = { + isa = PBXGroup; + children = ( + 9822A16721F24CA7007F5994 /* MapViewController.swift */, + 9853B510237E101300F46AB6 /* BikeAnnotationController.swift */, + 9841B476237E9239000A4922 /* AnnotationController.swift */, + ); + name = Map; + sourceTree = ""; + }; 985E94BC220891F200AA991B /* Social */ = { isa = PBXGroup; children = ( @@ -1054,6 +1071,7 @@ 9823712623781D53009D6F05 /* SimulationNetworkDataProvider.swift in Sources */, 98401AF721FBCA1100064DAE /* ChatInputView.swift in Sources */, 94FCBD28226C57CA00F93F94 /* UITableViewController+FooterSize.swift in Sources */, + 9853B511237E101300F46AB6 /* BikeAnnotationController.swift in Sources */, 9491720D23009EB3008B98AD /* APIRequest.swift in Sources */, 988B12AD22A271FE0010FAED /* Logger.swift in Sources */, 94FCBD35226C57FA00F93F94 /* ThemeController.swift in Sources */, @@ -1082,6 +1100,7 @@ 98F225262368D1B100EE8BD9 /* ChatMessageTableViewCell.swift in Sources */, 98E7338A220394E7005F311F /* SettingsSwitchTableViewCell.swift in Sources */, 9822A17221F3349F007F5994 /* Preferences.swift in Sources */, + 9841B477237E9239000A4922 /* AnnotationController.swift in Sources */, 989F9FFE21C8035500E71085 /* ApiResponse.swift in Sources */, 9491720F23009ED0008B98AD /* TwitterRequest.swift in Sources */, 982DA8C921FF65F700E2A51B /* Tweet.swift in Sources */, @@ -1138,6 +1157,7 @@ 94FCBD3D226C583F00F93F94 /* SettingsTableSectionHeader.swift in Sources */, 98329A1F22D3BEA000A157EE /* KeychainHelper.swift in Sources */, 989F9FF421C7E64900E71085 /* RulesDetailViewController.swift in Sources */, + 9853B50F237E0E8D00F46AB6 /* MKMapView+Register.swift in Sources */, 9460B76B225A6EB200786E27 /* FormatDisplay.swift in Sources */, 981C7893225F95900009FC3E /* ChatNavigationButtonController.swift in Sources */, 98003D4922872C1800592D55 /* URLCodable.swift in Sources */, diff --git a/CriticalMass/AnnotationController.swift b/CriticalMass/AnnotationController.swift new file mode 100644 index 000000000..9ab4da60a --- /dev/null +++ b/CriticalMass/AnnotationController.swift @@ -0,0 +1,22 @@ +// +// AnnotationController.swift +// CriticalMaps +// +// Created by Leonard Thomas on 15.11.19. +// Copyright © 2019 Pokus Labs. All rights reserved. +// + +import MapKit + +class AnnotationController { + var mapView: MKMapView + let annotationType = T.self + let annotationViewType = K.self + + required init(mapView: MKMapView) { + self.mapView = mapView + setup() + } + + open func setup() {} +} diff --git a/CriticalMass/BikeAnnotationController.swift b/CriticalMass/BikeAnnotationController.swift new file mode 100644 index 000000000..1abfe70e0 --- /dev/null +++ b/CriticalMass/BikeAnnotationController.swift @@ -0,0 +1,45 @@ +// +// BikeAnnotationController.swift +// CriticalMaps +// +// Created by Leonard Thomas on 14.11.19. +// Copyright © 2019 Pokus Labs. All rights reserved. +// + +import MapKit + +class BikeAnnotation: IdentifiableAnnnotation {} + +class BikeAnnotationController: AnnotationController { + public override func setup() { + NotificationCenter.default.addObserver(self, selector: #selector(positionsDidChange(notification:)), name: Notification.positionOthersChanged, object: nil) + } + + @objc private func positionsDidChange(notification: Notification) { + guard let response = notification.object as? ApiResponse else { return } + display(locations: response.locations) + } + + private func display(locations: [String: Location]) { + guard LocationManager.accessPermission == .authorized else { + Logger.log(.info, log: .map, "Bike annotations cannot be displayed because no GPS Access permission granted", parameter: LocationManager.accessPermission.rawValue) + return + } + var unmatchedLocations = locations + var unmatchedAnnotations: [MKAnnotation] = [] + // update existing annotations + mapView.annotations.compactMap { $0 as? BikeAnnotation }.forEach { annotation in + if let location = unmatchedLocations[annotation.identifier] { + annotation.location = location + unmatchedLocations.removeValue(forKey: annotation.identifier) + } else { + unmatchedAnnotations.append(annotation) + } + } + let annotations = unmatchedLocations.map { BikeAnnotation(location: $0.value, identifier: $0.key) } + mapView.addAnnotations(annotations) + + // remove annotations that no longer exist + mapView.removeAnnotations(unmatchedAnnotations) + } +} diff --git a/CriticalMass/LocationProvider.swift b/CriticalMass/LocationProvider.swift index cb4cd539f..71168da24 100644 --- a/CriticalMass/LocationProvider.swift +++ b/CriticalMass/LocationProvider.swift @@ -7,7 +7,7 @@ import Foundation -public enum LocationProviderPermission { +public enum LocationProviderPermission: String { case authorized case denied case disabled diff --git a/CriticalMass/Logger.swift b/CriticalMass/Logger.swift index 1897187b2..62281f5df 100644 --- a/CriticalMass/Logger.swift +++ b/CriticalMass/Logger.swift @@ -14,6 +14,9 @@ extension OSLog { @available(OSX 10.12, *) static let viewManagement = OSLog(subsystem: subsystem, category: "viewManagement") + + @available(OSX 10.12, *) + static let map = OSLog(subsystem: subsystem, category: "Map") } class Logger { diff --git a/CriticalMass/MKMapView+Register.swift b/CriticalMass/MKMapView+Register.swift new file mode 100644 index 000000000..5f5989d56 --- /dev/null +++ b/CriticalMass/MKMapView+Register.swift @@ -0,0 +1,34 @@ +// +// MKMapView+Register.swift +// CriticalMaps +// +// Created by Leonard Thomas on 14.11.19. +// Copyright © 2019 Pokus Labs. All rights reserved. +// + +import MapKit + +extension MKAnnotationView { + fileprivate class var reuseIdentifier: String { + return String(describing: self) + } +} + +extension MKMapView { + func register(annotationViewType: T.Type) { + if #available(iOS 11.0, *) { + register(annotationViewType, forAnnotationViewWithReuseIdentifier: annotationViewType.reuseIdentifier) + } + } + + func dequeueReusableAnnotationView(ofType annotationType: T.Type, for indexPath: IndexPath? = nil, with annotation: MKAnnotation) -> T { + let annotationView: T + if #available(iOS 11.0, *) { + annotationView = dequeueReusableAnnotationView(withIdentifier: annotationType.reuseIdentifier, for: annotation) as! T + } else { + annotationView = dequeueReusableAnnotationView(withIdentifier: annotationType.reuseIdentifier) as? T ?? T() + annotationView.annotation = annotation + } + return annotationView + } +} diff --git a/CriticalMass/MapViewController.swift b/CriticalMass/MapViewController.swift index 2752abf61..eaaa97476 100644 --- a/CriticalMass/MapViewController.swift +++ b/CriticalMass/MapViewController.swift @@ -11,50 +11,58 @@ import UIKit class MapViewController: UIViewController { private let themeController: ThemeController! private var tileRenderer: MKTileOverlayRenderer? - + init(themeController: ThemeController) { self.themeController = themeController super.init(nibName: nil, bundle: nil) } - + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + // MARK: Properties - + + private lazy var annotationController: [AnnotationController] = { + [BikeAnnotationController(mapView: self.mapView)] + }() + private let nightThemeOverlay = DarkModeMapOverlay() public lazy var followMeButton: UserTrackingButton = { let button = UserTrackingButton(mapView: mapView) return button }() - + public var bottomContentOffset: CGFloat = 0 { didSet { mapView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: bottomContentOffset, right: 0) } } - + private var mapView = MKMapView(frame: .zero) - + private let gpsDisabledOverlayView: BlurryOverlayView = { let view = BlurryOverlayView.fromNib() view.translatesAutoresizingMaskIntoConstraints = false return view }() - + override func viewDidLoad() { super.viewDidLoad() - + title = String.mapTitle configureNotifications() configureTileRenderer() configureMapView() condfigureGPSDisabledOverlayView() - + + annotationController + .map{ $0.annotationViewType } + .forEach(mapView.register) + setNeedsStatusBarAppearanceUpdate() } - + private func configureTileRenderer() { guard themeController.currentTheme == .dark else { if #available(iOS 13.0, *) { @@ -62,14 +70,14 @@ class MapViewController: UIViewController { } return } - + if #available(iOS 13.0, *) { overrideUserInterfaceStyle = .dark } else { addTileRenderer() } } - + private func condfigureGPSDisabledOverlayView() { let gpsDisabledOverlayView = self.gpsDisabledOverlayView gpsDisabledOverlayView.set(title: String.mapLayerInfoTitle, message: String.mapLayerInfo) @@ -79,99 +87,63 @@ class MapViewController: UIViewController { gpsDisabledOverlayView.heightAnchor.constraint(equalTo: view.heightAnchor), gpsDisabledOverlayView.widthAnchor.constraint(equalTo: view.widthAnchor), ]) - + updateGPSDisabledOverlayVisibility() } - + private func configureNotifications() { - NotificationCenter.default.addObserver(self, selector: #selector(positionsDidChange(notification:)), name: Notification.positionOthersChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(didReceiveInitialLocation(notification:)), name: Notification.initialGpsDataReceived, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateGPSDisabledOverlayVisibility), name: Notification.observationModeChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: Notification.themeDidChange, object: nil) } - + private func configureMapView() { view.addSubview(mapView) - mapView.translatesAutoresizingMaskIntoConstraints = false - view.addConstraints([ - NSLayoutConstraint(item: mapView, attribute: .width, relatedBy: .equal, toItem: view, attribute: .width, multiplier: 1, constant: 0), - NSLayoutConstraint(item: mapView, attribute: .height, relatedBy: .equal, toItem: view, attribute: .height, multiplier: 1, constant: 1), - NSLayoutConstraint(item: mapView, attribute: .leading, relatedBy: .equal, toItem: view, attribute: .leading, multiplier: 1, constant: 0), - NSLayoutConstraint(item: mapView, attribute: .top, relatedBy: .equal, toItem: view, attribute: .top, multiplier: 1, constant: 0), - ]) - - if #available(iOS 11.0, *) { - mapView.register(BikeAnnoationView.self, forAnnotationViewWithReuseIdentifier: BikeAnnoationView.identifier) - } + mapView.addLayoutsSameSizeAndOrigin(in: view) mapView.showsPointsOfInterest = false mapView.delegate = self mapView.showsUserLocation = true } - - private func display(locations: [String: Location]) { - guard LocationManager.accessPermission == .authorized else { - return - } - var unmatchedLocations = locations - var unmatchedAnnotations: [MKAnnotation] = [] - // update existing annotations - mapView.annotations.compactMap { $0 as? IdentifiableAnnnotation }.forEach { annotation in - if let location = unmatchedLocations[annotation.identifier] { - annotation.location = location - unmatchedLocations.removeValue(forKey: annotation.identifier) - } else { - unmatchedAnnotations.append(annotation) - } - } - let annotations = unmatchedLocations.map { IdentifiableAnnnotation(location: $0.value, identifier: $0.key) } - mapView.addAnnotations(annotations) - - // remove annotations that no longer exist - mapView.removeAnnotations(unmatchedAnnotations) - } - + + // GPS Disabled Overlay + @objc func didTapGPSDisabledOverlayButton() { UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) } - + @objc func updateGPSDisabledOverlayVisibility() { gpsDisabledOverlayView.isHidden = LocationManager.accessPermission != .denied } - + // MARK: Notifications - + override var preferredStatusBarStyle: UIStatusBarStyle { return themeController.currentTheme.style.statusBarStyle } - + @objc private func themeDidChange() { let theme = themeController.currentTheme guard theme == .dark else { - if #available(iOS 13.0, *) { + if #available(iOS 13.0, *) { overrideUserInterfaceStyle = .light - } else { - removeTileRenderer() - } + } else { + removeTileRenderer() + } return } configureTileRenderer() } - + private func removeTileRenderer() { tileRenderer = nil mapView.removeOverlay(nightThemeOverlay) } - + private func addTileRenderer() { tileRenderer = MKTileOverlayRenderer(tileOverlay: nightThemeOverlay) mapView.addOverlay(nightThemeOverlay, level: .aboveRoads) } - - @objc private func positionsDidChange(notification: Notification) { - guard let response = notification.object as? ApiResponse else { return } - display(locations: response.locations) - } - + @objc func didReceiveInitialLocation(notification: Notification) { guard let location = notification.object as? Location else { return } let region = MKCoordinateRegion(center: CLLocationCoordinate2D(location), latitudinalMeters: 10000, longitudinalMeters: 10000) @@ -182,25 +154,23 @@ class MapViewController: UIViewController { extension MapViewController: MKMapViewDelegate { // MARK: MKMapViewDelegate - + func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { guard annotation is MKUserLocation == false else { return nil } - let annotationView: BikeAnnoationView - if #available(iOS 11.0, *) { - annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: BikeAnnoationView.identifier, for: annotation) as! BikeAnnoationView - } else { - annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: BikeAnnoationView.identifier) as? BikeAnnoationView ?? BikeAnnoationView() - annotationView.annotation = annotation + + guard let matchingController = annotationController.first(where: { type(of: annotation) == $0.annotationType }) else { + return nil } - return annotationView + + return mapView.dequeueReusableAnnotationView(ofType: matchingController.annotationViewType, with: annotation) } - + func mapView(_: MKMapView, didChange mode: MKUserTrackingMode, animated _: Bool) { followMeButton.currentMode = UserTrackingButton.Mode(mode) } - + func mapView(_: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { guard let renderer = self.tileRenderer else { return MKOverlayRenderer(overlay: overlay) diff --git a/CriticalMass/UIView+Autolayout.swift b/CriticalMass/UIView+Autolayout.swift index ed5f8ce65..5021d6881 100644 --- a/CriticalMass/UIView+Autolayout.swift +++ b/CriticalMass/UIView+Autolayout.swift @@ -22,4 +22,15 @@ extension UIView { widthAnchor.constraint(equalToConstant: size.width) ]) } + + func addLayoutsSameSizeAndOrigin(in view: UIView) { + translatesAutoresizingMaskIntoConstraints = false + + view.addConstraints([ + heightAnchor.constraint(equalTo: view.heightAnchor), + widthAnchor.constraint(equalTo: view.widthAnchor), + leadingAnchor.constraint(equalTo: view.leadingAnchor), + topAnchor.constraint(equalTo: view.topAnchor) + ]) + } }