Show own location in map views (#7375)

* show own location on static sharing

* show own location on live sharing

* add tests and changelog

* check location authorisation, fix center to current user location button

* it moves request to LocationManager in proper service

* add static location viewer service
This commit is contained in:
Flescio
2023-02-20 18:07:03 +01:00
committed by GitHub
parent d15131e9ba
commit 95501bfdde
21 changed files with 267 additions and 36 deletions
@@ -42,6 +42,12 @@ struct LiveLocationSharingViewerViewState: BindableState {
/// Live location list items
var listItemsViewData: [LiveLocationListItemViewData]
var showsUserLocation = false
var isCurrentUserShared: Bool {
listItemsViewData.contains { $0.isCurrentUser }
}
var showLoadingIndicator = false
var shareButtonEnabled: Bool {
@@ -75,4 +81,5 @@ enum LiveLocationSharingViewerViewAction {
case tapListItem(_ userId: String)
case share(_ annotation: UserLocationAnnotation)
case mapCreditsDidTap
case showUserLocation
}
@@ -72,6 +72,8 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType
completion?(.share(userLocationAnnotation.coordinate))
case .mapCreditsDidTap:
state.bindings.showMapCreditsSheet.toggle()
case .showUserLocation:
showsCurrentUserLocation()
}
}
@@ -229,4 +231,12 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType
}
}
}
private func showsCurrentUserLocation() {
if liveLocationSharingViewerService.requestAuthorizationIfNeeded() {
state.showsUserLocation = true
} else {
state.errorSubject.send(.invalidLocationAuthorization)
}
}
}
@@ -33,4 +33,6 @@ protocol LiveLocationSharingViewerServiceProtocol {
/// Stop current user location sharing
func stopUserLiveLocationSharing(completion: @escaping (Result<Void, Error>) -> Void)
func requestAuthorizationIfNeeded() -> Bool
}
@@ -19,11 +19,13 @@ import Foundation
import MatrixSDK
class LiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol {
// MARK: - Properties
private(set) var usersLiveLocation: [UserLiveLocation] = []
private let roomId: String
private var beaconInfoSummaryListener: Any?
private let locationManager = CLLocationManager()
// MARK: Private
@@ -74,6 +76,10 @@ class LiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol
}
}
func requestAuthorizationIfNeeded() -> Bool {
locationManager.requestAuthorizationIfNeeded()
}
// MARK: - Private
private func updateUsersLiveLocation(notifyUpdate: Bool) {
@@ -27,12 +27,17 @@ class MockLiveLocationSharingViewerService: LiveLocationSharingViewerServiceProt
// MARK: Setup
init(generateRandomUsers: Bool = false) {
let firstUserLiveLocation = createFirstUserLiveLocation()
init(generateRandomUsers: Bool = false, currentUserSharingLocation: Bool = true) {
let firstUserLiveLocation: UserLiveLocation?
if currentUserSharingLocation {
firstUserLiveLocation = createFirstUserLiveLocation()
} else {
firstUserLiveLocation = nil
}
let secondUserLiveLocation = createSecondUserLiveLocation()
var usersLiveLocation: [UserLiveLocation] = [firstUserLiveLocation, secondUserLiveLocation]
var usersLiveLocation: [UserLiveLocation] = [firstUserLiveLocation, secondUserLiveLocation].compactMap { $0 }
if generateRandomUsers {
for _ in 1...20 {
@@ -56,6 +61,10 @@ class MockLiveLocationSharingViewerService: LiveLocationSharingViewerServiceProt
func stopUserLiveLocationSharing(completion: @escaping (Result<Void, Error>) -> Void) { }
func requestAuthorizationIfNeeded() -> Bool {
return true
}
// MARK: Private
private func createFirstUserLiveLocation() -> UserLiveLocation {
@@ -15,6 +15,7 @@
//
import Combine
import CoreLocation
import XCTest
@testable import RiotSwiftUI
@@ -30,4 +31,17 @@ class LiveLocationSharingViewerViewModelTests: XCTestCase {
viewModel = LiveLocationSharingViewerViewModel(mapStyleURL: BuildSettings.defaultTileServerMapStyleURL, service: service)
context = viewModel.context
}
func testIsUserBeingShared() {
XCTAssertTrue(context.viewState.isCurrentUserShared)
}
func testToggleShowUserLocation() {
let service = MockLiveLocationSharingViewerService(currentUserSharingLocation: false)
let viewModel = LiveLocationSharingViewerViewModel(mapStyleURL: BuildSettings.defaultTileServerMapStyleURL, service: service)
XCTAssertFalse(viewModel.context.viewState.isCurrentUserShared)
XCTAssertFalse(viewModel.context.viewState.showsUserLocation)
viewModel.context.send(viewAction: .showUserLocation)
XCTAssertTrue(viewModel.context.viewState.showsUserLocation)
}
}
@@ -34,23 +34,35 @@ struct LiveLocationSharingViewer: View {
@ObservedObject var viewModel: LiveLocationSharingViewerViewModel.Context
var mapView: LocationSharingMapView {
LocationSharingMapView(tileServerMapURL: viewModel.viewState.mapStyleURL,
annotations: viewModel.viewState.annotations,
highlightedAnnotation: viewModel.viewState.highlightedAnnotation,
userAvatarData: nil,
showsUserLocation: viewModel.viewState.showsUserLocation,
userAnnotationCanShowCallout: true,
userLocation: Binding.constant(nil),
mapCenterCoordinate: Binding.constant(nil),
onCalloutTap: { annotation in
if let userLocationAnnotation = annotation as? UserLocationAnnotation {
viewModel.send(viewAction: .share(userLocationAnnotation))
}
},
errorSubject: viewModel.viewState.errorSubject)
}
var body: some View {
ZStack(alignment: .bottom) {
if !viewModel.viewState.showMapLoadingError {
LocationSharingMapView(tileServerMapURL: viewModel.viewState.mapStyleURL,
annotations: viewModel.viewState.annotations,
highlightedAnnotation: viewModel.viewState.highlightedAnnotation,
userAvatarData: nil,
showsUserLocation: false,
userAnnotationCanShowCallout: true,
userLocation: Binding.constant(nil),
mapCenterCoordinate: Binding.constant(nil),
onCalloutTap: { annotation in
if let userLocationAnnotation = annotation as? UserLocationAnnotation {
viewModel.send(viewAction: .share(userLocationAnnotation))
}
},
errorSubject: viewModel.viewState.errorSubject)
if !viewModel.viewState.isCurrentUserShared {
mapView
.overlay(CenterToUserLocationButton(action: {
viewModel.send(viewAction: .showUserLocation)
}).offset(x: -11.0, y: 52), alignment: .topTrailing)
} else {
mapView
}
// Show map credits above collapsed bottom sheet height if bottom sheet is visible
if viewModel.viewState.isBottomSheetVisible {
@@ -178,3 +190,27 @@ struct LiveLocationSharingViewer_Previews: PreviewProvider {
}
}
}
struct CenterToUserLocationButton: View {
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
var action: () -> Void
var body: some View {
Button {
action()
} label: {
Image(uiImage: Asset.Images.locationCenterMapIcon.image)
.foregroundColor(theme.colors.accent)
}
.padding(8.0)
.background(theme.colors.background)
.clipShape(Circle())
.shadow(radius: 2.0)
}
}
@@ -32,7 +32,7 @@ struct MapViewErrorAlertInfoBuilder {
case .invalidLocationAuthorization:
alertInfo = AlertInfo(id: .authorizationError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, primaryButtonCompletion),
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, {}),
secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, primaryButtonCompletion))
default:
alertInfo = nil
@@ -75,7 +75,7 @@ struct LocationSharingMapView: UIViewRepresentable {
mapView.vc_removeAllAnnotations()
mapView.addAnnotations(annotations)
if let highlightedAnnotation = highlightedAnnotation {
if let highlightedAnnotation = highlightedAnnotation, !showsUserLocation {
mapView.setCenter(highlightedAnnotation.coordinate, zoomLevel: Constants.mapZoomLevel, animated: false)
}
@@ -125,11 +125,14 @@ extension LocationSharingMapView {
return LocationAnnotationView(userLocationAnnotation: userLocationAnnotation)
} else if let pinLocationAnnotation = annotation as? PinLocationAnnotation {
return LocationAnnotationView(pinLocationAnnotation: pinLocationAnnotation)
} else if annotation is MGLUserLocation, let currentUserAvatarData = locationSharingMapView.userAvatarData {
// Replace default current location annotation view with a UserLocationAnnotatonView when the map is center on user location
return LocationAnnotationView(avatarData: currentUserAvatarData)
} else if annotation is MGLUserLocation {
if let currentUserAvatarData = locationSharingMapView.userAvatarData {
// Replace default current location annotation view with a UserLocationAnnotatonView when the map is center on user location
return LocationAnnotationView(avatarData: currentUserAvatarData)
} else {
return LocationAnnotationView(userPinLocationAnnotation: annotation)
}
}
return nil
}
@@ -48,7 +48,11 @@ class LocationAnnotationView: MGLUserLocationAnnotationView {
addUserMarkerView(with: userLocationAnnotation.avatarData)
}
convenience init(userPinLocationAnnotation: MGLAnnotation) {
self.init(annotation: userPinLocationAnnotation, reuseIdentifier: "userPinLocation")
addPinView()
}
convenience init(pinLocationAnnotation: PinLocationAnnotation) {
// TODO: Use a reuseIdentifier
self.init(annotation: pinLocationAnnotation, reuseIdentifier: nil)
@@ -74,6 +78,16 @@ class LocationAnnotationView: MGLUserLocationAnnotationView {
addMarkerView(avatarMarkerView)
}
private func addPinView() {
guard let pinView = UIHostingController(rootView: Image(uiImage: Asset.Images.locationMarkerIcon.image)
.resizable()
.foregroundColor(theme.colors.accent)).view else {
return
}
addMarkerView(pinView)
}
private func addPinMarkerView() {
guard let pinMarkerView = UIHostingController(rootView: LocationSharingMarkerView(backgroundColor: theme.colors.accent) {
Image(uiImage: Asset.Images.locationPinIcon.image)
@@ -53,7 +53,8 @@ final class StaticLocationViewingCoordinator: Coordinator, Presentable {
mapStyleURL: parameters.session.vc_homeserverConfiguration().tileServer.mapStyleURL,
avatarData: parameters.avatarData,
location: parameters.location,
coordinateType: parameters.coordinateType
coordinateType: parameters.coordinateType,
service: StaticLocationSharingViewerService()
)
let view = StaticLocationView(viewModel: viewModel.context)
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager)))
@@ -46,7 +46,8 @@ enum MockStaticLocationViewingScreenState: MockScreenState, CaseIterable {
let viewModel = StaticLocationViewingViewModel(mapStyleURL: mapStyleURL,
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "alice:matrix.org", displayName: "Alice"),
location: location,
coordinateType: coordinateType)
coordinateType: coordinateType,
service: MockStaticLocationSharingViewerService())
return ([viewModel],
AnyView(StaticLocationView(viewModel: viewModel.context)
@@ -0,0 +1,32 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import CoreLocation
import Foundation
class StaticLocationSharingViewerService: StaticLocationSharingViewerServiceProtocol {
// MARK: Private
private let locationManager = CLLocationManager()
// MARK: Public
func requestAuthorizationIfNeeded() -> Bool {
locationManager.requestAuthorizationIfNeeded()
}
}
@@ -0,0 +1,25 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
class MockStaticLocationSharingViewerService: StaticLocationSharingViewerServiceProtocol {
func requestAuthorizationIfNeeded() -> Bool {
return true
}
}
@@ -0,0 +1,22 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol StaticLocationSharingViewerServiceProtocol {
func requestAuthorizationIfNeeded() -> Bool
}
@@ -23,6 +23,7 @@ import Foundation
enum StaticLocationViewingViewAction {
case close
case share
case showUserLocation
}
enum StaticLocationViewingViewModelResult {
@@ -42,6 +43,8 @@ struct StaticLocationViewingViewState: BindableState {
/// Shared annotation to display existing location
let sharedAnnotation: LocationAnnotation
var showsUserLocation = false
var showLoadingIndicator = false
var shareButtonEnabled: Bool {
@@ -24,6 +24,7 @@ class StaticLocationViewingViewModel: StaticLocationViewingViewModelType, Static
// MARK: Private
private var staticLocationSharingViewerService: StaticLocationSharingViewerServiceProtocol
private var mapViewErrorAlertInfoBuilder: MapViewErrorAlertInfoBuilder
// MARK: Public
@@ -32,7 +33,10 @@ class StaticLocationViewingViewModel: StaticLocationViewingViewModelType, Static
// MARK: - Setup
init(mapStyleURL: URL, avatarData: AvatarInputProtocol, location: CLLocationCoordinate2D, coordinateType: LocationSharingCoordinateType) {
init(mapStyleURL: URL, avatarData: AvatarInputProtocol, location: CLLocationCoordinate2D, coordinateType: LocationSharingCoordinateType, service: StaticLocationSharingViewerServiceProtocol) {
staticLocationSharingViewerService = service
let sharedAnnotation: LocationAnnotation
switch coordinateType {
case .user:
@@ -63,6 +67,8 @@ class StaticLocationViewingViewModel: StaticLocationViewingViewModelType, Static
completion?(.close)
case .share:
completion?(.share(state.sharedAnnotation.coordinate))
case .showUserLocation:
showsCurrentUserLocation()
}
}
@@ -89,4 +95,12 @@ class StaticLocationViewingViewModel: StaticLocationViewingViewModelType, Static
state.bindings.alertInfo = alertInfo
}
private func showsCurrentUserLocation() {
if staticLocationSharingViewerService.requestAuthorizationIfNeeded() {
state.showsUserLocation = true
} else {
state.errorSubject.send(.invalidLocationAuthorization)
}
}
}
@@ -79,10 +79,18 @@ class StaticLocationViewingViewModelTests: XCTestCase {
waitForExpectations(timeout: 3)
}
func testToggleShowUserLocation() {
let viewModel = buildViewModel()
XCTAssertFalse(viewModel.context.viewState.showsUserLocation)
viewModel.context.send(viewAction: .showUserLocation)
XCTAssertTrue(viewModel.context.viewState.showsUserLocation)
}
private func buildViewModel() -> StaticLocationViewingViewModel {
StaticLocationViewingViewModel(mapStyleURL: URL(string: "http://empty.com")!,
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: ""),
location: CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096),
coordinateType: .user)
coordinateType: .user,
service: MockStaticLocationSharingViewerService())
}
}
@@ -29,19 +29,26 @@ struct StaticLocationView: View {
// MARK: Views
var mapView: LocationSharingMapView {
LocationSharingMapView(tileServerMapURL: viewModel.viewState.mapStyleURL,
annotations: [viewModel.viewState.sharedAnnotation],
highlightedAnnotation: viewModel.viewState.sharedAnnotation,
userAvatarData: nil,
showsUserLocation: viewModel.viewState.showsUserLocation,
userLocation: Binding.constant(nil),
mapCenterCoordinate: Binding.constant(nil),
errorSubject: viewModel.viewState.errorSubject)
}
var body: some View {
NavigationView {
ZStack(alignment: .bottom) {
LocationSharingMapView(tileServerMapURL: viewModel.viewState.mapStyleURL,
annotations: [viewModel.viewState.sharedAnnotation],
highlightedAnnotation: viewModel.viewState.sharedAnnotation,
userAvatarData: viewModel.viewState.userAvatarData,
showsUserLocation: false,
userLocation: Binding.constant(nil),
mapCenterCoordinate: Binding.constant(nil),
errorSubject: viewModel.viewState.errorSubject)
mapView
MapCreditsView()
}
.overlay(CenterToUserLocationButton(action: {
viewModel.send(viewAction: .showUserLocation)
}).offset(x: -11.0, y: 52), alignment: .topTrailing)
.ignoresSafeArea(.all, edges: [.bottom])
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {