mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
5724e65d8e
Groundwork for loading MapLibre on demand. The map view is about to move into its own framework, and both sides need to agree on the types that cross the boundary, so ShowUserLocationMode and MapLibreError move out of the app into a small framework that the app links.
322 lines
12 KiB
Swift
322 lines
12 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2023-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import CoreLocation
|
|
import Foundation
|
|
import MapLibreInterface
|
|
import MatrixRustSDK
|
|
|
|
enum LocationSharingViewAlert: Hashable {
|
|
case missingAuthorization
|
|
case missingAlwaysAuthorization
|
|
case missingLiveLocationSharingPermission
|
|
case liveLocationDisclaimer
|
|
case liveLocationDurationSelection
|
|
case mapError(MapLibreError)
|
|
}
|
|
|
|
enum LocationSharingScreenViewModelAction {
|
|
case close
|
|
case openSystemSettings
|
|
}
|
|
|
|
enum LocationSharingInteractionMode: Hashable {
|
|
case picker(shouldShowLiveLocationOption: Bool)
|
|
case viewStatic(StaticLocationData)
|
|
case viewLive(sender: TimelineItemSender?, initialLiveLocationShare: LiveLocationShare?)
|
|
|
|
var isPicker: Bool {
|
|
if case .picker = self {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var shouldShowLiveLocationOption: Bool {
|
|
if case .picker(let shouldShowLiveLocationOption) = self {
|
|
return shouldShowLiveLocationOption
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
struct LocationSharingScreenViewState: BindableState {
|
|
init(interactionMode: LocationSharingInteractionMode,
|
|
mapURLBuilder: MapTilerURLBuilderProtocol,
|
|
ownUserID: String) {
|
|
self.interactionMode = interactionMode
|
|
self.mapURLBuilder = mapURLBuilder
|
|
self.ownUserID = ownUserID
|
|
|
|
let initialProfile: UserProfile = switch interactionMode {
|
|
case .viewStatic(let locationData):
|
|
.init(sender: locationData.sender)
|
|
case .viewLive(let sender, _):
|
|
if let sender {
|
|
.init(sender: sender)
|
|
} else {
|
|
.init(userID: ownUserID)
|
|
}
|
|
case .picker:
|
|
.init(userID: ownUserID)
|
|
}
|
|
userProfiles = [initialProfile.id: initialProfile]
|
|
|
|
if case .viewLive(_, let initialLiveLocationShare) = interactionMode, let initialLiveLocationShare {
|
|
liveLocationShares = [initialLiveLocationShare]
|
|
}
|
|
|
|
bindings.showsUserLocationMode = switch interactionMode {
|
|
case .picker: .showAndFollow
|
|
case .viewStatic, .viewLive: .show
|
|
}
|
|
}
|
|
|
|
let interactionMode: LocationSharingInteractionMode
|
|
let mapURLBuilder: MapTilerURLBuilderProtocol
|
|
let ownUserID: String
|
|
var userProfiles: [String: UserProfile]
|
|
var liveLocationShares: [LiveLocationShare] = []
|
|
var isStoppingLiveLocation = false
|
|
/// Whether this device has an active live location sharing session in the room.
|
|
var isSharingLiveLocationOnThisDevice = false
|
|
|
|
var annotations: [LocationAnnotation] {
|
|
switch interactionMode {
|
|
case .viewStatic(let location):
|
|
let profile = userProfiles.values.first
|
|
let kind: LocationMarkerKind = if location.kind == .sender, let profile {
|
|
.staticUser(profile)
|
|
} else {
|
|
.pin
|
|
}
|
|
let coordinate = CLLocationCoordinate2D(latitude: location.geoURI.latitude, longitude: location.geoURI.longitude)
|
|
return [LocationAnnotation(id: kind.id, coordinate: coordinate, kind: kind)]
|
|
case .viewLive:
|
|
return liveLocationShares.compactMap { share in
|
|
guard let geoURI = share.geoURI else { return nil }
|
|
if share.userID == ownUserID, isStoppingLiveLocation {
|
|
return nil
|
|
}
|
|
|
|
let profile = userProfiles[share.userID] ?? UserProfile(userID: share.userID)
|
|
let kind = LocationMarkerKind.liveUser(profile)
|
|
let coordinate = CLLocationCoordinate2D(latitude: geoURI.latitude, longitude: geoURI.longitude)
|
|
return LocationAnnotation(id: profile.id, coordinate: coordinate, kind: kind)
|
|
}
|
|
case .picker:
|
|
return []
|
|
}
|
|
}
|
|
|
|
func isOwnUser(_ userID: String) -> Bool {
|
|
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)
|
|
|
|
/// Indicates whether the user is sharing his current location
|
|
var isSharingUserLocation: Bool {
|
|
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 {
|
|
switch interactionMode {
|
|
case .picker:
|
|
// middle point in Europe, to be used if the users location is not yet known
|
|
.init(latitude: 49.843, longitude: 9.902056)
|
|
case .viewStatic(let location):
|
|
.init(latitude: location.geoURI.latitude, longitude: location.geoURI.longitude)
|
|
case .viewLive(_, let initialLiveLocationShare):
|
|
if let initialLiveLocationShare {
|
|
.init(latitude: initialLiveLocationShare.geoURI?.latitude ?? 0,
|
|
longitude: initialLiveLocationShare.geoURI?.longitude ?? 0)
|
|
} else {
|
|
.init(latitude: 49.843, longitude: 9.902056)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Displays a loader if the user's location has not yet been determined
|
|
/// Does not work as intended on simulator.
|
|
var isLocationLoading: Bool {
|
|
if case .picker = interactionMode {
|
|
// 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
|
|
return !bindings.hasLoadedUserLocation && bindings.isLocationAuthorized != false
|
|
}
|
|
|
|
// 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 {
|
|
15.0
|
|
}
|
|
|
|
var initialZoomLevel: Double {
|
|
switch interactionMode {
|
|
case .picker:
|
|
return 2.7
|
|
case .viewStatic, .viewLive:
|
|
return 15.0
|
|
}
|
|
}
|
|
|
|
/// The marker kind used for the picker overlay (not a map annotation).
|
|
var pickerMarkerKind: LocationMarkerKind? {
|
|
guard case .picker = interactionMode else { return nil }
|
|
if let profile = userProfiles.values.first {
|
|
return isSharingUserLocation ? .staticUser(profile) : .pin
|
|
}
|
|
return .pin
|
|
}
|
|
}
|
|
|
|
struct LocationSharingScreenBindings {
|
|
var mapCenterLocation: CLLocationCoordinate2D?
|
|
var geolocationUncertainty: CLLocationAccuracy?
|
|
var showsUserLocationMode: ShowUserLocationMode
|
|
var hasLoadedUserLocation = false
|
|
var isLocationAuthorized: Bool?
|
|
|
|
/// Information describing the currently displayed alert.
|
|
var mapError: MapLibreError? {
|
|
get {
|
|
if case let .mapError(error) = alertInfo?.id {
|
|
return error
|
|
}
|
|
return nil
|
|
}
|
|
set {
|
|
alertInfo = newValue.map { AlertInfo(alertID: .mapError($0)) }
|
|
}
|
|
}
|
|
|
|
/// Information describing the currently displayed alert.
|
|
var alertInfo: AlertInfo<LocationSharingViewAlert>?
|
|
|
|
var sharedAnnotation: LocationAnnotation?
|
|
}
|
|
|
|
enum LocationSharingScreenViewAction {
|
|
case close
|
|
case selectLocation
|
|
case startLiveLocation
|
|
case centerToUser
|
|
case userDidPan
|
|
case stopLiveLocation
|
|
case setMapCenter(CLLocationCoordinate2D)
|
|
}
|
|
|
|
extension AlertInfo where T == LocationSharingViewAlert {
|
|
init(alertID: LocationSharingViewAlert,
|
|
primaryButton: AlertButton = AlertButton(title: L10n.actionOk, action: nil),
|
|
secondaryButton: AlertButton? = nil,
|
|
verticalButtons: [AlertButton]? = nil) {
|
|
switch alertID {
|
|
case .missingAuthorization:
|
|
self.init(id: alertID,
|
|
title: L10n.dialogAllowAccess,
|
|
message: L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName),
|
|
primaryButton: primaryButton,
|
|
secondaryButton: secondaryButton)
|
|
case .missingAlwaysAuthorization:
|
|
self.init(id: alertID,
|
|
title: L10n.dialogAllowAccess,
|
|
message: L10n.dialogPermissionLiveLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName),
|
|
primaryButton: primaryButton,
|
|
secondaryButton: secondaryButton)
|
|
case .missingLiveLocationSharingPermission:
|
|
self.init(id: alertID,
|
|
title: L10n.screenShareLocationLiveLocationMissingPermissions,
|
|
primaryButton: primaryButton)
|
|
case .liveLocationDisclaimer:
|
|
self.init(id: alertID,
|
|
title: L10n.screenShareLocationLiveLocationDisclaimerTitle,
|
|
primaryButton: primaryButton,
|
|
secondaryButton: secondaryButton)
|
|
case .liveLocationDurationSelection:
|
|
self.init(id: alertID,
|
|
title: L10n.screenShareLocationLiveLocationDurationPickerTitle,
|
|
primaryButton: primaryButton,
|
|
secondaryButton: nil,
|
|
verticalButtons: verticalButtons)
|
|
case .mapError(.failedLoadingMap):
|
|
self.init(id: alertID,
|
|
title: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName),
|
|
primaryButton: primaryButton,
|
|
secondaryButton: secondaryButton)
|
|
case .mapError(.failedLocatingUser):
|
|
self.init(id: alertID,
|
|
title: L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName),
|
|
primaryButton: primaryButton,
|
|
secondaryButton: secondaryButton)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum LocationMarkerKind: Equatable {
|
|
case pin
|
|
case staticUser(UserProfile)
|
|
case liveUser(UserProfile)
|
|
case placeholder
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .pin, .placeholder:
|
|
UUID().uuidString
|
|
case .staticUser(let profile), .liveUser(let profile):
|
|
profile.id
|
|
}
|
|
}
|
|
|
|
var displayName: String? {
|
|
switch self {
|
|
case .pin, .placeholder:
|
|
nil
|
|
case .staticUser(let profile), .liveUser(let profile):
|
|
profile.displayName
|
|
}
|
|
}
|
|
|
|
var userProfile: UserProfile? {
|
|
switch self {
|
|
case .pin, .placeholder:
|
|
nil
|
|
case .staticUser(let profile), .liveUser(let profile):
|
|
profile
|
|
}
|
|
}
|
|
}
|