Merge pull request #6016 from vector-im/maximee/5982_ls_static_share_viewer

[Location Sharing] Add separate screen for viewing static shared location
This commit is contained in:
MaximeEvrard42 2022-04-12 12:32:12 +02:00 committed by GitHub
commit dcfaadbf97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 699 additions and 162 deletions

View file

@ -2139,6 +2139,7 @@ Tap the + to start adding people.";
"live_location_sharing_banner_stop" = "Stop";
"location_sharing_static_share_title" = "Send my current location";
"location_sharing_pin_drop_share_title" = "Send this location";
"location_sharing_live_map_callout_title" = "Share location";
// MARK: - MatrixKit

View file

@ -2759,6 +2759,10 @@ public class VectorL10n: NSObject {
public static var locationSharingInvalidAuthorizationSettings: String {
return VectorL10n.tr("Vector", "location_sharing_invalid_authorization_settings")
}
/// Share location
public static var locationSharingLiveMapCalloutTitle: String {
return VectorL10n.tr("Vector", "location_sharing_live_map_callout_title")
}
/// Share live location
public static var locationSharingLiveShareTitle: String {
return VectorL10n.tr("Vector", "location_sharing_live_share_title")

View file

@ -246,8 +246,54 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
completion?()
}
private func showLocationCoordinatorWithEvent(_ event: MXEvent, bubbleData: MXKRoomBubbleCellDataStoring) {
guard #available(iOS 14.0, *) else {
return
}
guard let navigationRouter = self.navigationRouter,
let mediaManager = mxSession?.mediaManager,
let locationContent = event.location else {
MXLog.error("[RoomCoordinator] Invalid location showing coordinator parameters. Returning.")
return
}
let avatarData = AvatarInput(mxContentUri: bubbleData.senderAvatarUrl,
matrixItemId: bubbleData.senderId,
displayName: bubbleData.senderDisplayName)
let location = CLLocationCoordinate2D(latitude: locationContent.latitude, longitude: locationContent.longitude)
let coordinateType = locationContent.assetType
guard let locationSharingCoordinatetype = coordinateType.locationSharingCoordinateType() else {
fatalError("[LocationSharingCoordinator] event asset type is not supported: \(coordinateType)")
}
let parameters = StaticLocationViewingCoordinatorParameters(mediaManager: mediaManager,
avatarData: avatarData,
location: location,
coordinateType: locationSharingCoordinatetype)
let coordinator = StaticLocationViewingCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] in
guard let self = self, let coordinator = coordinator else {
return
}
self.navigationRouter?.dismissModule(animated: true, completion: nil)
self.remove(childCoordinator: coordinator)
}
add(childCoordinator: coordinator)
navigationRouter.present(coordinator, animated: true)
coordinator.start()
}
private func startLocationCoordinatorWithEvent(_ event: MXEvent? = nil, bubbleData: MXKRoomBubbleCellDataStoring? = nil) {
private func startLocationCoordinator() {
guard #available(iOS 14.0, *) else {
return
}
@ -259,29 +305,13 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
return
}
var avatarData: AvatarInputProtocol
if event != nil, let bubbleData = bubbleData {
avatarData = AvatarInput(mxContentUri: bubbleData.senderAvatarUrl,
matrixItemId: bubbleData.senderId,
displayName: bubbleData.senderDisplayName)
} else {
avatarData = AvatarInput(mxContentUri: user.avatarUrl,
let avatarData = AvatarInput(mxContentUri: user.avatarUrl,
matrixItemId: user.userId,
displayName: user.displayname)
}
var location: CLLocationCoordinate2D?
var coordinateType: MXEventAssetType = .user
if let locationContent = event?.location {
location = CLLocationCoordinate2D(latitude: locationContent.latitude, longitude: locationContent.longitude)
coordinateType = locationContent.assetType
}
let parameters = LocationSharingCoordinatorParameters(roomDataSource: roomViewController.roomDataSource,
mediaManager: mediaManager,
avatarData: avatarData,
location: location,
coordinateType: coordinateType)
avatarData: avatarData)
let coordinator = LocationSharingCoordinator(parameters: parameters)
@ -411,11 +441,11 @@ extension RoomCoordinator: RoomViewControllerDelegate {
}
func roomViewControllerDidRequestLocationSharingFormPresentation(_ roomViewController: RoomViewController) {
startLocationCoordinatorWithEvent()
startLocationCoordinator()
}
func roomViewController(_ roomViewController: RoomViewController, didRequestLocationPresentationFor event: MXEvent, bubbleData: MXKRoomBubbleCellDataStoring) {
startLocationCoordinatorWithEvent(event, bubbleData: bubbleData)
showLocationCoordinatorWithEvent(event, bubbleData: bubbleData)
}
func roomViewController(_ roomViewController: RoomViewController, locationShareActivityViewControllerFor event: MXEvent) -> UIActivityViewController? {

View file

@ -23,8 +23,6 @@ struct LocationSharingCoordinatorParameters {
let roomDataSource: MXKRoomDataSource
let mediaManager: MXMediaManager
let avatarData: AvatarInputProtocol
let location: CLLocationCoordinate2D?
let coordinateType: MXEventAssetType
}
// Map between type from MatrixSDK and type from SwiftUI target, as we don't want
@ -79,16 +77,8 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
init(parameters: LocationSharingCoordinatorParameters) {
self.parameters = parameters
// TODO: Make this check before creating LocationSharingCoordinator
// Use LocationSharingCoordinateType in parameters
guard let locationSharingCoordinatetype = parameters.coordinateType.locationSharingCoordinateType() else {
fatalError("[LocationSharingCoordinator] event asset type is not supported: \(parameters.coordinateType)")
}
let viewModel = LocationSharingViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL,
avatarData: parameters.avatarData,
location: parameters.location,
coordinateType: locationSharingCoordinatetype,
isLiveLocationSharingEnabled: BuildSettings.liveLocationSharingEnabled)
let view = LocationSharingView(context: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
@ -111,14 +101,7 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
case .cancel:
self.completion?()
case .share(let latitude, let longitude, let coordinateType):
// Show share sheet on existing location display
if let location = self.parameters.location {
self.presentShareLocationActivity(with: location)
} else {
self.shareStaticLocation(latitude: latitude, longitude: longitude, coordinateType: coordinateType)
}
self.shareStaticLocation(latitude: latitude, longitude: longitude, coordinateType: coordinateType)
case .shareLiveLocation(let timeout):
self.startLiveLocationSharing(with: timeout)
}

View file

@ -0,0 +1,29 @@
//
// Copyright 2022 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
import CoreLocation
/// Build a UIActivityViewController to share a location
class ShareLocationActivityControllerBuilder {
func build(with location: CLLocationCoordinate2D) -> UIActivityViewController {
return UIActivityViewController(activityItems: [ShareToMapsAppActivity.urlForMapsAppType(.apple, location: location)],
applicationActivities: [ShareToMapsAppActivity(type: .apple, location: location),
ShareToMapsAppActivity(type: .google, location: location),
ShareToMapsAppActivity(type: .osm, location: location)])
}
}

View file

@ -55,9 +55,6 @@ struct LocationSharingViewState: BindableState {
/// Current user avatarData
let userAvatarData: AvatarInputProtocol
/// Shared annotation to display existing location
let sharedAnnotation: LocationAnnotation?
/// Map annotations to display on map
var annotations: [LocationAnnotation]
@ -77,14 +74,6 @@ struct LocationSharingViewState: BindableState {
/// Used to hide live location sharing features until is finished
var isLiveLocationSharingEnabled: Bool = false
var shareButtonVisible: Bool {
return self.displayExistingLocation == false
}
var displayExistingLocation: Bool {
return sharedAnnotation != nil
}
var shareButtonEnabled: Bool {
!showLoadingIndicator
}

View file

@ -21,7 +21,6 @@ import CoreLocation
@available(iOS 14.0, *)
enum MockLocationSharingScreenState: MockScreenState, CaseIterable {
case shareUserLocation
case displayExistingLocation
var screenType: Any.Type {
LocationSharingView.self
@ -29,16 +28,9 @@ enum MockLocationSharingScreenState: MockScreenState, CaseIterable {
var screenView: ([Any], AnyView) {
var location: CLLocationCoordinate2D?
if self == .displayExistingLocation {
location = CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096)
}
let mapStyleURL = URL(string: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx")!
let viewModel = LocationSharingViewModel(mapStyleURL: mapStyleURL,
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "alice:matrix.org", displayName: "Alice"),
location: location,
coordinateType: .user,
isLiveLocationSharingEnabled: true)
return ([viewModel],
AnyView(LocationSharingView(context: viewModel.context)

View file

@ -41,38 +41,12 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie
// MARK: - Setup
init(mapStyleURL: URL, avatarData: AvatarInputProtocol, location: CLLocationCoordinate2D? = nil, coordinateType: LocationSharingCoordinateType, isLiveLocationSharingEnabled: Bool = false) {
var sharedAnnotation: LocationAnnotation?
var annotations: [LocationAnnotation] = []
var highlightedAnnotation: LocationAnnotation?
var showsUserLocation: Bool = false
// Displaying an existing location
if let sharedCoordinate = location {
let sharedLocationAnnotation: LocationAnnotation
switch coordinateType {
case .user:
sharedLocationAnnotation = UserLocationAnnotation(avatarData: avatarData, coordinate: sharedCoordinate)
case .pin:
sharedLocationAnnotation = PinLocationAnnotation(coordinate: sharedCoordinate)
}
annotations.append(sharedLocationAnnotation)
highlightedAnnotation = sharedLocationAnnotation
sharedAnnotation = sharedLocationAnnotation
} else {
// Share current location
showsUserLocation = true
}
init(mapStyleURL: URL, avatarData: AvatarInputProtocol, isLiveLocationSharingEnabled: Bool = false) {
let viewState = LocationSharingViewState(mapStyleURL: mapStyleURL,
userAvatarData: avatarData,
sharedAnnotation: sharedAnnotation,
annotations: annotations,
highlightedAnnotation: highlightedAnnotation,
showsUserLocation: showsUserLocation,
annotations: [],
highlightedAnnotation: nil,
showsUserLocation: true,
isLiveLocationSharingEnabled: isLiveLocationSharingEnabled)
super.init(initialViewState: viewState)
@ -90,12 +64,6 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie
case .cancel:
completion?(.cancel)
case .share:
// Share existing location
if let location = state.sharedAnnotation?.coordinate {
completion?(.share(latitude: location.latitude, longitude: location.longitude, coordinateType: .user))
return
}
// Share current user location
guard let location = state.bindings.userLocation else {
processError(.failedLocatingUser)

View file

@ -0,0 +1,51 @@
//
// Copyright 2022 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
struct MapViewErrorAlertInfoBuilder {
func build(with error: LocationSharingViewError, dimissalCallback: (() -> Void)?) -> AlertInfo<LocationSharingAlertType>? {
let alertInfo: AlertInfo<LocationSharingAlertType>?
switch error {
case .failedLoadingMap:
alertInfo = AlertInfo(id: .mapLoadingError,
title: VectorL10n.locationSharingLoadingMapErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, dimissalCallback))
case .failedLocatingUser:
alertInfo = AlertInfo(id: .userLocatingError,
title: VectorL10n.locationSharingLocatingUserErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.ok, dimissalCallback))
case .invalidLocationAuthorization:
alertInfo = AlertInfo(id: .authorizationError,
title: VectorL10n.locationSharingInvalidAuthorizationErrorTitle(AppInfo.current.displayName),
primaryButton: (VectorL10n.locationSharingInvalidAuthorizationNotNow, dimissalCallback),
secondaryButton: (VectorL10n.locationSharingInvalidAuthorizationSettings, {
if let applicationSettingsURL = URL(string:UIApplication.openSettingsURLString) {
UIApplication.shared.open(applicationSettingsURL)
}
}))
default:
alertInfo = nil
}
return alertInfo
}
}

View file

@ -36,14 +36,6 @@ class LocationSharingUITests: XCTestCase {
XCTAssertTrue(app.otherElements["Map"].exists)
}
func testInitialExistingLocation() {
goToScreenWithIdentifier(MockLocationSharingScreenState.displayExistingLocation.title)
XCTAssertTrue(app.buttons["Cancel"].exists)
XCTAssertTrue(app.buttons["LocationSharingView.shareButton"].exists)
XCTAssertTrue(app.otherElements["Map"].exists)
}
// Need a delay when showing the map otherwise the simulator breaks
private func goToScreenWithIdentifier(_ identifier: String) {
app.goToScreenWithIdentifier(identifier)

View file

@ -26,22 +26,20 @@ class LocationSharingViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
func testInitialState() {
let viewModel = buildViewModel(withLocation: false)
let viewModel = buildViewModel()
XCTAssertTrue(viewModel.context.viewState.shareButtonEnabled)
XCTAssertTrue(viewModel.context.viewState.shareButtonVisible)
XCTAssertFalse(viewModel.context.viewState.showLoadingIndicator)
XCTAssertNotNil(viewModel.context.viewState.mapStyleURL)
XCTAssertNotNil(viewModel.context.viewState.userAvatarData)
XCTAssertNil(viewModel.context.viewState.sharedAnnotation)
XCTAssertNil(viewModel.context.viewState.bindings.userLocation)
XCTAssertNil(viewModel.context.viewState.bindings.alertInfo)
}
func testCancellation() {
let viewModel = buildViewModel(withLocation: false)
let viewModel = buildViewModel()
let expectation = self.expectation(description: "Cancellation completion should be invoked")
@ -62,10 +60,9 @@ class LocationSharingViewModelTests: XCTestCase {
}
func testShareNoUserLocation() {
let viewModel = buildViewModel(withLocation: false)
let viewModel = buildViewModel()
XCTAssertNil(viewModel.context.viewState.bindings.userLocation)
XCTAssertNil(viewModel.context.viewState.sharedAnnotation)
viewModel.context.send(viewAction: .share)
@ -73,36 +70,8 @@ class LocationSharingViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.context.viewState.bindings.alertInfo?.id, .userLocatingError)
}
func testShareExistingLocation() {
let viewModel = buildViewModel(withLocation: true)
let expectation = self.expectation(description: "Share completion should be invoked")
viewModel.completion = { result in
switch result {
case .share(let latitude, let longitude, _):
XCTAssertEqual(latitude, viewModel.context.viewState.sharedAnnotation?.coordinate.latitude)
XCTAssertEqual(longitude, viewModel.context.viewState.sharedAnnotation?.coordinate.longitude)
expectation.fulfill()
case .cancel:
XCTFail()
case .shareLiveLocation(timeout: let timeout):
XCTFail()
}
}
XCTAssertNil(viewModel.context.viewState.bindings.userLocation)
XCTAssertNotNil(viewModel.context.viewState.sharedAnnotation)
viewModel.context.send(viewAction: .share)
XCTAssertNil(viewModel.context.viewState.bindings.alertInfo)
waitForExpectations(timeout: 3)
}
func testLoading() {
let viewModel = buildViewModel(withLocation: false)
let viewModel = buildViewModel()
viewModel.startLoading()
@ -116,7 +85,7 @@ class LocationSharingViewModelTests: XCTestCase {
}
func testInvalidLocationAuthorization() {
let viewModel = buildViewModel(withLocation: false)
let viewModel = buildViewModel()
viewModel.context.viewState.errorSubject.send(.invalidLocationAuthorization)
@ -124,9 +93,8 @@ class LocationSharingViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.context.viewState.bindings.alertInfo?.id, .authorizationError)
}
private func buildViewModel(withLocation: Bool) -> LocationSharingViewModel {
private func buildViewModel() -> LocationSharingViewModel {
LocationSharingViewModel(mapStyleURL: URL(string: "http://empty.com")!,
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: ""),
location: (withLocation ? CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096) : nil), coordinateType: .user)
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "", displayName: ""))
}
}

View file

@ -1,4 +1,4 @@
//
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
@ -117,12 +117,12 @@ extension LocationSharingMapView {
func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
if let userLocationAnnotation = annotation as? UserLocationAnnotation {
return LocationAnnotatonView(userLocationAnnotation: userLocationAnnotation)
return LocationAnnotationView(userLocationAnnotation: userLocationAnnotation)
} else if let pinLocationAnnotation = annotation as? PinLocationAnnotation {
return LocationAnnotatonView(pinLocationAnnotation: pinLocationAnnotation)
return LocationAnnotationView(pinLocationAnnotation: pinLocationAnnotation)
} else if annotation is MGLUserLocation && locationSharingMapView.mapCenterCoordinate == nil, let currentUserAvatarData = locationSharingMapView.userAvatarData {
// Replace default current location annotation view with a UserLocationAnnotatonView when the map is center on user location
return LocationAnnotatonView(avatarData: currentUserAvatarData)
return LocationAnnotationView(avatarData: currentUserAvatarData)
}
return nil

View file

@ -36,11 +36,9 @@ struct LocationSharingView: View {
mapView
VStack(spacing: 0) {
MapCreditsView()
if context.viewState.shareButtonVisible {
buttonsView
.background(theme.colors.background)
.clipShape(RoundedCornerShape(radius: 8, corners: [.topLeft, .topRight]))
}
buttonsView
.background(theme.colors.background)
.clipShape(RoundedCornerShape(radius: 8, corners: [.topLeft, .topRight]))
}
}
.toolbar {
@ -54,17 +52,6 @@ struct LocationSharingView: View {
.font(.headline)
.foregroundColor(theme.colors.primaryContent)
}
ToolbarItem(placement: .navigationBarTrailing) {
if context.viewState.displayExistingLocation {
Button {
context.send(viewAction: .share)
} label: {
Image(uiImage: Asset.Images.locationShareIcon.image)
.accessibilityIdentifier("LocationSharingView.shareButton")
}
.disabled(!context.viewState.shareButtonEnabled)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.introspectNavigationController { navigationController in

View file

@ -19,7 +19,7 @@ import SwiftUI
import Mapbox
@available(iOS 14, *)
class LocationAnnotatonView: MGLUserLocationAnnotationView {
class LocationAnnotationView: MGLUserLocationAnnotationView {
// MARK: Private

View file

@ -0,0 +1,88 @@
//
// Copyright 2021 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
import UIKit
import SwiftUI
import MatrixSDK
struct StaticLocationViewingCoordinatorParameters {
let mediaManager: MXMediaManager
let avatarData: AvatarInputProtocol
let location: CLLocationCoordinate2D
let coordinateType: LocationSharingCoordinateType
}
final class StaticLocationViewingCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: StaticLocationViewingCoordinatorParameters
private let staticLocationViewingHostingController: UIViewController
private var staticLocationViewingViewModel: StaticLocationViewingViewModelProtocol
private let shareLocationActivityControllerBuilder = ShareLocationActivityControllerBuilder()
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: StaticLocationViewingCoordinatorParameters) {
self.parameters = parameters
let viewModel = StaticLocationViewingViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL,
avatarData: parameters.avatarData,
location: parameters.location,
coordinateType: parameters.coordinateType)
let view = StaticLocationView(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
staticLocationViewingViewModel = viewModel
staticLocationViewingHostingController = VectorHostingController(rootView: view)
}
// MARK: - Public
func start() {
MXLog.debug("[StaticLocationSharingViewerCoordinator] did start.")
staticLocationViewingViewModel.completion = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[StaticLocationSharingViewerCoordinator] StaticLocationSharingViewerViewModel did complete with result: \(result).")
switch result {
case .close:
self.completion?()
case .share(let coordinate):
self.presentLocationActivityController(with: coordinate)
}
}
}
func toPresentable() -> UIViewController {
return self.staticLocationViewingHostingController
}
func presentLocationActivityController(with coordinate: CLLocationCoordinate2D) {
let shareActivityController = shareLocationActivityControllerBuilder.build(with: coordinate)
self.staticLocationViewingHostingController.present(shareActivityController, animated: true)
}
}

View file

@ -0,0 +1,57 @@
//
// Copyright 2021 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
import SwiftUI
import CoreLocation
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
@available(iOS 14.0, *)
enum MockStaticLocationViewingScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case showUserLocation
case showPinLocation
/// The associated screen
var screenType: Any.Type {
StaticLocationView.self
}
/// A list of screen state definitions
static var allCases: [MockStaticLocationViewingScreenState] {
return [.showUserLocation, .showPinLocation]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let location = CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096)
let coordinateType: LocationSharingCoordinateType = self == .showUserLocation ? .user : .pin
let mapStyleURL = URL(string: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx")!
let viewModel = StaticLocationViewingViewModel(mapStyleURL: mapStyleURL,
avatarData: AvatarInput(mxContentUri: "", matrixItemId: "alice:matrix.org", displayName: "Alice"),
location: location,
coordinateType: coordinateType)
return ([viewModel],
AnyView(StaticLocationView(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
)
}
}

View file

@ -0,0 +1,60 @@
//
// Copyright 2021 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
import Combine
import CoreLocation
// MARK: View model
enum StaticLocationViewingViewAction {
case close
case share
}
enum StaticLocationViewingViewModelResult {
case close
case share(_ coordinate: CLLocationCoordinate2D)
}
// MARK: View
@available(iOS 14, *)
struct StaticLocationViewingViewState: BindableState {
/// Map style URL
let mapStyleURL: URL
/// Current user avatarData
let userAvatarData: AvatarInputProtocol
/// Shared annotation to display existing location
let sharedAnnotation: LocationAnnotation
var showLoadingIndicator: Bool = false
var shareButtonEnabled: Bool {
!showLoadingIndicator
}
let errorSubject = PassthroughSubject<LocationSharingViewError, Never>()
var bindings = StaticLocationViewingViewBindings()
}
struct StaticLocationViewingViewBindings {
var alertInfo: AlertInfo<LocationSharingAlertType>?
}

View file

@ -0,0 +1,86 @@
//
// Copyright 2021 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 SwiftUI
import CoreLocation
@available(iOS 14, *)
typealias StaticLocationViewingViewModelType = StateStoreViewModel<StaticLocationViewingViewState,
Never,
StaticLocationViewingViewAction>
@available(iOS 14, *)
class StaticLocationViewingViewModel: StaticLocationViewingViewModelType, StaticLocationViewingViewModelProtocol {
// MARK: - Properties
// MARK: Private
private var mapViewErrorAlertInfoBuilder: MapViewErrorAlertInfoBuilder
// MARK: Public
var completion: ((StaticLocationViewingViewModelResult) -> Void)?
// MARK: - Setup
init(mapStyleURL: URL, avatarData: AvatarInputProtocol, location: CLLocationCoordinate2D, coordinateType: LocationSharingCoordinateType) {
let sharedAnnotation: LocationAnnotation
switch coordinateType {
case .user:
sharedAnnotation = UserLocationAnnotation(avatarData: avatarData, coordinate: location)
case .pin:
sharedAnnotation = PinLocationAnnotation(coordinate: location)
}
let viewState = StaticLocationViewingViewState(mapStyleURL: mapStyleURL,
userAvatarData: avatarData,
sharedAnnotation: sharedAnnotation)
mapViewErrorAlertInfoBuilder = MapViewErrorAlertInfoBuilder()
super.init(initialViewState: viewState)
state.errorSubject.sink { [weak self] error in
guard let self = self else { return }
self.processError(error)
}.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: StaticLocationViewingViewAction) {
switch viewAction {
case .close:
completion?(.close)
case .share:
completion?(.share(state.sharedAnnotation.coordinate))
}
}
// MARK: - Private
private func processError(_ error: LocationSharingViewError) {
guard state.bindings.alertInfo == nil else {
return
}
let alertInfo = mapViewErrorAlertInfoBuilder.build(with: error) { [weak self] in
self?.completion?(.close)
}
state.bindings.alertInfo = alertInfo
}
}

View file

@ -0,0 +1,24 @@
//
// Copyright 2021 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 StaticLocationViewingViewModelProtocol {
var completion: ((StaticLocationViewingViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
var context: StaticLocationViewingViewModelType.Context { get }
}

View file

@ -0,0 +1,39 @@
//
// Copyright 2021 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 XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class StaticLocationViewingUITests: MockScreenTest {
private var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
func testInitialExistingLocation() {
goToScreenWithIdentifier(MockStaticLocationViewingScreenState.showUserLocation.title)
XCTAssertTrue(app.buttons["Cancel"].exists)
XCTAssertTrue(app.buttons["StaticLocationView.shareButton"].exists)
XCTAssertTrue(app.otherElements["Map"].exists)
}
}

View file

@ -0,0 +1,90 @@
//
// Copyright 2021 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 XCTest
import Combine
import CoreLocation
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class StaticLocationViewingViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
func testInitialState() {
let viewModel = buildViewModel()
XCTAssertTrue(viewModel.context.viewState.shareButtonEnabled)
XCTAssertFalse(viewModel.context.viewState.showLoadingIndicator)
XCTAssertNotNil(viewModel.context.viewState.mapStyleURL)
XCTAssertNotNil(viewModel.context.viewState.userAvatarData)
XCTAssertNil(viewModel.context.viewState.bindings.alertInfo)
}
func testCancellation() {
let viewModel = buildViewModel()
let expectation = self.expectation(description: "Cancellation completion should be invoked")
viewModel.completion = { result in
switch result {
case .share:
XCTFail()
case .close:
expectation.fulfill()
}
}
viewModel.context.send(viewAction: .close)
waitForExpectations(timeout: 3)
}
func testShareExistingLocation() {
let viewModel = buildViewModel()
let expectation = self.expectation(description: "Share completion should be invoked")
viewModel.completion = { result in
switch result {
case .share(let coordinate):
XCTAssertEqual(coordinate.latitude, viewModel.context.viewState.sharedAnnotation.coordinate.latitude)
XCTAssertEqual(coordinate.longitude, viewModel.context.viewState.sharedAnnotation.coordinate.longitude)
expectation.fulfill()
case .close:
XCTFail()
}
}
XCTAssertNotNil(viewModel.context.viewState.sharedAnnotation)
viewModel.context.send(viewAction: .share)
XCTAssertNil(viewModel.context.viewState.bindings.alertInfo)
waitForExpectations(timeout: 3)
}
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)
}
}

View file

@ -0,0 +1,98 @@
//
// Copyright 2021 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 SwiftUI
@available(iOS 14.0, *)
struct StaticLocationView: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
// MARK: Public
@ObservedObject var viewModel: StaticLocationViewingViewModel.Context
// MARK: Views
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)
MapCreditsView()
}
.ignoresSafeArea(.all, edges: [.bottom])
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(VectorL10n.cancel, action: {
viewModel.send(viewAction: .close)
})
}
ToolbarItem(placement: .principal) {
Text(VectorL10n.locationSharingTitle)
.font(.headline)
.foregroundColor(theme.colors.primaryContent)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
viewModel.send(viewAction: .share)
} label: {
Image(uiImage: Asset.Images.locationShareIcon.image)
.accessibilityIdentifier("LocationSharingView.shareButton")
}
.disabled(!viewModel.viewState.shareButtonEnabled)
}
}
.navigationBarTitleDisplayMode(.inline)
.introspectNavigationController { navigationController in
ThemeService.shared().theme.applyStyle(onNavigationBar: navigationController.navigationBar)
}
.alert(item: $viewModel.alertInfo) { info in
info.alert
}
}
.accentColor(theme.colors.accent)
.activityIndicator(show: viewModel.viewState.showLoadingIndicator)
.navigationViewStyle(StackNavigationViewStyle())
}
@ViewBuilder
private var activityIndicator: some View {
if viewModel.viewState.showLoadingIndicator {
ActivityIndicator()
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct StaticLocationSharingViewer_Previews: PreviewProvider {
static let stateRenderer = MockStaticLocationViewingScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}

1
changelog.d/5982.change Normal file
View file

@ -0,0 +1 @@
Location sharing: Create a screen specific for viewing static shared location