Files
element-x-ios/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenModels.swift
T
Stefan Ceriu 5724e65d8e Move the shared map types into a new MapLibreInterface framework
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.
2026-08-12 15:04:24 +03:00

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
}
}
}