mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
Hide user dot and track own user marker in Live Location mode. (#5707)
* added a new tracking mode for following a marker and the logic for switching such tracking mode * implemented the follow marker tracking mode * improved and added new tests
This commit is contained in:
@@ -111,6 +111,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
roomProxy: roomProxy,
|
roomProxy: roomProxy,
|
||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
liveLocationManager: flowParameters.userSession.liveLocationManager,
|
liveLocationManager: flowParameters.userSession.liveLocationManager,
|
||||||
|
appSettings: flowParameters.appSettings,
|
||||||
appMediator: flowParameters.appMediator,
|
appMediator: flowParameters.appMediator,
|
||||||
analytics: flowParameters.analytics,
|
analytics: flowParameters.analytics,
|
||||||
userIndicatorController: flowParameters.userIndicatorController,
|
userIndicatorController: flowParameters.userIndicatorController,
|
||||||
|
|||||||
@@ -1185,6 +1185,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
roomProxy: roomProxy,
|
roomProxy: roomProxy,
|
||||||
timelineController: timelineController,
|
timelineController: timelineController,
|
||||||
liveLocationManager: flowParameters.userSession.liveLocationManager,
|
liveLocationManager: flowParameters.userSession.liveLocationManager,
|
||||||
|
appSettings: flowParameters.appSettings,
|
||||||
appMediator: flowParameters.appMediator,
|
appMediator: flowParameters.appMediator,
|
||||||
analytics: flowParameters.analytics,
|
analytics: flowParameters.analytics,
|
||||||
userIndicatorController: flowParameters.userIndicatorController,
|
userIndicatorController: flowParameters.userIndicatorController,
|
||||||
|
|||||||
@@ -7564,11 +7564,6 @@ class LinkNewDeviceServiceMock: LinkNewDeviceServiceProtocol, @unchecked Sendabl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
class LiveLocationManagerMock: LiveLocationManagerProtocol, @unchecked Sendable {
|
class LiveLocationManagerMock: LiveLocationManagerProtocol, @unchecked Sendable {
|
||||||
var hasDisplayedLiveLocationDisclaimer: Bool {
|
|
||||||
get { return underlyingHasDisplayedLiveLocationDisclaimer }
|
|
||||||
set(value) { underlyingHasDisplayedLiveLocationDisclaimer = value }
|
|
||||||
}
|
|
||||||
var underlyingHasDisplayedLiveLocationDisclaimer: Bool!
|
|
||||||
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> {
|
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> {
|
||||||
get { return underlyingAuthorizationStatus }
|
get { return underlyingAuthorizationStatus }
|
||||||
set(value) { underlyingAuthorizationStatus = value }
|
set(value) { underlyingAuthorizationStatus = value }
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ extension LiveLocationManagerMock {
|
|||||||
struct Configuration {
|
struct Configuration {
|
||||||
var authorizationStatus: CLAuthorizationStatus = .notDetermined
|
var authorizationStatus: CLAuthorizationStatus = .notDetermined
|
||||||
var requestAlwaysAuthorizationIfPossibleReturnValue = true
|
var requestAlwaysAuthorizationIfPossibleReturnValue = true
|
||||||
var hasDisplayedLiveLocationDisclaimer = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(_ configuration: Configuration) {
|
convenience init(_ configuration: Configuration) {
|
||||||
@@ -23,7 +22,5 @@ extension LiveLocationManagerMock {
|
|||||||
|
|
||||||
requestAlwaysAuthorizationIfPossibleReturnValue = configuration.requestAlwaysAuthorizationIfPossibleReturnValue
|
requestAlwaysAuthorizationIfPossibleReturnValue = configuration.requestAlwaysAuthorizationIfPossibleReturnValue
|
||||||
startLiveLocationRoomIDDurationReturnValue = .success(())
|
startLiveLocationRoomIDDurationReturnValue = .success(())
|
||||||
|
|
||||||
hasDisplayedLiveLocationDisclaimer = configuration.hasDisplayedLiveLocationDisclaimer
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ struct MapLibreMapView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the center coordinate was updated externally (not by the map itself), move the map.
|
// If the center coordinate was updated externally (not by the map itself), move the map.
|
||||||
if let newCenter = mapCenterCoordinate,
|
// Not applied while following a marker, where the camera is driven by the marker's position.
|
||||||
|
if showsUserLocationMode.followedMarkerID == nil,
|
||||||
|
let newCenter = mapCenterCoordinate,
|
||||||
newCenter != context.coordinator.lastReportedCenter {
|
newCenter != context.coordinator.lastReportedCenter {
|
||||||
context.coordinator.lastReportedCenter = newCenter
|
context.coordinator.lastReportedCenter = newCenter
|
||||||
mapView.setCenter(newCenter, animated: true)
|
mapView.setCenter(newCenter, animated: true)
|
||||||
@@ -86,6 +88,8 @@ struct MapLibreMapView: UIViewRepresentable {
|
|||||||
updateAnnotations(in: mapView)
|
updateAnnotations(in: mapView)
|
||||||
|
|
||||||
showUserLocation(in: mapView)
|
showUserLocation(in: mapView)
|
||||||
|
|
||||||
|
context.coordinator.updateMarkerFollowing(in: mapView, markerID: showsUserLocationMode.followedMarkerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
@@ -162,7 +166,8 @@ struct MapLibreMapView: UIViewRepresentable {
|
|||||||
case (.show, _):
|
case (.show, _):
|
||||||
mapView.showsUserLocation = true
|
mapView.showsUserLocation = true
|
||||||
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil)
|
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil)
|
||||||
case (.hide, _):
|
case (.hide, _), (.hideAndFollowMarker, _):
|
||||||
|
// In hideAndFollowMarker mode the camera following is handled by the coordinator.
|
||||||
mapView.showsUserLocation = false
|
mapView.showsUserLocation = false
|
||||||
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil)
|
mapView.setUserTrackingMode(.none, animated: false, completionHandler: nil)
|
||||||
}
|
}
|
||||||
@@ -182,12 +187,62 @@ extension MapLibreMapView {
|
|||||||
/// so that `updateUIView` can tell apart external binding changes from internal ones.
|
/// so that `updateUIView` can tell apart external binding changes from internal ones.
|
||||||
var lastReportedCenter: CLLocationCoordinate2D?
|
var lastReportedCenter: CLLocationCoordinate2D?
|
||||||
|
|
||||||
|
/// The annotation the camera is currently locked on while in the `hideAndFollowMarker` mode.
|
||||||
|
private weak var followedAnnotation: LocationAnnotation?
|
||||||
|
/// Observes the followed annotation's coordinate, updated every frame by the `CoordinateAnimator`.
|
||||||
|
private var followedAnnotationObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
||||||
init(_ mapLibreView: MapLibreMapView) {
|
init(_ mapLibreView: MapLibreMapView) {
|
||||||
self.mapLibreView = mapLibreView
|
self.mapLibreView = mapLibreView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Marker following
|
||||||
|
|
||||||
|
/// Keeps the camera locked on the marker with the given id, or stops following when nil.
|
||||||
|
func updateMarkerFollowing(in mapView: MLNMapView, markerID: String?) {
|
||||||
|
guard let markerID else {
|
||||||
|
stopFollowingMarker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let annotation = (mapView.annotations ?? [])
|
||||||
|
.compactMap({ $0 as? LocationAnnotation })
|
||||||
|
.first(where: { $0.id == markerID }) else {
|
||||||
|
// The marker isn't on the map (yet), resolve it again on the next update.
|
||||||
|
stopFollowingMarker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard annotation !== followedAnnotation else { return }
|
||||||
|
startFollowingMarker(annotation, in: mapView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startFollowingMarker(_ annotation: LocationAnnotation, in mapView: MLNMapView) {
|
||||||
|
followedAnnotation = annotation
|
||||||
|
followedAnnotationObservation = nil
|
||||||
|
|
||||||
|
// Animate to the marker first, attaching the per-frame tracking only on completion
|
||||||
|
// so that it doesn't cancel the transition.
|
||||||
|
mapView.setCenter(annotation.coordinate,
|
||||||
|
zoomLevel: mapView.zoomLevel,
|
||||||
|
direction: -1, // negative value keeps the current direction
|
||||||
|
animated: true) { [weak self, weak annotation, weak mapView] in
|
||||||
|
guard let self, let annotation, let mapView, annotation === followedAnnotation else { return }
|
||||||
|
|
||||||
|
// Mirror every animated coordinate update to keep the camera as smooth as the marker.
|
||||||
|
followedAnnotationObservation = annotation.observe(\.coordinate) { [weak mapView] annotation, _ in
|
||||||
|
mapView?.setCenter(annotation.coordinate, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopFollowingMarker() {
|
||||||
|
followedAnnotation = nil
|
||||||
|
followedAnnotationObservation = nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - MLNMapViewDelegate
|
// MARK: - MLNMapViewDelegate
|
||||||
|
|
||||||
func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {
|
func mapView(_ mapView: MLNMapView, viewFor annotation: MLNAnnotation) -> MLNAnnotationView? {
|
||||||
@@ -231,6 +286,10 @@ extension MapLibreMapView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {
|
func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {
|
||||||
|
// While following a marker, don't flood the center binding with per-frame camera updates.
|
||||||
|
// Leaving `lastReportedCenter` untouched avoids an external re-centering when following stops.
|
||||||
|
guard mapLibreView.showsUserLocationMode.followedMarkerID == nil else { return }
|
||||||
|
|
||||||
// Avoid `Publishing changes from within view update` warnings
|
// Avoid `Publishing changes from within view update` warnings
|
||||||
DispatchQueue.main.async { [mapLibreView, weak self] in
|
DispatchQueue.main.async { [mapLibreView, weak self] in
|
||||||
let center = mapView.centerCoordinate
|
let center = mapView.centerCoordinate
|
||||||
@@ -240,22 +299,14 @@ extension MapLibreMapView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapView(_ mapView: MLNMapView, shouldChangeFrom oldCamera: MLNMapCamera, to newCamera: MLNMapCamera, reason: MLNCameraChangeReason) -> Bool {
|
func mapView(_ mapView: MLNMapView, shouldChangeFrom oldCamera: MLNMapCamera, to newCamera: MLNMapCamera, reason: MLNCameraChangeReason) -> Bool {
|
||||||
// we send the userDidPan event only for the reasons that actually will change the map center, and not zoom only / rotations only events.
|
// Send userDidPan only for gestures that actually change the map center. The reason
|
||||||
switch reason {
|
// is an option set and gestures can come combined with other reasons, so check for
|
||||||
case .gesturePan,
|
// containment instead of an exact match.
|
||||||
.gesturePinch,
|
let centerChangingGestures: MLNCameraChangeReason = [.gesturePan, .gesturePinch, .gestureRotate]
|
||||||
.gestureRotate:
|
if !reason.intersection(centerChangingGestures).isEmpty {
|
||||||
|
// Stop following immediately, the camera would fight the gesture otherwise.
|
||||||
|
stopFollowingMarker()
|
||||||
mapLibreView.userDidPan?()
|
mapLibreView.userDidPan?()
|
||||||
case .gestureOneFingerZoom,
|
|
||||||
.gestureTilt,
|
|
||||||
.gestureZoomIn,
|
|
||||||
.gestureZoomOut,
|
|
||||||
.programmatic,
|
|
||||||
.resetNorth,
|
|
||||||
.transitionCancelled:
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,22 @@ import Foundation
|
|||||||
/**
|
/**
|
||||||
Behavior mode of the current user's location, can be hidden, only shown and shown following the user
|
Behavior mode of the current user's location, can be hidden, only shown and shown following the user
|
||||||
*/
|
*/
|
||||||
enum ShowUserLocationMode {
|
enum ShowUserLocationMode: Equatable {
|
||||||
/// this mode will show the user pin in map
|
/// this mode will show the user pin in map
|
||||||
case show
|
case show
|
||||||
/// this mode will show the user pin in map and track him, panning the map automatically
|
/// this mode will show the user pin in map and track him, panning the map automatically
|
||||||
case showAndFollow
|
case showAndFollow
|
||||||
/// this mode will not show the user pin in map
|
/// this mode will not show the user pin in map
|
||||||
case hide
|
case hide
|
||||||
|
/// this mode will not show the user pin in map and will follow the marker with the given id,
|
||||||
|
/// panning the map automatically.
|
||||||
|
case hideAndFollowMarker(id: String)
|
||||||
|
|
||||||
|
/// The id of the marker the map should follow when in the `hideAndFollowMarker` mode, nil otherwise.
|
||||||
|
var followedMarkerID: String? {
|
||||||
|
guard case .hideAndFollowMarker(let id) = self else { return nil }
|
||||||
|
return id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MapLibreError: Error {
|
enum MapLibreError: Error {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct LocationSharingScreenCoordinatorParameters {
|
|||||||
let roomProxy: JoinedRoomProxyProtocol
|
let roomProxy: JoinedRoomProxyProtocol
|
||||||
let timelineController: TimelineControllerProtocol
|
let timelineController: TimelineControllerProtocol
|
||||||
let liveLocationManager: LiveLocationManagerProtocol
|
let liveLocationManager: LiveLocationManagerProtocol
|
||||||
|
let appSettings: AppSettings
|
||||||
let appMediator: AppMediatorProtocol
|
let appMediator: AppMediatorProtocol
|
||||||
let analytics: AnalyticsServiceProtocol
|
let analytics: AnalyticsServiceProtocol
|
||||||
let userIndicatorController: UserIndicatorControllerProtocol
|
let userIndicatorController: UserIndicatorControllerProtocol
|
||||||
@@ -44,6 +45,7 @@ final class LocationSharingScreenCoordinator: CoordinatorProtocol {
|
|||||||
roomProxy: parameters.roomProxy,
|
roomProxy: parameters.roomProxy,
|
||||||
timelineController: parameters.timelineController,
|
timelineController: parameters.timelineController,
|
||||||
liveLocationManager: parameters.liveLocationManager,
|
liveLocationManager: parameters.liveLocationManager,
|
||||||
|
appSettings: parameters.appSettings,
|
||||||
analytics: parameters.analytics,
|
analytics: parameters.analytics,
|
||||||
userIndicatorController: parameters.userIndicatorController,
|
userIndicatorController: parameters.userIndicatorController,
|
||||||
mediaProvider: parameters.mediaProvider)
|
mediaProvider: parameters.mediaProvider)
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ struct LocationSharingScreenViewState: BindableState {
|
|||||||
var userProfiles: [String: UserProfileProxy]
|
var userProfiles: [String: UserProfileProxy]
|
||||||
var liveLocationShares: [LiveLocationShare] = []
|
var liveLocationShares: [LiveLocationShare] = []
|
||||||
var isStoppingLiveLocation = false
|
var isStoppingLiveLocation = false
|
||||||
|
/// Whether this device has an active live location sharing session in the room.
|
||||||
|
var isSharingLiveLocationOnThisDevice = false
|
||||||
|
|
||||||
var annotations: [LocationAnnotation] {
|
var annotations: [LocationAnnotation] {
|
||||||
switch interactionMode {
|
switch interactionMode {
|
||||||
@@ -111,6 +113,17 @@ struct LocationSharingScreenViewState: BindableState {
|
|||||||
userID == ownUserID
|
userID == ownUserID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the own user has an active live location share in the room, from any of their devices.
|
||||||
|
var isOwnUserSharingLiveLocation: Bool {
|
||||||
|
liveLocationShares.contains { isOwnUser($0.userID) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the own user's live location share in the room originates from this very device,
|
||||||
|
/// requiring both the room's share and this device's sharing session to be active.
|
||||||
|
var isOwnUserSharingLiveLocationOnThisDevice: Bool {
|
||||||
|
isOwnUserSharingLiveLocation && isSharingLiveLocationOnThisDevice
|
||||||
|
}
|
||||||
|
|
||||||
var bindings = LocationSharingScreenBindings(showsUserLocationMode: .hide)
|
var bindings = LocationSharingScreenBindings(showsUserLocationMode: .hide)
|
||||||
|
|
||||||
/// Indicates whether the user is sharing his current location
|
/// Indicates whether the user is sharing his current location
|
||||||
@@ -118,6 +131,14 @@ struct LocationSharingScreenViewState: BindableState {
|
|||||||
bindings.isLocationAuthorized == true && bindings.showsUserLocationMode == .showAndFollow
|
bindings.isLocationAuthorized == true && bindings.showsUserLocationMode == .showAndFollow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the map is centered on the user, following either their dot or their own live marker.
|
||||||
|
var isMapCenteredOnUser: Bool {
|
||||||
|
if case .hideAndFollowMarker = bindings.showsUserLocationMode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isSharingUserLocation
|
||||||
|
}
|
||||||
|
|
||||||
var initialMapCenter: CLLocationCoordinate2D {
|
var initialMapCenter: CLLocationCoordinate2D {
|
||||||
switch interactionMode {
|
switch interactionMode {
|
||||||
case .picker:
|
case .picker:
|
||||||
@@ -141,12 +162,17 @@ struct LocationSharingScreenViewState: BindableState {
|
|||||||
if case .picker = interactionMode {
|
if case .picker = interactionMode {
|
||||||
// In picker mode permissions are requested immediately so returns true
|
// In picker mode permissions are requested immediately so returns true
|
||||||
// if the user's location has not yet been determined while location permissions are given or not yet set
|
// if the user's location has not yet been determined while location permissions are given or not yet set
|
||||||
!bindings.hasLoadedUserLocation && bindings.isLocationAuthorized != false
|
return !bindings.hasLoadedUserLocation && bindings.isLocationAuthorized != false
|
||||||
} else {
|
|
||||||
// In other modes permissions are requested only if the center to user button is tapped
|
|
||||||
// So we only display the loader if the user's location has not yet been determined while location permissions are given.
|
|
||||||
!bindings.hasLoadedUserLocation && bindings.isLocationAuthorized == true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The dot is hidden in favour of the own user's marker, there is no location to wait for.
|
||||||
|
if case .viewLive = interactionMode, isOwnUserSharingLiveLocationOnThisDevice {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// In other modes permissions are requested only if the center to user button is tapped
|
||||||
|
// So we only display the loader if the user's location has not yet been determined while location permissions are given.
|
||||||
|
return !bindings.hasLoadedUserLocation && bindings.isLocationAuthorized == true
|
||||||
}
|
}
|
||||||
|
|
||||||
var zoomLevel: Double {
|
var zoomLevel: Double {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
private let roomProxy: JoinedRoomProxyProtocol
|
private let roomProxy: JoinedRoomProxyProtocol
|
||||||
private let timelineController: TimelineControllerProtocol
|
private let timelineController: TimelineControllerProtocol
|
||||||
private let liveLocationManager: LiveLocationManagerProtocol
|
private let liveLocationManager: LiveLocationManagerProtocol
|
||||||
|
private let appSettings: AppSettings
|
||||||
private let analytics: AnalyticsServiceProtocol
|
private let analytics: AnalyticsServiceProtocol
|
||||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||||
private let notificationCenter: NotificationCenter
|
private let notificationCenter: NotificationCenter
|
||||||
@@ -36,6 +37,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
roomProxy: JoinedRoomProxyProtocol,
|
roomProxy: JoinedRoomProxyProtocol,
|
||||||
timelineController: TimelineControllerProtocol,
|
timelineController: TimelineControllerProtocol,
|
||||||
liveLocationManager: LiveLocationManagerProtocol,
|
liveLocationManager: LiveLocationManagerProtocol,
|
||||||
|
appSettings: AppSettings,
|
||||||
analytics: AnalyticsServiceProtocol,
|
analytics: AnalyticsServiceProtocol,
|
||||||
userIndicatorController: UserIndicatorControllerProtocol,
|
userIndicatorController: UserIndicatorControllerProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
mediaProvider: MediaProviderProtocol,
|
||||||
@@ -43,6 +45,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
self.roomProxy = roomProxy
|
self.roomProxy = roomProxy
|
||||||
self.timelineController = timelineController
|
self.timelineController = timelineController
|
||||||
self.liveLocationManager = liveLocationManager
|
self.liveLocationManager = liveLocationManager
|
||||||
|
self.appSettings = appSettings
|
||||||
self.analytics = analytics
|
self.analytics = analytics
|
||||||
self.userIndicatorController = userIndicatorController
|
self.userIndicatorController = userIndicatorController
|
||||||
self.notificationCenter = notificationCenter
|
self.notificationCenter = notificationCenter
|
||||||
@@ -74,11 +77,12 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil
|
let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil
|
||||||
Task { await sendLocation(.init(coordinate: coordinate, uncertainty: uncertainty), isUserLocation: state.isSharingUserLocation) }
|
Task { await sendLocation(.init(coordinate: coordinate, uncertainty: uncertainty), isUserLocation: state.isSharingUserLocation) }
|
||||||
case .userDidPan:
|
case .userDidPan:
|
||||||
state.bindings.showsUserLocationMode = .show
|
state.bindings.showsUserLocationMode = state.isOwnUserSharingLiveLocationOnThisDevice ? .hide : .show
|
||||||
case .centerToUser:
|
case .centerToUser:
|
||||||
switch state.bindings.isLocationAuthorized {
|
switch state.bindings.isLocationAuthorized {
|
||||||
case .some(true), .none:
|
case .some(true), .none:
|
||||||
state.bindings.showsUserLocationMode = .showAndFollow
|
// While sharing from this device the dot stays hidden, the own user's marker gets followed instead.
|
||||||
|
state.bindings.showsUserLocationMode = state.isOwnUserSharingLiveLocationOnThisDevice ? .hideAndFollowMarker(id: state.ownUserID) : .showAndFollow
|
||||||
case .some(false):
|
case .some(false):
|
||||||
let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) }
|
let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) }
|
||||||
state.bindings.alertInfo = .init(alertID: .missingAuthorization,
|
state.bindings.alertInfo = .init(alertID: .missingAuthorization,
|
||||||
@@ -88,7 +92,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
case .stopLiveLocation:
|
case .stopLiveLocation:
|
||||||
stopLiveLocation()
|
stopLiveLocation()
|
||||||
case .setMapCenter(let coordinate):
|
case .setMapCenter(let coordinate):
|
||||||
state.bindings.showsUserLocationMode = .show
|
state.bindings.showsUserLocationMode = state.isOwnUserSharingLiveLocationOnThisDevice ? .hide : .show
|
||||||
state.bindings.mapCenterLocation = coordinate
|
state.bindings.mapCenterLocation = coordinate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,9 +104,33 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
if let index = state.liveLocationShares.firstIndex(where: { $0.userID == roomProxy.ownUserID }) {
|
if let index = state.liveLocationShares.firstIndex(where: { $0.userID == roomProxy.ownUserID }) {
|
||||||
state.liveLocationShares.remove(at: index)
|
state.liveLocationShares.remove(at: index)
|
||||||
}
|
}
|
||||||
|
updateUserLocationDotVisibility()
|
||||||
Task { await liveLocationManager.stopLiveLocation(roomID: roomProxy.id) }
|
Task { await liveLocationManager.stopLiveLocation(roomID: roomProxy.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hides the user location dot while the own user is sharing their live location from this
|
||||||
|
/// device, since their marker already represents it, and restores it once the share has
|
||||||
|
/// ended or when it comes from another device of theirs.
|
||||||
|
private func updateUserLocationDotVisibility() {
|
||||||
|
if state.isOwnUserSharingLiveLocationOnThisDevice {
|
||||||
|
switch state.bindings.showsUserLocationMode {
|
||||||
|
case .show:
|
||||||
|
state.bindings.showsUserLocationMode = .hide
|
||||||
|
case .showAndFollow:
|
||||||
|
state.bindings.showsUserLocationMode = .hideAndFollowMarker(id: state.ownUserID)
|
||||||
|
case .hide, .hideAndFollowMarker:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch state.bindings.showsUserLocationMode {
|
||||||
|
case .hide, .hideAndFollowMarker:
|
||||||
|
state.bindings.showsUserLocationMode = .show
|
||||||
|
case .show, .showAndFollow:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func setupLiveLocationSubscription() async {
|
private func setupLiveLocationSubscription() async {
|
||||||
let liveLocationService = await roomProxy.makeLiveLocationService()
|
let liveLocationService = await roomProxy.makeLiveLocationService()
|
||||||
self.liveLocationService = liveLocationService
|
self.liveLocationService = liveLocationService
|
||||||
@@ -123,6 +151,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateUserProfiles(members: roomProxy.membersPublisher.value)
|
updateUserProfiles(members: roomProxy.membersPublisher.value)
|
||||||
|
updateUserLocationDotVisibility()
|
||||||
|
|
||||||
if needsCenteringOnFirstLiveLocationUpdate,
|
if needsCenteringOnFirstLiveLocationUpdate,
|
||||||
let liveLocation = state.liveLocationShares.first,
|
let liveLocation = state.liveLocationShares.first,
|
||||||
@@ -140,6 +169,16 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
appSettings.$liveLocationSharingSessionsByRoomID
|
||||||
|
.map { [roomID = roomProxy.id] sessions in sessions[roomID] != nil }
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] isSharingLiveLocationOnThisDevice in
|
||||||
|
guard let self else { return }
|
||||||
|
state.isSharingLiveLocationOnThisDevice = isSharingLiveLocationOnThisDevice
|
||||||
|
updateUserLocationDotVisibility()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
notificationCenter.publisher(for: UIApplication.didEnterBackgroundNotification)
|
notificationCenter.publisher(for: UIApplication.didEnterBackgroundNotification)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
// Let's remove the subscription if the user backgrounds the app (maybe to change their location settings)
|
// Let's remove the subscription if the user backgrounds the app (maybe to change their location settings)
|
||||||
@@ -218,14 +257,14 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showLiveLocationFlow() {
|
private func showLiveLocationFlow() {
|
||||||
if liveLocationManager.hasDisplayedLiveLocationDisclaimer {
|
if appSettings.liveLocationDisclaimerDisplayed {
|
||||||
showLiveLocationDurationPicker()
|
showLiveLocationDurationPicker()
|
||||||
} else {
|
} else {
|
||||||
state.bindings.alertInfo = .init(alertID: .liveLocationDisclaimer,
|
state.bindings.alertInfo = .init(alertID: .liveLocationDisclaimer,
|
||||||
primaryButton: .init(title: L10n.actionDecline, role: .cancel, action: nil),
|
primaryButton: .init(title: L10n.actionDecline, role: .cancel, action: nil),
|
||||||
secondaryButton: .init(title: L10n.actionAccept) { [weak self] in
|
secondaryButton: .init(title: L10n.actionAccept) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
liveLocationManager.hasDisplayedLiveLocationDisclaimer = true
|
appSettings.liveLocationDisclaimerDisplayed = true
|
||||||
// Delay so SwiftUI finishes dismissing the current alert
|
// Delay so SwiftUI finishes dismissing the current alert
|
||||||
// before presenting the next one.
|
// before presenting the next one.
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -389,11 +428,14 @@ extension LocationSharingScreenViewModel {
|
|||||||
let roomProxy = JoinedRoomProxyMock(.init(members: .allMembers, ownUserID: RoomMemberProxyMock.mockMe.userID))
|
let roomProxy = JoinedRoomProxyMock(.init(members: .allMembers, ownUserID: RoomMemberProxyMock.mockMe.userID))
|
||||||
roomProxy.makeLiveLocationServiceReturnValue = liveLocationServiceMock
|
roomProxy.makeLiveLocationServiceReturnValue = liveLocationServiceMock
|
||||||
|
|
||||||
|
let appSettings = AppSettings.volatile()
|
||||||
|
|
||||||
return LocationSharingScreenViewModel(interactionMode: interactionMode,
|
return LocationSharingScreenViewModel(interactionMode: interactionMode,
|
||||||
mapURLBuilder: AppSettings.volatile().mapTilerSettings.publisher.value,
|
mapURLBuilder: appSettings.mapTilerSettings.publisher.value,
|
||||||
roomProxy: roomProxy,
|
roomProxy: roomProxy,
|
||||||
timelineController: TimelineControllerMock(.init()),
|
timelineController: TimelineControllerMock(.init()),
|
||||||
liveLocationManager: LiveLocationManagerMock(),
|
liveLocationManager: LiveLocationManagerMock(),
|
||||||
|
appSettings: appSettings,
|
||||||
analytics: AnalyticsServiceMock(.init()),
|
analytics: AnalyticsServiceMock(.init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock(),
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
mediaProvider: MediaProviderMock(.init()))
|
mediaProvider: MediaProviderMock(.init()))
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ struct LocationSharingScreen: View {
|
|||||||
.tint(.compound.iconPrimary)
|
.tint(.compound.iconPrimary)
|
||||||
.padding(13)
|
.padding(13)
|
||||||
} else {
|
} else {
|
||||||
CompoundIcon(context.viewState.isSharingUserLocation ? \.locationNavigatorCentred : \.locationNavigator)
|
CompoundIcon(context.viewState.isMapCenteredOnUser ? \.locationNavigatorCentred : \.locationNavigator)
|
||||||
.foregroundStyle(.compound.iconPrimary)
|
.foregroundStyle(.compound.iconPrimary)
|
||||||
.padding(13)
|
.padding(13)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,15 +71,6 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
|
|||||||
|
|
||||||
// MARK: - LiveLocationManagerProtocol
|
// MARK: - LiveLocationManagerProtocol
|
||||||
|
|
||||||
var hasDisplayedLiveLocationDisclaimer: Bool {
|
|
||||||
get {
|
|
||||||
appSettings.liveLocationDisclaimerDisplayed
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
appSettings.liveLocationDisclaimerDisplayed = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func requestAlwaysAuthorizationIfPossible() -> Bool {
|
func requestAlwaysAuthorizationIfPossible() -> Bool {
|
||||||
guard !appSettings.hasRequestedLocationAlwaysLocationAuthorization else { return false }
|
guard !appSettings.hasRequestedLocationAlwaysLocationAuthorization else { return false }
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ enum LiveLocationManagerError: Error {
|
|||||||
|
|
||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
protocol LiveLocationManagerProtocol: AnyObject {
|
protocol LiveLocationManagerProtocol: AnyObject {
|
||||||
/// True if the live location disclaimer has been displayed already, will only be displayed once.
|
|
||||||
var hasDisplayedLiveLocationDisclaimer: Bool { get set }
|
|
||||||
/// Publishes the current location authorization status.
|
/// Publishes the current location authorization status.
|
||||||
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> { get }
|
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> { get }
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import Testing
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct LocationSharingScreenViewModelTests {
|
struct LocationSharingScreenViewModelTests {
|
||||||
|
private static let roomID = "!live-location-room:matrix.org"
|
||||||
|
|
||||||
private var timelineProxy: TimelineProxyMock!
|
private var timelineProxy: TimelineProxyMock!
|
||||||
|
private var appSettings: AppSettings!
|
||||||
private var viewModel: LocationSharingScreenViewModel!
|
private var viewModel: LocationSharingScreenViewModel!
|
||||||
|
|
||||||
private var context: LocationSharingScreenViewModel.Context {
|
private var context: LocationSharingScreenViewModel.Context {
|
||||||
@@ -172,6 +175,28 @@ struct LocationSharingScreenViewModelTests {
|
|||||||
#expect(context.viewState.isLocationLoading)
|
#expect(context.viewState.isLocationLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func isLocationNotLoadingWhileOwnUserSharingFromThisDevice() {
|
||||||
|
let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID)
|
||||||
|
let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare])
|
||||||
|
setupViewModelForViewLive(sender: sender,
|
||||||
|
initialShare: ownShare,
|
||||||
|
liveLocationsSubject: liveLocationsSubject,
|
||||||
|
isSharingLiveLocationFromThisDevice: true)
|
||||||
|
context.isLocationAuthorized = true
|
||||||
|
context.hasLoadedUserLocation = false
|
||||||
|
|
||||||
|
// The dot is hidden, no loader should block the center to user button.
|
||||||
|
#expect(context.showsUserLocationMode == .hide)
|
||||||
|
#expect(!context.viewState.isLocationLoading)
|
||||||
|
|
||||||
|
// Same while following the marker.
|
||||||
|
context.send(viewAction: .centerToUser)
|
||||||
|
#expect(context.showsUserLocationMode == .hideAndFollowMarker(id: RoomMemberProxyMock.mockMe.userID))
|
||||||
|
#expect(!context.viewState.isLocationLoading)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Live Location Permission Tests
|
// MARK: - Live Location Permission Tests
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -401,6 +426,7 @@ struct LocationSharingScreenViewModelTests {
|
|||||||
roomProxy: roomProxyMock,
|
roomProxy: roomProxyMock,
|
||||||
timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))),
|
timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))),
|
||||||
liveLocationManager: LiveLocationManagerMock(.init()),
|
liveLocationManager: LiveLocationManagerMock(.init()),
|
||||||
|
appSettings: appSettings,
|
||||||
analytics: AnalyticsServiceMock(.init()),
|
analytics: AnalyticsServiceMock(.init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock(),
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
mediaProvider: MediaProviderMock(.init()))
|
mediaProvider: MediaProviderMock(.init()))
|
||||||
@@ -425,32 +451,195 @@ struct LocationSharingScreenViewModelTests {
|
|||||||
#expect(context.mapCenterLocation?.longitude == -0.1)
|
#expect(context.mapCenterLocation?.longitude == -0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - User Location Dot Visibility Tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func viewLiveOwnUserSharingFromThisDeviceHidesUserLocationDot() {
|
||||||
|
let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID)
|
||||||
|
let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare])
|
||||||
|
|
||||||
|
setupViewModelForViewLive(sender: sender,
|
||||||
|
initialShare: ownShare,
|
||||||
|
liveLocationsSubject: liveLocationsSubject,
|
||||||
|
isSharingLiveLocationFromThisDevice: true)
|
||||||
|
|
||||||
|
#expect(context.viewState.isOwnUserSharingLiveLocation)
|
||||||
|
#expect(context.viewState.isOwnUserSharingLiveLocationOnThisDevice)
|
||||||
|
#expect(context.showsUserLocationMode == .hide)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func viewLiveOwnUserSharingFromAnotherDeviceKeepsUserLocationDot() {
|
||||||
|
let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID)
|
||||||
|
let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare])
|
||||||
|
|
||||||
|
// The own user has an active share in the room but there is no sharing session on this device.
|
||||||
|
setupViewModelForViewLive(sender: sender, initialShare: ownShare, liveLocationsSubject: liveLocationsSubject)
|
||||||
|
|
||||||
|
#expect(context.viewState.isOwnUserSharingLiveLocation)
|
||||||
|
#expect(!context.viewState.isOwnUserSharingLiveLocationOnThisDevice)
|
||||||
|
#expect(context.showsUserLocationMode == .show)
|
||||||
|
|
||||||
|
// The center to user button keeps its standard behaviour too.
|
||||||
|
context.isLocationAuthorized = true
|
||||||
|
context.send(viewAction: .centerToUser)
|
||||||
|
#expect(context.showsUserLocationMode == .showAndFollow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func viewLiveOtherUserSharingShowsUserLocationDot() {
|
||||||
|
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org")
|
||||||
|
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
|
||||||
|
|
||||||
|
setupViewModelForViewLive(sender: sender, initialShare: aliceShare, liveLocationsSubject: liveLocationsSubject)
|
||||||
|
|
||||||
|
#expect(!context.viewState.isOwnUserSharingLiveLocation)
|
||||||
|
#expect(context.showsUserLocationMode == .show)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func viewLiveOwnUserStartingToShareHidesUserLocationDot() async throws {
|
||||||
|
let aliceShare = makeLiveLocationShare(userID: "@alice:matrix.org")
|
||||||
|
let sender = TimelineItemSender(id: "@alice:matrix.org", displayName: "Alice")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([aliceShare])
|
||||||
|
|
||||||
|
setupViewModelForViewLive(sender: sender,
|
||||||
|
initialShare: aliceShare,
|
||||||
|
liveLocationsSubject: liveLocationsSubject,
|
||||||
|
isSharingLiveLocationFromThisDevice: true)
|
||||||
|
#expect(context.showsUserLocationMode == .show)
|
||||||
|
|
||||||
|
let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID)
|
||||||
|
let deferred = deferFulfillment(context.observe(\.showsUserLocationMode)) { $0 == .hide }
|
||||||
|
liveLocationsSubject.send([aliceShare, ownShare])
|
||||||
|
try await deferred.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func viewLiveOwnUserShareEndingRestoresUserLocationDot() async throws {
|
||||||
|
let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID)
|
||||||
|
let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare])
|
||||||
|
|
||||||
|
setupViewModelForViewLive(sender: sender,
|
||||||
|
initialShare: ownShare,
|
||||||
|
liveLocationsSubject: liveLocationsSubject,
|
||||||
|
isSharingLiveLocationFromThisDevice: true)
|
||||||
|
#expect(context.showsUserLocationMode == .hide)
|
||||||
|
|
||||||
|
let deferred = deferFulfillment(context.observe(\.showsUserLocationMode)) { $0 == .show }
|
||||||
|
liveLocationsSubject.send([])
|
||||||
|
try await deferred.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func viewLiveAnotherDeviceTakingOverShareRestoresUserLocationDot() async throws {
|
||||||
|
let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID)
|
||||||
|
let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare])
|
||||||
|
|
||||||
|
setupViewModelForViewLive(sender: sender,
|
||||||
|
initialShare: ownShare,
|
||||||
|
liveLocationsSubject: liveLocationsSubject,
|
||||||
|
isSharingLiveLocationFromThisDevice: true)
|
||||||
|
#expect(context.showsUserLocationMode == .hide)
|
||||||
|
|
||||||
|
// Another device takes over: this device's session is removed and a new own share arrives.
|
||||||
|
appSettings.liveLocationSharingSessionsByRoomID.removeValue(forKey: Self.roomID)
|
||||||
|
let newOwnShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID, latitude: 48.8, longitude: 2.3)
|
||||||
|
|
||||||
|
let deferred = deferFulfillment(context.observe(\.showsUserLocationMode)) { $0 == .show }
|
||||||
|
liveLocationsSubject.send([newOwnShare])
|
||||||
|
try await deferred.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func panningAndCenteringWhileOwnUserSharingTogglesHiddenModes() {
|
||||||
|
let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID)
|
||||||
|
let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare])
|
||||||
|
|
||||||
|
setupViewModelForViewLive(sender: sender,
|
||||||
|
initialShare: ownShare,
|
||||||
|
liveLocationsSubject: liveLocationsSubject,
|
||||||
|
isSharingLiveLocationFromThisDevice: true)
|
||||||
|
context.isLocationAuthorized = true
|
||||||
|
#expect(context.showsUserLocationMode == .hide)
|
||||||
|
#expect(!context.viewState.isMapCenteredOnUser)
|
||||||
|
|
||||||
|
// Centering follows the own user's marker instead of the hidden dot.
|
||||||
|
context.send(viewAction: .centerToUser)
|
||||||
|
#expect(context.showsUserLocationMode == .hideAndFollowMarker(id: RoomMemberProxyMock.mockMe.userID))
|
||||||
|
#expect(context.viewState.isMapCenteredOnUser)
|
||||||
|
|
||||||
|
context.send(viewAction: .userDidPan)
|
||||||
|
#expect(context.showsUserLocationMode == .hide)
|
||||||
|
#expect(!context.viewState.isMapCenteredOnUser)
|
||||||
|
|
||||||
|
context.send(viewAction: .centerToUser)
|
||||||
|
#expect(context.showsUserLocationMode == .hideAndFollowMarker(id: RoomMemberProxyMock.mockMe.userID))
|
||||||
|
#expect(context.viewState.isMapCenteredOnUser)
|
||||||
|
|
||||||
|
context.send(viewAction: .setMapCenter(.init(latitude: 51.5, longitude: -0.1)))
|
||||||
|
#expect(context.showsUserLocationMode == .hide)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func stopLiveLocationRestoresUserLocationDot() {
|
||||||
|
let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID)
|
||||||
|
let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare])
|
||||||
|
|
||||||
|
setupViewModelForViewLive(sender: sender,
|
||||||
|
initialShare: ownShare,
|
||||||
|
liveLocationsSubject: liveLocationsSubject,
|
||||||
|
isSharingLiveLocationFromThisDevice: true)
|
||||||
|
#expect(context.showsUserLocationMode == .hide)
|
||||||
|
|
||||||
|
context.send(viewAction: .stopLiveLocation)
|
||||||
|
#expect(context.showsUserLocationMode == .show)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
mutating func stopLiveLocationWhileFollowingMarkerRestoresUserLocationDot() {
|
||||||
|
let ownShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID)
|
||||||
|
let sender = TimelineItemSender(id: RoomMemberProxyMock.mockMe.userID, displayName: "Me")
|
||||||
|
let liveLocationsSubject = CurrentValueSubject<[LiveLocationShare], Never>([ownShare])
|
||||||
|
|
||||||
|
setupViewModelForViewLive(sender: sender,
|
||||||
|
initialShare: ownShare,
|
||||||
|
liveLocationsSubject: liveLocationsSubject,
|
||||||
|
isSharingLiveLocationFromThisDevice: true)
|
||||||
|
context.isLocationAuthorized = true
|
||||||
|
|
||||||
|
context.send(viewAction: .centerToUser)
|
||||||
|
#expect(context.showsUserLocationMode == .hideAndFollowMarker(id: RoomMemberProxyMock.mockMe.userID))
|
||||||
|
|
||||||
|
// Stopping the share restores the dot without following it.
|
||||||
|
context.send(viewAction: .stopLiveLocation)
|
||||||
|
#expect(context.showsUserLocationMode == .show)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init(),
|
private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init(),
|
||||||
members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
|
members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
|
||||||
let appSettings = AppSettings.volatile()
|
setupViewModel(liveLocationManagerMock: LiveLocationManagerMock(liveLocationManagerConfiguration), members: members)
|
||||||
timelineProxy = TimelineProxyMock(.init())
|
|
||||||
viewModel = LocationSharingScreenViewModel(interactionMode: .picker(shouldShowLiveLocationOption: true),
|
|
||||||
mapURLBuilder: appSettings.mapTilerSettings.publisher.value,
|
|
||||||
roomProxy: JoinedRoomProxyMock(.init(members: members)),
|
|
||||||
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)),
|
|
||||||
liveLocationManager: LiveLocationManagerMock(liveLocationManagerConfiguration),
|
|
||||||
analytics: AnalyticsServiceMock(.init()),
|
|
||||||
userIndicatorController: UserIndicatorControllerMock(),
|
|
||||||
mediaProvider: MediaProviderMock(.init()))
|
|
||||||
viewModel.state.bindings.isLocationAuthorized = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock,
|
private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock,
|
||||||
members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
|
members: [RoomMemberProxyMock] = .allMembersAsAdmin) {
|
||||||
let appSettings = AppSettings.volatile()
|
appSettings = AppSettings.volatile()
|
||||||
timelineProxy = TimelineProxyMock(.init())
|
timelineProxy = TimelineProxyMock(.init())
|
||||||
viewModel = LocationSharingScreenViewModel(interactionMode: .picker(shouldShowLiveLocationOption: true),
|
viewModel = LocationSharingScreenViewModel(interactionMode: .picker(shouldShowLiveLocationOption: true),
|
||||||
mapURLBuilder: appSettings.mapTilerSettings.publisher.value,
|
mapURLBuilder: appSettings.mapTilerSettings.publisher.value,
|
||||||
roomProxy: JoinedRoomProxyMock(.init(members: members)),
|
roomProxy: JoinedRoomProxyMock(.init(members: members)),
|
||||||
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)),
|
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)),
|
||||||
liveLocationManager: liveLocationManagerMock,
|
liveLocationManager: liveLocationManagerMock,
|
||||||
|
appSettings: appSettings,
|
||||||
analytics: AnalyticsServiceMock(.init()),
|
analytics: AnalyticsServiceMock(.init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock(),
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
mediaProvider: MediaProviderMock(.init()))
|
mediaProvider: MediaProviderMock(.init()))
|
||||||
@@ -460,12 +649,17 @@ struct LocationSharingScreenViewModelTests {
|
|||||||
private mutating func setupViewModelForViewLive(sender: TimelineItemSender,
|
private mutating func setupViewModelForViewLive(sender: TimelineItemSender,
|
||||||
initialShare: LiveLocationShare,
|
initialShare: LiveLocationShare,
|
||||||
liveLocationsSubject: CurrentValueSubject<[LiveLocationShare], Never>,
|
liveLocationsSubject: CurrentValueSubject<[LiveLocationShare], Never>,
|
||||||
members: [RoomMemberProxyMock] = .allMembers) {
|
members: [RoomMemberProxyMock] = .allMembers,
|
||||||
let appSettings = AppSettings.volatile()
|
isSharingLiveLocationFromThisDevice: Bool = false) {
|
||||||
|
appSettings = AppSettings.volatile()
|
||||||
|
if isSharingLiveLocationFromThisDevice {
|
||||||
|
appSettings.liveLocationSharingSessionsByRoomID[Self.roomID] = .init(eventID: "$event:matrix.org", expirationDate: .distantFuture)
|
||||||
|
}
|
||||||
|
|
||||||
let liveLocationServiceMock = RoomLiveLocationServiceMock()
|
let liveLocationServiceMock = RoomLiveLocationServiceMock()
|
||||||
liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher()
|
liveLocationServiceMock.liveLocationsPublisher = liveLocationsSubject.asCurrentValuePublisher()
|
||||||
|
|
||||||
let roomProxyMock = JoinedRoomProxyMock(.init(members: members))
|
let roomProxyMock = JoinedRoomProxyMock(.init(id: Self.roomID, members: members))
|
||||||
roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock
|
roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock
|
||||||
|
|
||||||
viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare),
|
viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare),
|
||||||
@@ -473,6 +667,7 @@ struct LocationSharingScreenViewModelTests {
|
|||||||
roomProxy: roomProxyMock,
|
roomProxy: roomProxyMock,
|
||||||
timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))),
|
timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))),
|
||||||
liveLocationManager: LiveLocationManagerMock(.init()),
|
liveLocationManager: LiveLocationManagerMock(.init()),
|
||||||
|
appSettings: appSettings,
|
||||||
analytics: AnalyticsServiceMock(.init()),
|
analytics: AnalyticsServiceMock(.init()),
|
||||||
userIndicatorController: UserIndicatorControllerMock(),
|
userIndicatorController: UserIndicatorControllerMock(),
|
||||||
mediaProvider: MediaProviderMock(.init()))
|
mediaProvider: MediaProviderMock(.init()))
|
||||||
|
|||||||
Reference in New Issue
Block a user