Send current user location (#1272)

* add new asset for location, add logic for sharing user current location

* work on default zoom and auth for location

* add attribution button

* fix attribution button position, fix isSharingUserLocation

* Add NSLocationWhenInUseUsageDescription back

* Fix initial animation

* change map logo and attribution position

* Add annotation just in the setup

* add errors and alert for map vies

* location unit test

* add unit test for location sharing

* add changelog

* Update ElementX/Sources/Other/MapLibre/MapLibreMapView.swift

Co-authored-by: Alfonso Grillo <alfogrillo@gmail.com>

* add comments and minor parameters refactor

* Update ElementX/Sources/Screens/LocationSharing/StaticLocationScreenCoordinator.swift

Co-authored-by: Mauro <34335419+Velin92@users.noreply.github.com>

* Update ElementX/Sources/Screens/LocationSharing/StaticLocationScreenViewModel.swift

Co-authored-by: Mauro <34335419+Velin92@users.noreply.github.com>

* Sort assets

* Cleanup

* Cleanup

* Fix error localizations

* Fix tests

---------

Co-authored-by: Alfonso Grillo <alfogrillo@gmail.com>
Co-authored-by: Mauro <34335419+Velin92@users.noreply.github.com>
This commit is contained in:
Flescio
2023-07-11 12:42:59 +02:00
committed by GitHub
parent 5e97a4a0fa
commit 153149d93f
23 changed files with 1139 additions and 129 deletions
@@ -515,7 +515,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
_ = await self.roomProxy?.sendLocation(body: geoURI.bodyMessage,
geoURI: geoURI,
description: nil,
zoomLevel: nil,
zoomLevel: 15,
assetType: isUserLocation ? .sender : .pin)
self.navigationSplitCoordinator.setSheetCoordinator(nil)
}
+2
View File
@@ -39,6 +39,8 @@ internal enum Asset {
internal static let launchLogo = ImageAsset(name: "images/launch-logo")
internal static let locationMarker = ImageAsset(name: "images/location-marker")
internal static let locationPin = ImageAsset(name: "images/location-pin")
internal static let locationPointerFull = ImageAsset(name: "images/location-pointer-full")
internal static let locationPointer = ImageAsset(name: "images/location-pointer")
internal static let timelineComposerSendMessage = ImageAsset(name: "images/timeline-composer-send-message")
internal static let waitingGradient = ImageAsset(name: "images/waiting-gradient")
}
+20 -6
View File
@@ -308,14 +308,20 @@ public enum L10n {
public static var emojiPickerCategorySymbols: String { return L10n.tr("Localizable", "emoji_picker_category_symbols") }
/// Failed creating the permalink
public static var errorFailedCreatingThePermalink: String { return L10n.tr("Localizable", "error_failed_creating_the_permalink") }
/// Element could not load the map. Please try again later.
public static var errorFailedLoadingMap: String { return L10n.tr("Localizable", "error_failed_loading_map") }
/// %1$@ could not load the map. Please try again later.
public static func errorFailedLoadingMap(_ p1: Any) -> String {
return L10n.tr("Localizable", "error_failed_loading_map", String(describing: p1))
}
/// Failed loading messages
public static var errorFailedLoadingMessages: String { return L10n.tr("Localizable", "error_failed_loading_messages") }
/// Element could not access your location. Please try again later.
public static var errorFailedLocatingUser: String { return L10n.tr("Localizable", "error_failed_locating_user") }
/// Element does not have permission to access your location. You can enable access in Settings > Location
public static var errorMissingLocationAuth: String { return L10n.tr("Localizable", "error_missing_location_auth") }
/// %1$@ could not access your location. Please try again later.
public static func errorFailedLocatingUser(_ p1: Any) -> String {
return L10n.tr("Localizable", "error_failed_locating_user", String(describing: p1))
}
/// %1$@ does not have permission to access your location. You can enable access in Settings > Location
public static func errorMissingLocationAuth(_ p1: Any) -> String {
return L10n.tr("Localizable", "error_missing_location_auth", String(describing: p1))
}
/// No compatible app was found to handle this action.
public static var errorNoCompatibleAppFound: String { return L10n.tr("Localizable", "error_no_compatible_app_found") }
/// Some messages have not been sent
@@ -780,6 +786,14 @@ public enum L10n {
public static var screenRoomRetrySendMenuSendAgainAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_send_again_action") }
/// Your message failed to send
public static var screenRoomRetrySendMenuTitle: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_title") }
/// Add emoji
public static var screenRoomTimelineAddReaction: String { return L10n.tr("Localizable", "screen_room_timeline_add_reaction") }
/// Show less
public static var screenRoomTimelineLessReactions: String { return L10n.tr("Localizable", "screen_room_timeline_less_reactions") }
/// Plural format key: "%#@COUNT@"
public static func screenRoomTimelineMoreReactions(_ p1: Int) -> String {
return L10n.tr("Localizable", "screen_room_timeline_more_reactions", p1)
}
/// Create a new conversation or room
public static var screenRoomlistA11yCreateMessage: String { return L10n.tr("Localizable", "screen_roomlist_a11y_create_message") }
/// All Chats
@@ -20,15 +20,20 @@ import SwiftUI
struct MapLibreMapView: UIViewRepresentable {
struct Options {
/// The initial zoom level
/// the final zoom level used when the first user location emit
let zoomLevel: Double
/// The initial zoom level used when the map it firstly loaded and the user location is not yet available, in case of annotations this property is not being used
let initialZoomLevel: Double
/// The initial map center
let mapCenter: CLLocationCoordinate2D?
let mapCenter: CLLocationCoordinate2D
/// Map annotations
let annotations: [LocationAnnotation]
init(zoomLevel: Double, mapCenter: CLLocationCoordinate2D? = nil, annotations: [LocationAnnotation] = []) {
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [LocationAnnotation] = []) {
self.zoomLevel = zoomLevel
self.initialZoomLevel = initialZoomLevel
self.mapCenter = mapCenter
self.annotations = annotations
}
@@ -43,14 +48,16 @@ struct MapLibreMapView: UIViewRepresentable {
let options: Options
/// Behavior mode of the current user's location, can be hidden, only shown and shown following the user
var showsUserLocationMode: ShowUserLocationMode = .hide
@Binding var showsUserLocationMode: ShowUserLocationMode
/// Bind view errors if any
let error: Binding<MapLibreError?>
@Binding var error: MapLibreError?
/// Coordinate of the center of the map
@Binding var mapCenterCoordinate: CLLocationCoordinate2D?
@Binding var isLocationAuthorized: Bool?
/// Called when the user pan on the map
var userDidPan: (() -> Void)?
@@ -59,23 +66,12 @@ struct MapLibreMapView: UIViewRepresentable {
func makeUIView(context: Context) -> MGLMapView {
let mapView = makeMapView()
mapView.delegate = context.coordinator
let panGesture = UIPanGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.didPan))
panGesture.delegate = context.coordinator
mapView.addGestureRecognizer(panGesture)
setupMap(mapView: mapView, with: options)
return mapView
}
func updateUIView(_ mapView: MGLMapView, context: Context) {
mapView.removeAllAnnotations()
mapView.addAnnotations(options.annotations)
if colorScheme == .dark {
mapView.styleURL = builder.dynamicMapURL(for: .dark)
} else {
mapView.styleURL = builder.dynamicMapURL(for: .light)
}
mapView.styleURL = builder.dynamicMapURL(for: .init(colorScheme))
showUserLocation(in: mapView)
}
@@ -86,32 +82,36 @@ struct MapLibreMapView: UIViewRepresentable {
// MARK: - Private
private func setupMap(mapView: MGLMapView, with options: Options) {
mapView.zoomLevel = options.zoomLevel
if let mapCenter = options.mapCenter {
mapView.centerCoordinate = mapCenter
}
mapView.addAnnotations(options.annotations)
mapView.zoomLevel = options.annotations.isEmpty ? options.initialZoomLevel : options.zoomLevel
mapView.centerCoordinate = options.mapCenter
}
private func makeMapView() -> MGLMapView {
let mapView = MGLMapView(frame: .zero, styleURL: colorScheme == .dark ? builder.dynamicMapURL(for: .dark) : builder.dynamicMapURL(for: .light))
showUserLocation(in: mapView)
mapView.attributionButton.isHidden = true
mapView.logoViewPosition = .topLeft
mapView.attributionButtonPosition = .topLeft
mapView.attributionButtonMargins = .init(x: mapView.logoView.frame.maxX + 8, y: mapView.logoView.center.y / 2)
return mapView
}
private func showUserLocation(in mapView: MGLMapView) {
switch showsUserLocationMode {
case .showAndFollow:
mapView.showsUserLocation = true
switch (showsUserLocationMode, options.annotations) {
case (.showAndFollow, _):
mapView.userTrackingMode = .follow
case .show:
case (.show, let annotations) where !annotations.isEmpty:
/** in the show mode, if there are annotations, we check the authorizationStatus,
if it's not determined, we wont prompt the user with a request for permissions,
because he should be able to see the annotations without sharing his location informations
**/
guard mapView.locationManager.authorizationStatus != .notDetermined else { return }
fallthrough
case (.show, _):
mapView.showsUserLocation = true
mapView.userTrackingMode = .none
case .hide:
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil)
case (.hide, _):
mapView.showsUserLocation = false
mapView.userTrackingMode = .none
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil)
}
}
}
@@ -119,11 +119,13 @@ struct MapLibreMapView: UIViewRepresentable {
// MARK: - Coordinator
extension MapLibreMapView {
class Coordinator: NSObject, MGLMapViewDelegate, UIGestureRecognizerDelegate {
class Coordinator: NSObject, MGLMapViewDelegate {
// MARK: - Properties
var mapLibreView: MapLibreMapView
private var previousUserLocation: MGLUserLocation?
// MARK: - Setup
init(_ mapLibreView: MapLibreMapView) {
@@ -140,20 +142,30 @@ extension MapLibreMapView {
}
func mapViewDidFailLoadingMap(_ mapView: MGLMapView, withError error: Error) {
mapLibreView.error.wrappedValue = .failedLoadingMap
mapLibreView.error = .failedLoadingMap
}
func mapView(_ mapView: MGLMapView, didUpdate userLocation: MGLUserLocation?) { }
func mapView(_ mapView: MGLMapView, didUpdate userLocation: MGLUserLocation?) {
guard let userLocation else { return }
if previousUserLocation == nil, mapLibreView.options.annotations.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
mapView.setCenter(userLocation.coordinate, zoomLevel: self.mapLibreView.options.zoomLevel, animated: true)
}
}
previousUserLocation = userLocation
}
func mapView(_ mapView: MGLMapView, didChangeLocationManagerAuthorization manager: MGLLocationManager) {
guard mapView.showsUserLocation else {
return
}
switch manager.authorizationStatus {
case .denied, .restricted:
mapLibreView.error.wrappedValue = .invalidLocationAuthorization
default:
mapLibreView.isLocationAuthorized = false
case .authorizedAlways, .authorizedWhenInUse:
mapLibreView.isLocationAuthorized = true
case .notDetermined:
mapLibreView.isLocationAuthorized = nil
@unknown default:
break
}
}
@@ -171,15 +183,25 @@ extension MapLibreMapView {
false
}
// MARK: UIGestureRecognizer
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
gestureRecognizer is UIPanGestureRecognizer
}
@objc
func didPan() {
mapLibreView.userDidPan?()
func mapView(_ mapView: MGLMapView, shouldChangeFrom oldCamera: MGLMapCamera, to newCamera: MGLMapCamera, reason: MGLCameraChangeReason) -> Bool {
// we send the userDidPan event only for the reasons that actually will change the map center, and not zoom only / rotations only events.
switch reason {
case .gesturePan,
.gesturePinch,
.gestureRotate:
mapLibreView.userDidPan?()
case .gestureOneFingerZoom,
.gestureTilt,
.gestureZoomIn,
.gestureZoomOut,
.programmatic,
.resetNorth,
.transitionCancelled:
break
default:
break
}
return true
}
}
}
@@ -194,3 +216,16 @@ private extension MGLMapView {
removeAnnotations(annotations)
}
}
private extension MapTilerStyle {
init(_ colorScheme: ColorScheme) {
switch colorScheme {
case .light:
self = .light
case .dark:
self = .dark
@unknown default:
fatalError()
}
}
}
@@ -36,7 +36,6 @@ enum MapTilerStyle {
enum MapLibreError: Error {
case failedLoadingMap
case failedLocatingUser
case invalidLocationAuthorization
}
enum MapTilerAttributionPlacement: String {
@@ -18,12 +18,13 @@ import CoreLocation
import Foundation
enum LocationSharingViewError: Error, Hashable {
case failedSharingLocation
case missingAuthorization
case mapError(MapLibreError)
}
enum StaticLocationScreenViewModelAction {
case close
case openSystemSettings
case sendLocation(GeoURI, isUserLocation: Bool)
}
@@ -33,47 +34,36 @@ enum StaticLocationInteractionMode: Hashable {
}
struct StaticLocationScreenViewState: BindableState {
init(interactionMode: StaticLocationInteractionMode, isSharingUserLocation: Bool = false, showsUserLocationMode: ShowUserLocationMode = .hide) {
init(interactionMode: StaticLocationInteractionMode) {
self.interactionMode = interactionMode
self.isSharingUserLocation = isSharingUserLocation
self.showsUserLocationMode = showsUserLocationMode
switch interactionMode {
case .picker:
bindings = .init()
case .viewOnly(let geoURI, _):
bindings = .init(mapCenterLocation: .init(latitude: geoURI.latitude, longitude: geoURI.longitude))
bindings.showsUserLocationMode = .showAndFollow
case .viewOnly:
bindings.showsUserLocationMode = .show
}
}
let interactionMode: StaticLocationInteractionMode
/// Indicates whether the user is sharing his current location
var isSharingUserLocation: Bool
/// Behavior mode of the current user's location, can be hidden, only shown and shown following the user
var showsUserLocationMode: ShowUserLocationMode
var bindings = StaticLocationScreenBindings()
var showBottomToolbar: Bool {
interactionMode == .picker
var isSharingUserLocation: Bool {
bindings.isLocationAuthorized == true && bindings.showsUserLocationMode == .showAndFollow
}
var mapAnnotationCoordinate: CLLocationCoordinate2D? {
var bindings = StaticLocationScreenBindings(showsUserLocationMode: .hide)
var initialMapCenter: CLLocationCoordinate2D {
switch interactionMode {
case .picker:
return nil
// middle point in Europe, to be used if the users location is not yet known
return .init(latitude: 49.843, longitude: 9.902056)
case .viewOnly(let geoURI, _):
return .init(latitude: geoURI.latitude, longitude: geoURI.longitude)
}
}
var isLocationPickerMode: Bool {
switch interactionMode {
case .picker:
return true
case .viewOnly:
return false
}
interactionMode == .picker
}
var navigationTitle: String {
@@ -95,9 +85,13 @@ struct StaticLocationScreenViewState: BindableState {
}
var zoomLevel: Double {
15.0
}
var initialZoomLevel: Double {
switch interactionMode {
case .picker:
return 5.0
return 2.7
case .viewOnly:
return 15.0
}
@@ -115,6 +109,10 @@ struct StaticLocationScreenViewState: BindableState {
struct StaticLocationScreenBindings {
var mapCenterLocation: CLLocationCoordinate2D?
var showsUserLocationMode: ShowUserLocationMode
var isLocationAuthorized: Bool?
/// Information describing the currently displayed alert.
var mapError: MapLibreError? {
@@ -125,7 +123,7 @@ struct StaticLocationScreenBindings {
return nil
}
set {
alertInfo = newValue.map { AlertInfo(id: .mapError($0)) }
alertInfo = newValue.map { AlertInfo(locationSharingViewError: .mapError($0)) }
}
}
@@ -138,5 +136,33 @@ struct StaticLocationScreenBindings {
enum StaticLocationScreenViewAction {
case close
case selectLocation
case centerToUser
case userDidPan
}
extension AlertInfo where T == LocationSharingViewError {
init(locationSharingViewError error: LocationSharingViewError,
primaryButton: AlertButton = AlertButton(title: L10n.actionOk, action: nil),
secondaryButton: AlertButton? = nil) {
switch error {
case .missingAuthorization:
self.init(id: error,
title: "",
message: L10n.errorMissingLocationAuth(InfoPlistReader.main.bundleDisplayName),
primaryButton: primaryButton,
secondaryButton: secondaryButton)
case .mapError(.failedLoadingMap):
self.init(id: error,
title: "",
message: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName),
primaryButton: primaryButton,
secondaryButton: secondaryButton)
case .mapError(.failedLocatingUser):
self.init(id: error,
title: "",
message: L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName),
primaryButton: primaryButton,
secondaryButton: secondaryButton)
}
}
}
@@ -51,6 +51,12 @@ final class StaticLocationScreenCoordinator: CoordinatorProtocol {
switch action {
case .close:
actionsSubject.send(.close)
case .openSystemSettings:
guard let url = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(url) else {
return
}
UIApplication.shared.open(url)
case .sendLocation(let geoURI, let isUserLocation):
actionsSubject.send(.selectedLocation(geoURI, isUserLocation: isUserLocation))
}
@@ -38,8 +38,17 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo
guard let coordinate = state.bindings.mapCenterLocation else { return }
actionsSubject.send(.sendLocation(.init(coordinate: coordinate), isUserLocation: state.isSharingUserLocation))
case .userDidPan:
state.showsUserLocationMode = .hide
state.isSharingUserLocation = false
state.bindings.showsUserLocationMode = .show
case .centerToUser:
switch state.bindings.isLocationAuthorized {
case .some(true), .none:
state.bindings.showsUserLocationMode = .showAndFollow
case .some(false):
let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) }
state.bindings.alertInfo = .init(locationSharingViewError: .missingAuthorization,
primaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.commonSettings, action: action))
}
}
}
}
@@ -44,17 +44,20 @@ struct StaticLocationScreen: View {
ZStack(alignment: .center) {
MapLibreMapView(builder: builder,
options: mapOptions,
showsUserLocationMode: .hide,
showsUserLocationMode: $context.showsUserLocationMode,
error: $context.mapError,
mapCenterCoordinate: $context.mapCenterLocation,
userDidPan: {
context.send(viewAction: .userDidPan)
})
isLocationAuthorized: $context.isLocationAuthorized) {
context.send(viewAction: .userDidPan)
}
.ignoresSafeArea(.all, edges: mapSafeAreaEdges)
if context.viewState.isLocationPickerMode {
LocationMarkerView()
}
}
.ignoresSafeArea(.all, edges: mapSafeAreaEdges)
.overlay(alignment: .bottomTrailing) {
centerToUserLocationButton
}
}
// MARK: - Private
@@ -72,7 +75,7 @@ struct StaticLocationScreen: View {
}
}
if context.viewState.showBottomToolbar {
if context.viewState.isLocationPickerMode {
ToolbarItemGroup(placement: .bottomBar) {
selectLocationButton
Spacer()
@@ -81,19 +84,22 @@ struct StaticLocationScreen: View {
}
private var mapOptions: MapLibreMapView.Options {
guard let coordinate = context.viewState.mapAnnotationCoordinate else {
return .init(zoomLevel: context.viewState.zoomLevel)
var annotations: [LocationAnnotation] = []
if context.viewState.isLocationPickerMode == false {
let annotation = LocationAnnotation(coordinate: context.viewState.initialMapCenter, anchorPoint: .bottomCenter) {
LocationMarkerView()
}
annotations.append(annotation)
}
return .init(zoomLevel: context.viewState.zoomLevel,
mapCenter: coordinate,
annotations: [LocationAnnotation(coordinate: coordinate, anchorPoint: .bottomCenter) {
LocationMarkerView()
}])
initialZoomLevel: context.viewState.initialZoomLevel,
mapCenter: context.viewState.initialMapCenter,
annotations: annotations)
}
private var mapSafeAreaEdges: Edge.Set {
context.viewState.showBottomToolbar ? .horizontal : [.horizontal, .bottom]
context.viewState.isLocationPickerMode ? .horizontal : [.horizontal, .bottom]
}
@ScaledMetric private var shareMarkerSize: CGFloat = 28
@@ -111,6 +117,15 @@ struct StaticLocationScreen: View {
}
}
private var centerToUserLocationButton: some View {
Button {
context.send(viewAction: .centerToUser)
} label: {
Image(asset: context.viewState.isSharingUserLocation ? Asset.Images.locationPointerFull : Asset.Images.locationPointer)
}
.padding(16)
}
private var closeButton: some View {
Button(L10n.actionCancel) {
context.send(viewAction: .close)
@@ -127,14 +142,13 @@ struct StaticLocationScreen: View {
@ViewBuilder
private var shareSheet: some View {
if let location = context.viewState.mapAnnotationCoordinate {
let locationDescription = context.viewState.locationDescription
AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, locationDescription: locationDescription)],
applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, locationDescription: locationDescription) })
.edgesIgnoringSafeArea(.bottom)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
}
let location = context.viewState.initialMapCenter
let locationDescription = context.viewState.locationDescription
AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, locationDescription: locationDescription)],
applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, locationDescription: locationDescription) })
.edgesIgnoringSafeArea(.bottom)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
}
}
@@ -238,7 +238,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
return .none
}
}
// swiftlint:disable:next cyclomatic_complexity function_body_length
private func updateTimelineItems() {
var newTimelineItems = [RoomTimelineItemProtocol]()
var canBackPaginate = true