mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
Refactor and redesign Location Sharing Screen (#5155)
* refactored the static location screen to the location sharing screen # Conflicts: # UnitTests/Sources/LocationSharingScreenViewModelTests.swift # Conflicts: # ElementX.xcodeproj/project.pbxproj * implemented a custom pin with an overlayable view * implemented the render of the user when the location is sender instead of the pin type * removed description and body they are not used at all. * reimplemented single button for sharing this or user location + implemented an experimental way to update annotations * removed unnecessary @Suite description * implemented a way to display the alert on top of the sheet and added a spinner to the center user location button * fixed alerts strings * fixed a failing test * improved preview tests
This commit is contained in:
@@ -64,6 +64,8 @@ final class AppSettings {
|
||||
|
||||
case elementCallBaseURLOverride
|
||||
|
||||
case voiceMessagePlaybackSpeed
|
||||
|
||||
// Feature flags
|
||||
case publicSearchEnabled
|
||||
case fuzzyRoomListSearchEnabled
|
||||
@@ -76,9 +78,8 @@ final class AppSettings {
|
||||
case linkPreviewsEnabled
|
||||
case focusEventOnNotificationTap
|
||||
case linkNewDeviceEnabled
|
||||
case liveLocationSharingEnabled
|
||||
|
||||
case voiceMessagePlaybackSpeed
|
||||
|
||||
// Doug's tweaks 🔧
|
||||
case hideUnreadMessagesBadge
|
||||
case hideQuietNotificationAlerts
|
||||
@@ -432,6 +433,9 @@ final class AppSettings {
|
||||
@UserPreference(key: UserDefaultsKeys.linkNewDeviceEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var linkNewDeviceEnabled
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.liveLocationSharingEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var liveLocationSharingEnabled
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.developerOptionsEnabled, defaultValue: appBuildType == .debug, storageType: .userDefaults(store))
|
||||
var developerOptionsEnabled
|
||||
}
|
||||
|
||||
@@ -85,8 +85,10 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
|
||||
actionsSubject.send(.finished)
|
||||
case .displayUser(let userID):
|
||||
actionsSubject.send(.displayUser(userID: userID))
|
||||
case .presentLocationViewer(let geoURI, let description):
|
||||
presentMapNavigator(geoURI: geoURI, description: description, timelineController: timelineController)
|
||||
case .presentLocationViewer(let senderID, let geoURI):
|
||||
presentMapNavigator(senderID: senderID,
|
||||
geoURI: geoURI,
|
||||
timelineController: timelineController)
|
||||
case .displayMessageForwarding(let forwardingItem):
|
||||
presentMessageForwarding(with: forwardingItem)
|
||||
case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID):
|
||||
@@ -98,16 +100,22 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
}
|
||||
|
||||
private func presentMapNavigator(geoURI: GeoURI, description: String?, timelineController: TimelineControllerProtocol) {
|
||||
private func presentMapNavigator(senderID: String?,
|
||||
geoURI: GeoURI,
|
||||
timelineController: TimelineControllerProtocol) {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let params = StaticLocationScreenCoordinatorParameters(interactionMode: .viewOnly(geoURI: geoURI, description: description),
|
||||
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
|
||||
timelineController: timelineController,
|
||||
appMediator: flowParameters.appMediator,
|
||||
analytics: flowParameters.analytics,
|
||||
userIndicatorController: flowParameters.userIndicatorController)
|
||||
let coordinator = StaticLocationScreenCoordinator(parameters: params)
|
||||
let params = LocationSharingScreenCoordinatorParameters(interactionMode: .viewStatic(senderID: senderID,
|
||||
geoURI: geoURI),
|
||||
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled,
|
||||
roomProxy: roomProxy,
|
||||
timelineController: timelineController,
|
||||
appMediator: flowParameters.appMediator,
|
||||
analytics: flowParameters.analytics,
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
mediaProvider: flowParameters.userSession.mediaProvider)
|
||||
let coordinator = LocationSharingScreenCoordinator(parameters: params)
|
||||
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -703,8 +703,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .presentPollForm(let mode):
|
||||
stateMachine.tryEvent(.presentPollForm(mode: mode),
|
||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||
case .presentLocationViewer(_, let geoURI, let description):
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewOnly(geoURI: geoURI, description: description)),
|
||||
case .presentLocationViewer(let senderID, let geoURI):
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewStatic(senderID: senderID, geoURI: geoURI)),
|
||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||
case .presentRoomMemberDetails(userID: let userID):
|
||||
stateMachine.tryEvent(.startMembersFlow(entryPoint: .roomMember(userID: userID)))
|
||||
@@ -788,10 +788,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .presentPollForm(let mode):
|
||||
stateMachine.tryEvent(.presentPollForm(mode: mode),
|
||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||
case .presentLocationViewer(_, let geoURI, let description):
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewOnly(geoURI: geoURI,
|
||||
description: description)),
|
||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||
case .presentLocationViewer(let senderID, let geoURI):
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewStatic(senderID: senderID,
|
||||
geoURI: geoURI)),
|
||||
userInfo: EventUserInfo(animated: animated,
|
||||
timelineController: timelineController))
|
||||
case .presentEmojiPicker(let itemID, let selectedEmojis):
|
||||
stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis),
|
||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||
@@ -1128,18 +1129,21 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMapNavigator(interactionMode: StaticLocationInteractionMode,
|
||||
private func presentMapNavigator(interactionMode: LocationSharingInteractionMode,
|
||||
timelineController: TimelineControllerProtocol,
|
||||
animated: Bool) {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let params = StaticLocationScreenCoordinatorParameters(interactionMode: interactionMode,
|
||||
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
|
||||
timelineController: timelineController,
|
||||
appMediator: flowParameters.appMediator,
|
||||
analytics: flowParameters.analytics,
|
||||
userIndicatorController: flowParameters.userIndicatorController)
|
||||
let coordinator = StaticLocationScreenCoordinator(parameters: params)
|
||||
let params = LocationSharingScreenCoordinatorParameters(interactionMode: interactionMode,
|
||||
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: flowParameters.appSettings.liveLocationSharingEnabled,
|
||||
roomProxy: roomProxy,
|
||||
timelineController: timelineController,
|
||||
appMediator: flowParameters.appMediator,
|
||||
analytics: flowParameters.analytics,
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
mediaProvider: flowParameters.userSession.mediaProvider)
|
||||
let coordinator = LocationSharingScreenCoordinator(parameters: params)
|
||||
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -144,7 +144,7 @@ extension RoomFlowCoordinator {
|
||||
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case dismissEmojiPicker
|
||||
|
||||
case presentMapNavigator(interactionMode: StaticLocationInteractionMode)
|
||||
case presentMapNavigator(interactionMode: LocationSharingInteractionMode)
|
||||
case dismissMapNavigator
|
||||
|
||||
case presentMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||
|
||||
@@ -116,6 +116,8 @@ internal enum L10n {
|
||||
internal static var a11yShowPassword: String { return L10n.tr("Localizable", "a11y_show_password") }
|
||||
/// Start a call
|
||||
internal static var a11yStartCall: String { return L10n.tr("Localizable", "a11y_start_call") }
|
||||
/// Start a voice call
|
||||
internal static var a11yStartVoiceCall: String { return L10n.tr("Localizable", "a11y_start_voice_call") }
|
||||
/// Tombstoned room
|
||||
internal static var a11yTombstonedRoom: String { return L10n.tr("Localizable", "a11y_tombstoned_room") }
|
||||
/// User menu
|
||||
@@ -584,6 +586,8 @@ internal enum L10n {
|
||||
internal static var commonOpticIdIos: String { return L10n.tr("Localizable", "common_optic_id_ios") }
|
||||
/// or
|
||||
internal static var commonOr: String { return L10n.tr("Localizable", "common_or") }
|
||||
/// Other options
|
||||
internal static var commonOtherOptions: String { return L10n.tr("Localizable", "common_other_options") }
|
||||
/// Password
|
||||
internal static var commonPassword: String { return L10n.tr("Localizable", "common_password") }
|
||||
/// People
|
||||
@@ -908,8 +912,10 @@ internal enum L10n {
|
||||
internal static var dialogPermissionCamera: String { return L10n.tr("Localizable", "dialog_permission_camera") }
|
||||
/// Please grant the permission in the system settings.
|
||||
internal static var dialogPermissionGeneric: String { return L10n.tr("Localizable", "dialog_permission_generic") }
|
||||
/// To share your current location, Element needs location access. Go to Settings > Location.
|
||||
internal static var dialogPermissionLocationDescriptionIos: String { return L10n.tr("Localizable", "dialog_permission_location_description_ios") }
|
||||
/// To share your current location, %1$@ needs location access. Go to Settings > Location.
|
||||
internal static func dialogPermissionLocationDescriptionIos(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "dialog_permission_location_description_ios", String(describing: p1))
|
||||
}
|
||||
/// %1$@ does not have access to your location.
|
||||
internal static func dialogPermissionLocationTitleIos(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "dialog_permission_location_title_ios", String(describing: p1))
|
||||
@@ -1062,6 +1068,8 @@ internal enum L10n {
|
||||
internal static func notificationFallbackNContent(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "notification_fallback_n_content", p1)
|
||||
}
|
||||
/// 📞 Incoming call
|
||||
internal static var notificationIncomingAudioCall: String { return L10n.tr("Localizable", "notification_incoming_audio_call") }
|
||||
/// 📹 Incoming call
|
||||
internal static var notificationIncomingCall: String { return L10n.tr("Localizable", "notification_incoming_call") }
|
||||
/// ** Failed to send - please open room
|
||||
@@ -2238,6 +2246,8 @@ internal enum L10n {
|
||||
internal static var screenOnboardingSignInWithQrCode: String { return L10n.tr("Localizable", "screen_onboarding_sign_in_with_qr_code") }
|
||||
/// Create account
|
||||
internal static var screenOnboardingSignUp: String { return L10n.tr("Localizable", "screen_onboarding_sign_up") }
|
||||
/// Welcome back
|
||||
internal static var screenOnboardingWelcomeBack: String { return L10n.tr("Localizable", "screen_onboarding_welcome_back") }
|
||||
/// Welcome to the fastest %1$@ ever. Supercharged for speed and simplicity.
|
||||
internal static func screenOnboardingWelcomeMessage(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "screen_onboarding_welcome_message", String(describing: p1))
|
||||
@@ -3157,7 +3167,7 @@ internal enum L10n {
|
||||
internal static var screenShareOpenGoogleMaps: String { return L10n.tr("Localizable", "screen_share_open_google_maps") }
|
||||
/// Open in OpenStreetMap
|
||||
internal static var screenShareOpenOsmMaps: String { return L10n.tr("Localizable", "screen_share_open_osm_maps") }
|
||||
/// Share this location
|
||||
/// Share pinned location
|
||||
internal static var screenShareThisLocationAction: String { return L10n.tr("Localizable", "screen_share_this_location_action") }
|
||||
/// You’ve changed your password on another session
|
||||
internal static var screenSignedOutReason1: String { return L10n.tr("Localizable", "screen_signed_out_reason_1") }
|
||||
|
||||
@@ -84,55 +84,38 @@ enum UserAvatarSizeOnScreen {
|
||||
case sendInviteConfirmation
|
||||
case sessionVerification
|
||||
case threadSummary
|
||||
case map
|
||||
|
||||
var value: CGFloat {
|
||||
switch self {
|
||||
case .chats, .spaces:
|
||||
return 32
|
||||
case .timeline:
|
||||
return 32
|
||||
case .readReceipt:
|
||||
return 16
|
||||
case .readReceiptSheet:
|
||||
return 32
|
||||
16
|
||||
case .spaceHeader:
|
||||
return 20
|
||||
case .completionSuggestions:
|
||||
return 32
|
||||
case .blockedUsers:
|
||||
return 32
|
||||
case .settings:
|
||||
return 52
|
||||
case .roomDetails:
|
||||
return 44
|
||||
case .roomMembersList:
|
||||
return 32
|
||||
case .roomChangeRoles:
|
||||
return 56
|
||||
case .startChat:
|
||||
return 36
|
||||
case .memberDetails:
|
||||
return 96
|
||||
case .inviteUsers:
|
||||
return 52
|
||||
case .editUserDetails:
|
||||
return 96
|
||||
case .dmDetails:
|
||||
return 75
|
||||
case .knockingUsersBannerStack:
|
||||
return 28
|
||||
case .knockingUserBanner:
|
||||
return 32
|
||||
case .knockingUserList:
|
||||
return 52
|
||||
case .mediaPreviewDetails:
|
||||
return 32
|
||||
case .sendInviteConfirmation:
|
||||
return 64
|
||||
case .sessionVerification:
|
||||
return 52
|
||||
20
|
||||
case .threadSummary:
|
||||
return 24
|
||||
24
|
||||
case .knockingUsersBannerStack:
|
||||
28
|
||||
case .chats, .spaces, .map,
|
||||
.timeline, .readReceiptSheet, .completionSuggestions,
|
||||
.blockedUsers, .roomMembersList, .knockingUserBanner,
|
||||
.mediaPreviewDetails:
|
||||
32
|
||||
case .startChat:
|
||||
36
|
||||
case .roomDetails:
|
||||
44
|
||||
case .inviteUsers, .knockingUserList, .sessionVerification,
|
||||
.settings:
|
||||
52
|
||||
case .roomChangeRoles:
|
||||
56
|
||||
case .sendInviteConfirmation:
|
||||
64
|
||||
case .dmDetails:
|
||||
75
|
||||
case .memberDetails, .editUserDetails:
|
||||
96
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,24 +143,22 @@ enum RoomAvatarSizeOnScreen {
|
||||
|
||||
var value: CGFloat {
|
||||
switch self {
|
||||
case .chats, .spaces, .spaceSettings:
|
||||
return 52
|
||||
case .notificationSettings:
|
||||
30
|
||||
case .timeline, .leaveSpace, .roomDirectorySearch,
|
||||
.completionSuggestions, .authorizedSpaces, .createRoomSelectSpace,
|
||||
.spaceFilters:
|
||||
return 32
|
||||
case .notificationSettings:
|
||||
return 30
|
||||
case .messageForwarding, .globalSearch, .roomSelection, .spaceAddRooms:
|
||||
return 36
|
||||
case .spaceAddRoomsSelected:
|
||||
return 52
|
||||
32
|
||||
case .messageForwarding, .globalSearch, .roomSelection,
|
||||
.spaceAddRooms:
|
||||
36
|
||||
case .chats, .spaces, .spaceSettings,
|
||||
.spaceAddRoomsSelected:
|
||||
52
|
||||
case .joinRoom, .spaceHeader:
|
||||
64
|
||||
case .details:
|
||||
return 96
|
||||
case .joinRoom:
|
||||
return 64
|
||||
case .spaceHeader:
|
||||
return 64
|
||||
96
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,23 +11,32 @@ import MapLibre
|
||||
import SwiftUI
|
||||
|
||||
final class LocationAnnotation: NSObject, MLNAnnotation {
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
let id: String
|
||||
var coordinate: CLLocationCoordinate2D
|
||||
let anchorPoint: CGPoint
|
||||
let view: AnyView
|
||||
var view: AnyView
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(coordinate: CLLocationCoordinate2D,
|
||||
init(id: String = UUID().uuidString,
|
||||
coordinate: CLLocationCoordinate2D,
|
||||
anchorPoint: CGPoint = .init(x: 0.5, y: 0.5),
|
||||
@ViewBuilder label: () -> some View) {
|
||||
self.id = id
|
||||
self.coordinate = coordinate
|
||||
self.anchorPoint = anchorPoint
|
||||
view = AnyView(label())
|
||||
super.init()
|
||||
}
|
||||
|
||||
func updateView(@ViewBuilder label: () -> some View) {
|
||||
view = AnyView(label())
|
||||
}
|
||||
}
|
||||
|
||||
final class LocationAnnotationView: MLNUserLocationAnnotationView {
|
||||
private var hostingController: UIHostingController<AnyView>?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override init(annotation: MLNAnnotation?, reuseIdentifier: String?) {
|
||||
@@ -37,12 +46,21 @@ final class LocationAnnotationView: MLNUserLocationAnnotationView {
|
||||
|
||||
convenience init(annotation: LocationAnnotation) {
|
||||
self.init(annotation: annotation, reuseIdentifier: "\(Self.self)")
|
||||
let view: UIView = UIHostingController(rootView: annotation.view).view
|
||||
let hostingController = UIHostingController(rootView: annotation.view)
|
||||
self.hostingController = hostingController
|
||||
let view: UIView = hostingController.view
|
||||
view.backgroundColor = .clear
|
||||
view.anchorPoint = annotation.anchorPoint
|
||||
addSubview(view)
|
||||
view.bounds.size = view.intrinsicContentSize
|
||||
}
|
||||
|
||||
func updateContent(with view: AnyView) {
|
||||
hostingController?.rootView = view
|
||||
if let hostedView = hostingController?.view {
|
||||
hostedView.bounds.size = hostedView.intrinsicContentSize
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
|
||||
@@ -20,10 +20,10 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
/// The initial map center
|
||||
let mapCenter: CLLocationCoordinate2D
|
||||
|
||||
/// Map annotations
|
||||
let annotations: [LocationAnnotation]
|
||||
/// Map annotations keyed by a stable identifier (e.g. sender userID for user pins, UUID string for generic pins)
|
||||
let annotations: [String: LocationAnnotation]
|
||||
|
||||
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [LocationAnnotation] = []) {
|
||||
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [String: LocationAnnotation] = [:]) {
|
||||
self.zoomLevel = zoomLevel
|
||||
self.initialZoomLevel = initialZoomLevel
|
||||
self.mapCenter = mapCenter
|
||||
@@ -45,6 +45,7 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
@Binding var error: MapLibreError?
|
||||
/// Coordinate of the center of the map
|
||||
@Binding var mapCenterCoordinate: CLLocationCoordinate2D?
|
||||
@Binding var hasLoadedUserLocation: Bool
|
||||
@Binding var isLocationAuthorized: Bool?
|
||||
/// The radius of uncertainty for the location, measured in meters.
|
||||
@Binding var geolocationUncertainty: CLLocationAccuracy?
|
||||
@@ -70,6 +71,11 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
mapView.styleURL = dynamicMapURL
|
||||
}
|
||||
|
||||
// Update existing annotation views with fresh SwiftUI content.
|
||||
// This handles the case where the annotation's view data changes after
|
||||
// the annotation was initially placed (e.g. user avatar loads asynchronously).
|
||||
updateAnnotations(in: mapView)
|
||||
|
||||
showUserLocation(in: mapView)
|
||||
}
|
||||
|
||||
@@ -80,17 +86,52 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
// MARK: - Private
|
||||
|
||||
private func setupMap(mapView: MLNMapView, with options: Options) {
|
||||
mapView.addAnnotations(options.annotations)
|
||||
mapView.addAnnotations(Array(options.annotations.values))
|
||||
mapView.zoomLevel = options.annotations.isEmpty ? options.initialZoomLevel : options.zoomLevel
|
||||
mapView.centerCoordinate = options.mapCenter
|
||||
}
|
||||
|
||||
private func updateAnnotations(in mapView: MLNMapView) {
|
||||
let existingByID = Dictionary(uniqueKeysWithValues:
|
||||
(mapView.annotations ?? []).compactMap { $0 as? LocationAnnotation }.map { ($0.id, $0) })
|
||||
|
||||
let existingIDs = Set(existingByID.keys)
|
||||
let updatedIDs = Set(options.annotations.keys)
|
||||
|
||||
// Remove annotations that are no longer present
|
||||
let removedIDs = existingIDs.subtracting(updatedIDs)
|
||||
if !removedIDs.isEmpty {
|
||||
let toRemove = removedIDs.compactMap { existingByID[$0] }
|
||||
mapView.removeAnnotations(toRemove)
|
||||
}
|
||||
|
||||
// Add new annotations
|
||||
let addedIDs = updatedIDs.subtracting(existingIDs)
|
||||
if !addedIDs.isEmpty {
|
||||
let toAdd = addedIDs.compactMap { options.annotations[$0] }
|
||||
mapView.addAnnotations(toAdd)
|
||||
}
|
||||
|
||||
// Update existing annotations that are still present
|
||||
let keptIDs = existingIDs.intersection(updatedIDs)
|
||||
for id in keptIDs {
|
||||
guard let existingAnnotation = existingByID[id],
|
||||
let updatedAnnotation = options.annotations[id] else {
|
||||
continue
|
||||
}
|
||||
existingAnnotation.coordinate = updatedAnnotation.coordinate
|
||||
if let annotationView = mapView.view(for: existingAnnotation) as? LocationAnnotationView {
|
||||
annotationView.updateContent(with: updatedAnnotation.view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMapView() -> MLNMapView {
|
||||
let mapView = MLNMapView(frame: .zero, styleURL: mapURLBuilder.interactiveMapURL(for: colorScheme == .dark ? .dark : .light))
|
||||
mapView.logoViewPosition = .topLeft
|
||||
mapView.attributionButtonPosition = .topLeft
|
||||
mapView.attributionButtonMargins = .init(x: mapView.logoView.frame.maxX + 8, y: mapView.logoView.center.y / 2)
|
||||
mapView.tintColor = .black
|
||||
mapView.tintColor = .compound.iconAccentPrimary
|
||||
mapView.allowsRotating = false
|
||||
mapView.allowsTilting = false
|
||||
return mapView
|
||||
@@ -149,7 +190,8 @@ extension MapLibreMapView {
|
||||
|
||||
func mapView(_ mapView: MLNMapView, didUpdate userLocation: MLNUserLocation?) {
|
||||
guard let userLocation else { return }
|
||||
|
||||
mapLibreView.hasLoadedUserLocation = true
|
||||
|
||||
if previousUserLocation == nil, mapLibreView.options.annotations.isEmpty {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
mapView.setCenter(userLocation.coordinate, zoomLevel: self.mapLibreView.options.zoomLevel, animated: true)
|
||||
|
||||
@@ -18,12 +18,12 @@ final class ShareToMapsAppActivity: UIActivity {
|
||||
|
||||
private let type: MapsAppType
|
||||
private let location: CLLocationCoordinate2D
|
||||
private let locationDescription: String?
|
||||
private let senderName: String?
|
||||
|
||||
init(type: MapsAppType, location: CLLocationCoordinate2D, locationDescription: String?) {
|
||||
init(type: MapsAppType, location: CLLocationCoordinate2D, senderName: String?) {
|
||||
self.type = type
|
||||
self.location = location
|
||||
self.locationDescription = locationDescription
|
||||
self.senderName = senderName
|
||||
super.init()
|
||||
}
|
||||
|
||||
@@ -44,20 +44,20 @@ final class ShareToMapsAppActivity: UIActivity {
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
UIApplication.shared.open(type.activityURL(for: location, locationDescription: locationDescription), options: [:]) { [weak self] result in
|
||||
UIApplication.shared.open(type.activityURL(for: location, senderName: senderName), options: [:]) { [weak self] result in
|
||||
self?.activityDidFinish(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ShareToMapsAppActivity.MapsAppType {
|
||||
func activityURL(for location: CLLocationCoordinate2D, locationDescription: String?) -> URL {
|
||||
func activityURL(for location: CLLocationCoordinate2D, senderName: String?) -> URL {
|
||||
switch self {
|
||||
case .apple:
|
||||
var url: URL = "https://maps.apple.com/"
|
||||
url.append(queryItems: [
|
||||
.init(name: "ll", value: "\(location.latitude),\(location.longitude)"),
|
||||
.init(name: "q", value: locationDescription ?? "Pin")
|
||||
.init(name: "q", value: senderName ?? "Pin") // We need to provide a value or no marker is displayed.
|
||||
])
|
||||
return url
|
||||
case .google:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct RowDivider: ViewModifier {
|
||||
@Environment(\.pixelLength) var pixelLength: CGFloat
|
||||
let alignment: Alignment
|
||||
let horizontalInsets: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(alignment: alignment) {
|
||||
Rectangle()
|
||||
.fill(Color.compound.borderDisabled)
|
||||
.frame(height: pixelLength)
|
||||
.padding(.trailing, -horizontalInsets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func rowDivider(alignment: Alignment = .bottom, horizontalInsets: CGFloat) -> some View {
|
||||
modifier(RowDivider(alignment: alignment, horizontalInsets: horizontalInsets))
|
||||
}
|
||||
}
|
||||
@@ -10,28 +10,84 @@ import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct LocationMarkerView: View {
|
||||
private let pinColor: Color = .compound.iconOnSolidPrimary
|
||||
private let pinInsets = EdgeInsets(top: 13, leading: 12, bottom: 15, trailing: 12)
|
||||
var userProfile: UserProfileProxy?
|
||||
var fillColor = Color.compound.bgCanvasDefault
|
||||
var strokeColor = Color.compound.iconSecondaryAlpha
|
||||
var dotColor = Color.compound.iconPrimary
|
||||
@ScaledMetric var size: CGFloat = 42
|
||||
var mediaProvider: MediaProviderProtocol?
|
||||
|
||||
private let circleCenter = CGPoint(x: 21, y: 21) // in SVG space
|
||||
private let circleRadius: CGFloat = 6 // in SVG space
|
||||
|
||||
var body: some View {
|
||||
CompoundIcon(\.locationPinSolid)
|
||||
.dynamicTypeSize(.large)
|
||||
.foregroundStyle(pinColor)
|
||||
.padding(pinInsets)
|
||||
.background {
|
||||
backgroundShape
|
||||
.shadow(color: .black.opacity(0.2), radius: 4.1129, x: 0, y: 4.93548)
|
||||
// Generated from the SVG
|
||||
Canvas { context, canvasSize in
|
||||
let scaleX = canvasSize.width / 42
|
||||
let scaleY = canvasSize.height / 50
|
||||
|
||||
let pinPath = Path { p in
|
||||
p.move(to: CGPoint(x: 21 * scaleX, y: 49 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 17.6875 * scaleX, y: 47.7581 * scaleY),
|
||||
control1: CGPoint(x: 19.25 * scaleX, y: 49 * scaleY),
|
||||
control2: CGPoint(x: 18.1458 * scaleX, y: 48.4825 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 5.28125 * scaleX, y: 33.6313 * scaleY),
|
||||
control1: CGPoint(x: 12.5833 * scaleX, y: 42.8525 * scaleY),
|
||||
control2: CGPoint(x: 8.41667 * scaleX, y: 38.1332 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 1 * scaleX, y: 21.3674 * scaleY),
|
||||
control1: CGPoint(x: 2.82292 * scaleX, y: 29.2846 * scaleY),
|
||||
control2: CGPoint(x: 1 * scaleX, y: 25.1863 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 21 * scaleX, y: 1 * scaleY),
|
||||
control1: CGPoint(x: 1 * scaleX, y: 10.2109 * scaleY),
|
||||
control2: CGPoint(x: 9.5 * scaleX, y: 1 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 41 * scaleX, y: 21.3674 * scaleY),
|
||||
control1: CGPoint(x: 32.5 * scaleX, y: 1 * scaleY),
|
||||
control2: CGPoint(x: 41 * scaleX, y: 10.2109 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 36.7188 * scaleX, y: 33.6313 * scaleY),
|
||||
control1: CGPoint(x: 41 * scaleX, y: 25.1863 * scaleY),
|
||||
control2: CGPoint(x: 39.1771 * scaleX, y: 29.2846 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 24.3125 * scaleX, y: 47.7581 * scaleY),
|
||||
control1: CGPoint(x: 33.5833 * scaleX, y: 38.1332 * scaleY),
|
||||
control2: CGPoint(x: 29.4167 * scaleX, y: 42.8525 * scaleY))
|
||||
p.addCurve(to: CGPoint(x: 21 * scaleX, y: 49 * scaleY),
|
||||
control1: CGPoint(x: 23.3333 * scaleX, y: 48.4825 * scaleY),
|
||||
control2: CGPoint(x: 21.5833 * scaleX, y: 49 * scaleY))
|
||||
p.closeSubpath()
|
||||
}
|
||||
.alignmentGuide(VerticalAlignment.center) { dimensions in
|
||||
dimensions[.bottom]
|
||||
|
||||
context.stroke(pinPath, with: .color(strokeColor), lineWidth: 2 * scaleX)
|
||||
context.fill(pinPath, with: .color(fillColor))
|
||||
|
||||
// Dot
|
||||
let dotPath = Path(ellipseIn: CGRect(x: (circleCenter.x - circleRadius) * scaleX,
|
||||
y: (circleCenter.y - circleRadius) * scaleY,
|
||||
width: circleRadius * 2 * scaleX,
|
||||
height: circleRadius * 2 * scaleY))
|
||||
context.fill(dotPath, with: .color(dotColor))
|
||||
|
||||
// Draw resolved symbol centered on the circle
|
||||
if userProfile != nil, let symbol = context.resolveSymbol(id: 0) {
|
||||
let center = CGPoint(x: circleCenter.x * scaleX,
|
||||
y: circleCenter.y * scaleY)
|
||||
context.draw(symbol, at: center, anchor: .center)
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundShape: some View {
|
||||
Image(asset: Asset.Images.locationMarkerShape)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.foregroundStyle(.compound.iconPrimary)
|
||||
} symbols: {
|
||||
if let userProfile {
|
||||
LoadableAvatarImage(url: userProfile.avatarURL,
|
||||
name: userProfile.displayName,
|
||||
contentID: userProfile.userID,
|
||||
avatarSize: .user(on: .map),
|
||||
mediaProvider: mediaProvider)
|
||||
.overlay {
|
||||
Circle().inset(by: 0.5).stroke(strokeColor)
|
||||
}
|
||||
.tag(0)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size * 50 / 42)
|
||||
.alignmentGuide(VerticalAlignment.center) { dimensions in
|
||||
dimensions[.bottom]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +95,19 @@ struct LocationMarkerView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 30) {
|
||||
LocationMarkerView()
|
||||
|
||||
|
||||
LocationMarkerView()
|
||||
.colorScheme(.dark)
|
||||
|
||||
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
|
||||
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
.colorScheme(.dark)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(red: 0.9, green: 0.85, blue: 0.8))
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct ToolbarButton: View {
|
||||
static let cancel = Role.cancel(title: L10n.actionCancel)
|
||||
static let done = Role.confirm(title: L10n.actionDone)
|
||||
static let save = Role.confirm(title: L10n.actionSave)
|
||||
static let close = Role.cancel(title: L10n.actionClose)
|
||||
|
||||
case cancel(title: String)
|
||||
case confirm(title: String)
|
||||
|
||||
@@ -83,6 +83,7 @@ enum TestablePreviewsDictionary {
|
||||
"LoadableImage_Previews" : LoadableImage_Previews.self,
|
||||
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
|
||||
"LocationRoomTimelineView_Previews" : LocationRoomTimelineView_Previews.self,
|
||||
"LocationSharingScreen_Previews" : LocationSharingScreen_Previews.self,
|
||||
"LoginScreen_Previews" : LoginScreen_Previews.self,
|
||||
"LongPressWithFeedback_Previews" : LongPressWithFeedback_Previews.self,
|
||||
"ManageAuthorizedSpacesScreen_Previews" : ManageAuthorizedSpacesScreen_Previews.self,
|
||||
@@ -181,7 +182,6 @@ enum TestablePreviewsDictionary {
|
||||
"StackedAvatarsView_Previews" : StackedAvatarsView_Previews.self,
|
||||
"StartChatScreen_Previews" : StartChatScreen_Previews.self,
|
||||
"StateRoomTimelineView_Previews" : StateRoomTimelineView_Previews.self,
|
||||
"StaticLocationScreenViewer_Previews" : StaticLocationScreenViewer_Previews.self,
|
||||
"StickerRoomTimelineView_Previews" : StickerRoomTimelineView_Previews.self,
|
||||
"SwipeRightAction_Previews" : SwipeRightAction_Previews.self,
|
||||
"SwipeToReplyView_Previews" : SwipeToReplyView_Previews.self,
|
||||
|
||||
@@ -33,12 +33,7 @@ struct HomeScreenRoomCell: View {
|
||||
|
||||
content
|
||||
.padding(.vertical, verticalInsets)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color.compound.borderDisabled)
|
||||
.frame(height: 1 / UIScreen.main.scale)
|
||||
.padding(.trailing, -horizontalInsets)
|
||||
}
|
||||
.rowDivider(horizontalInsets: horizontalInsets)
|
||||
}
|
||||
.padding(.horizontal, horizontalInsets)
|
||||
.accessibilityElement(children: .combine)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import SwiftUI
|
||||
|
||||
struct LocationSharingScreenCoordinatorParameters {
|
||||
let interactionMode: LocationSharingInteractionMode
|
||||
let mapURLBuilder: MapTilerURLBuilderProtocol
|
||||
let liveLocationSharingEnabled: Bool
|
||||
let roomProxy: JoinedRoomProxyProtocol
|
||||
let timelineController: TimelineControllerProtocol
|
||||
let appMediator: AppMediatorProtocol
|
||||
let analytics: AnalyticsService
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
}
|
||||
|
||||
enum LocationSharingScreenCoordinatorAction {
|
||||
case close
|
||||
}
|
||||
|
||||
final class LocationSharingScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: LocationSharingScreenCoordinatorParameters
|
||||
private let viewModel: LocationSharingScreenViewModelProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<LocationSharingScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actions: AnyPublisher<LocationSharingScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: LocationSharingScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = LocationSharingScreenViewModel(interactionMode: parameters.interactionMode,
|
||||
mapURLBuilder: parameters.mapURLBuilder,
|
||||
liveLocationSharingEnabled: parameters.liveLocationSharingEnabled,
|
||||
roomProxy: parameters.roomProxy,
|
||||
timelineController: parameters.timelineController,
|
||||
analytics: parameters.analytics,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
mediaProvider: parameters.mediaProvider)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .close:
|
||||
actionsSubject.send(.close)
|
||||
case .openSystemSettings:
|
||||
parameters.appMediator.openAppSettings()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(LocationSharingScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -14,31 +14,44 @@ enum LocationSharingViewError: Error, Hashable {
|
||||
case mapError(MapLibreError)
|
||||
}
|
||||
|
||||
enum StaticLocationScreenViewModelAction {
|
||||
enum LocationSharingScreenViewModelAction {
|
||||
case close
|
||||
case openSystemSettings
|
||||
}
|
||||
|
||||
enum StaticLocationInteractionMode: Hashable {
|
||||
enum LocationSharingInteractionMode: Hashable {
|
||||
case picker
|
||||
case viewOnly(geoURI: GeoURI, description: String? = nil)
|
||||
case viewStatic(senderID: String?, geoURI: GeoURI)
|
||||
|
||||
var canShowAvatar: Bool {
|
||||
switch self {
|
||||
case .picker, .viewStatic(.some(_), _):
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticLocationScreenViewState: BindableState {
|
||||
init(interactionMode: StaticLocationInteractionMode, mapURLBuilder: MapTilerURLBuilderProtocol) {
|
||||
struct LocationSharingScreenViewState: BindableState {
|
||||
init(interactionMode: LocationSharingInteractionMode,
|
||||
mapURLBuilder: MapTilerURLBuilderProtocol,
|
||||
showLiveLocationSharingButton: Bool) {
|
||||
self.interactionMode = interactionMode
|
||||
self.mapURLBuilder = mapURLBuilder
|
||||
self.showLiveLocationSharingButton = showLiveLocationSharingButton
|
||||
|
||||
bindings.showsUserLocationMode = switch interactionMode {
|
||||
case .picker: .showAndFollow
|
||||
case .viewOnly: .show
|
||||
case .viewStatic: .show
|
||||
}
|
||||
}
|
||||
|
||||
let interactionMode: StaticLocationInteractionMode
|
||||
let interactionMode: LocationSharingInteractionMode
|
||||
let mapURLBuilder: MapTilerURLBuilderProtocol
|
||||
let showLiveLocationSharingButton: Bool
|
||||
|
||||
var bindings = StaticLocationScreenBindings(showsUserLocationMode: .hide)
|
||||
var bindings = LocationSharingScreenBindings(showsUserLocationMode: .hide)
|
||||
|
||||
/// Indicates whether the user is sharing his current location
|
||||
var isSharingUserLocation: Bool {
|
||||
@@ -50,21 +63,17 @@ struct StaticLocationScreenViewState: BindableState {
|
||||
case .picker:
|
||||
// middle point in Europe, to be used if the users location is not yet known
|
||||
return .init(latitude: 49.843, longitude: 9.902056)
|
||||
case .viewOnly(let geoURI, _):
|
||||
case .viewStatic(_, let geoURI):
|
||||
return .init(latitude: geoURI.latitude, longitude: geoURI.longitude)
|
||||
}
|
||||
}
|
||||
|
||||
var isLocationPickerMode: Bool {
|
||||
interactionMode == .picker
|
||||
}
|
||||
|
||||
var navigationTitle: String {
|
||||
switch interactionMode {
|
||||
case .picker:
|
||||
return L10n.screenShareLocationTitle
|
||||
case .viewOnly:
|
||||
return L10n.screenViewLocationTitle
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +81,14 @@ struct StaticLocationScreenViewState: BindableState {
|
||||
switch interactionMode {
|
||||
case .picker:
|
||||
return false
|
||||
case .viewOnly:
|
||||
case .viewStatic:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var isLocationLoading: Bool {
|
||||
!bindings.hasLoadedUserLocation && bindings.isLocationAuthorized != false
|
||||
}
|
||||
|
||||
var zoomLevel: Double {
|
||||
15.0
|
||||
@@ -85,27 +98,28 @@ struct StaticLocationScreenViewState: BindableState {
|
||||
switch interactionMode {
|
||||
case .picker:
|
||||
return 2.7
|
||||
case .viewOnly:
|
||||
case .viewStatic:
|
||||
return 15.0
|
||||
}
|
||||
}
|
||||
|
||||
var locationDescription: String? {
|
||||
|
||||
var userProfile: UserProfileProxy?
|
||||
|
||||
var shownUserProfile: UserProfileProxy? {
|
||||
switch interactionMode {
|
||||
case .picker:
|
||||
return nil
|
||||
case .viewOnly(_, let description):
|
||||
return description
|
||||
isSharingUserLocation ? userProfile : nil
|
||||
case .viewStatic:
|
||||
userProfile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticLocationScreenBindings {
|
||||
struct LocationSharingScreenBindings {
|
||||
var mapCenterLocation: CLLocationCoordinate2D?
|
||||
var geolocationUncertainty: CLLocationAccuracy?
|
||||
|
||||
var showsUserLocationMode: ShowUserLocationMode
|
||||
|
||||
var hasLoadedUserLocation = false
|
||||
var isLocationAuthorized: Bool?
|
||||
|
||||
/// Information describing the currently displayed alert.
|
||||
@@ -127,7 +141,7 @@ struct StaticLocationScreenBindings {
|
||||
var showShareSheet = false
|
||||
}
|
||||
|
||||
enum StaticLocationScreenViewAction {
|
||||
enum LocationSharingScreenViewAction {
|
||||
case close
|
||||
case selectLocation
|
||||
case centerToUser
|
||||
@@ -141,20 +155,18 @@ extension AlertInfo where T == LocationSharingViewError {
|
||||
switch error {
|
||||
case .missingAuthorization:
|
||||
self.init(id: error,
|
||||
title: L10n.dialogPermissionLocationTitleIos(InfoPlistReader.main.bundleDisplayName),
|
||||
message: L10n.dialogPermissionLocationDescriptionIos,
|
||||
title: L10n.dialogAllowAccess,
|
||||
message: L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName),
|
||||
primaryButton: primaryButton,
|
||||
secondaryButton: secondaryButton)
|
||||
case .mapError(.failedLoadingMap):
|
||||
self.init(id: error,
|
||||
title: "",
|
||||
message: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName),
|
||||
title: L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName),
|
||||
primaryButton: primaryButton,
|
||||
secondaryButton: secondaryButton)
|
||||
case .mapError(.failedLocatingUser):
|
||||
self.init(id: error,
|
||||
title: "",
|
||||
message: L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName),
|
||||
title: L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName),
|
||||
primaryButton: primaryButton,
|
||||
secondaryButton: secondaryButton)
|
||||
}
|
||||
|
||||
+47
-8
@@ -9,31 +9,44 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
typealias StaticLocationScreenViewModelType = StateStoreViewModelV2<StaticLocationScreenViewState, StaticLocationScreenViewAction>
|
||||
typealias LocationSharingScreenViewModelType = StateStoreViewModelV2<LocationSharingScreenViewState, LocationSharingScreenViewAction>
|
||||
|
||||
class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLocationScreenViewModelProtocol {
|
||||
class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, LocationSharingScreenViewModelProtocol {
|
||||
private let roomProxy: JoinedRoomProxyProtocol
|
||||
private let timelineController: TimelineControllerProtocol
|
||||
private let analytics: AnalyticsService
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<StaticLocationScreenViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<StaticLocationScreenViewModelAction, Never> {
|
||||
private let actionsSubject: PassthroughSubject<LocationSharingScreenViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<LocationSharingScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(interactionMode: StaticLocationInteractionMode,
|
||||
init(interactionMode: LocationSharingInteractionMode,
|
||||
mapURLBuilder: MapTilerURLBuilderProtocol,
|
||||
liveLocationSharingEnabled: Bool,
|
||||
roomProxy: JoinedRoomProxyProtocol,
|
||||
timelineController: TimelineControllerProtocol,
|
||||
analytics: AnalyticsService,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
mediaProvider: MediaProviderProtocol) {
|
||||
self.roomProxy = roomProxy
|
||||
self.timelineController = timelineController
|
||||
self.analytics = analytics
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
super.init(initialViewState: .init(interactionMode: interactionMode, mapURLBuilder: mapURLBuilder))
|
||||
super.init(initialViewState: .init(interactionMode: interactionMode,
|
||||
mapURLBuilder: mapURLBuilder,
|
||||
showLiveLocationSharingButton: liveLocationSharingEnabled),
|
||||
mediaProvider: mediaProvider)
|
||||
|
||||
if interactionMode.canShowAvatar {
|
||||
updateShownUserProfile(members: roomProxy.membersPublisher.value)
|
||||
setupSubscriptions()
|
||||
}
|
||||
}
|
||||
|
||||
override func process(viewAction: StaticLocationScreenViewAction) {
|
||||
override func process(viewAction: LocationSharingScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .close:
|
||||
actionsSubject.send(.close)
|
||||
@@ -58,6 +71,32 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupSubscriptions() {
|
||||
roomProxy.membersPublisher.sink { [weak self] members in
|
||||
self?.updateShownUserProfile(members: members)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateShownUserProfile(members: [RoomMemberProxyProtocol]) {
|
||||
switch state.interactionMode {
|
||||
case .picker:
|
||||
if let ownUser = members.first(where: { $0.userID == roomProxy.ownUserID }).map(UserProfileProxy.init) {
|
||||
state.userProfile = ownUser
|
||||
} else {
|
||||
state.userProfile = .init(userID: roomProxy.ownUserID)
|
||||
}
|
||||
case .viewStatic(.some(let senderID), _):
|
||||
if let sender = members.first(where: { $0.userID == senderID }).map(UserProfileProxy.init) {
|
||||
state.userProfile = sender
|
||||
} else {
|
||||
state.userProfile = .init(userID: senderID)
|
||||
}
|
||||
default:
|
||||
state.userProfile = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func sendLocation(_ geoURI: GeoURI, isUserLocation: Bool) async {
|
||||
guard case .success = await timelineController.sendLocation(body: geoURI.bodyMessage,
|
||||
geoURI: geoURI,
|
||||
+3
-3
@@ -9,7 +9,7 @@
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol StaticLocationScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<StaticLocationScreenViewModelAction, Never> { get }
|
||||
var context: StaticLocationScreenViewModelType.Context { get }
|
||||
protocol LocationSharingScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<LocationSharingScreenViewModelAction, Never> { get }
|
||||
var context: LocationSharingScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import SwiftUI
|
||||
|
||||
struct StaticLocationScreenCoordinatorParameters {
|
||||
let interactionMode: StaticLocationInteractionMode
|
||||
let mapURLBuilder: MapTilerURLBuilderProtocol
|
||||
let timelineController: TimelineControllerProtocol
|
||||
let appMediator: AppMediatorProtocol
|
||||
let analytics: AnalyticsService
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
enum StaticLocationScreenCoordinatorAction {
|
||||
case close
|
||||
}
|
||||
|
||||
final class StaticLocationScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: StaticLocationScreenCoordinatorParameters
|
||||
private let viewModel: StaticLocationScreenViewModelProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<StaticLocationScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actions: AnyPublisher<StaticLocationScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: StaticLocationScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = StaticLocationScreenViewModel(interactionMode: parameters.interactionMode,
|
||||
mapURLBuilder: parameters.mapURLBuilder,
|
||||
timelineController: parameters.timelineController,
|
||||
analytics: parameters.analytics,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
viewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .close:
|
||||
actionsSubject.send(.close)
|
||||
case .openSystemSettings:
|
||||
parameters.appMediator.openAppSettings()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(StaticLocationScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//
|
||||
// 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 Compound
|
||||
import SwiftUI
|
||||
|
||||
struct LocationSharingScreen: View {
|
||||
@Bindable var context: LocationSharingScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
if context.viewState.isLocationPickerMode {
|
||||
mainContent
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
sharingOptionsSheet
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
} else {
|
||||
mainContent
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var mainContent: some View {
|
||||
mapView
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.track(screen: context.viewState.isLocationPickerMode ? .LocationSend : .LocationView)
|
||||
.navigationTitle(L10n.screenViewLocationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
}
|
||||
|
||||
private var mapView: some View {
|
||||
ZStack(alignment: .center) {
|
||||
MapLibreMapView(mapURLBuilder: context.viewState.mapURLBuilder,
|
||||
options: mapOptions,
|
||||
showsUserLocationMode: $context.showsUserLocationMode,
|
||||
error: $context.mapError,
|
||||
mapCenterCoordinate: $context.mapCenterLocation,
|
||||
hasLoadedUserLocation: $context.hasLoadedUserLocation,
|
||||
isLocationAuthorized: $context.isLocationAuthorized,
|
||||
geolocationUncertainty: $context.geolocationUncertainty) {
|
||||
context.send(viewAction: .userDidPan)
|
||||
}
|
||||
.ignoresSafeArea(.all, edges: mapSafeAreaEdges)
|
||||
|
||||
if context.viewState.isLocationPickerMode {
|
||||
LocationMarkerView(userProfile: context.viewState.shownUserProfile, mediaProvider: context.mediaProvider)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
centerToUserLocationButton
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: context.viewState.showShareAction ? .topBarLeading : .topBarTrailing) {
|
||||
ToolbarButton(role: .close) {
|
||||
context.send(viewAction: .close)
|
||||
}
|
||||
}
|
||||
|
||||
if context.viewState.showShareAction {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
context.showShareSheet = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.popover(isPresented: $context.showShareSheet) { shareSheet }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mapOptions: MapLibreMapView.Options {
|
||||
var annotations: [String: LocationAnnotation] = [:]
|
||||
if !context.viewState.isLocationPickerMode {
|
||||
let id = context.viewState.shownUserProfile?.userID ?? UUID().uuidString
|
||||
let annotation = LocationAnnotation(id: id,
|
||||
coordinate: context.viewState.initialMapCenter,
|
||||
anchorPoint: .bottomCenter) {
|
||||
LocationMarkerView(userProfile: context.viewState.shownUserProfile, mediaProvider: context.mediaProvider)
|
||||
}
|
||||
annotations[id] = annotation
|
||||
}
|
||||
|
||||
return .init(zoomLevel: context.viewState.zoomLevel,
|
||||
initialZoomLevel: context.viewState.initialZoomLevel,
|
||||
mapCenter: context.viewState.initialMapCenter,
|
||||
annotations: annotations)
|
||||
}
|
||||
|
||||
private var mapSafeAreaEdges: Edge.Set {
|
||||
context.viewState.isLocationPickerMode ? .horizontal : [.horizontal, .bottom]
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var centerToUseIcon: some View {
|
||||
if context.viewState.isLocationLoading {
|
||||
ProgressView()
|
||||
.tint(.compound.iconPrimary)
|
||||
.padding(13)
|
||||
} else {
|
||||
CompoundIcon(context.viewState.isSharingUserLocation ? \.locationNavigatorCentred : \.locationNavigator)
|
||||
.foregroundStyle(.compound.iconPrimary)
|
||||
.padding(13)
|
||||
}
|
||||
}
|
||||
|
||||
private var centerToUserLocationButton: some View {
|
||||
Button {
|
||||
context.send(viewAction: .centerToUser)
|
||||
} label: {
|
||||
if #available(iOS 26.0, *) {
|
||||
centerToUseIcon
|
||||
.glassEffect(.regular.interactive(), in: Circle())
|
||||
.tint(.compound.bgCanvasDefault)
|
||||
} else {
|
||||
centerToUseIcon
|
||||
.background(.compound.bgCanvasDefault, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.disabled(context.viewState.isLocationLoading)
|
||||
.dynamicTypeSize(.large)
|
||||
.padding(13)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var shareSheet: some View {
|
||||
let location = context.viewState.initialMapCenter
|
||||
let senderName = context.viewState.shownUserProfile?.displayName
|
||||
AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, senderName: senderName)],
|
||||
applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, senderName: senderName) })
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationCompactAdaptation(shareSheetCompactPresentation)
|
||||
.presentationDragIndicator(.hidden)
|
||||
}
|
||||
|
||||
private var shareSheetCompactPresentation: PresentationAdaptation {
|
||||
if #available(iOS 26.0, *) {
|
||||
.none // ShareLinks use a popover presentation on iOS 26, let it match that.
|
||||
} else {
|
||||
.sheet
|
||||
}
|
||||
}
|
||||
|
||||
@State private var sharingOptionsSheetHeight: CGFloat = .zero
|
||||
|
||||
private var sharingOptionsSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
context.send(viewAction: .selectLocation)
|
||||
} label: {
|
||||
if context.viewState.isSharingUserLocation {
|
||||
LocationSharingLabel(text: L10n.screenShareMyLocationAction,
|
||||
icon: \.locationNavigatorCentred,
|
||||
iconColor: .compound.iconSecondary)
|
||||
} else {
|
||||
LocationSharingLabel(text: L10n.screenShareThisLocationAction,
|
||||
icon: \.locationNavigator,
|
||||
iconColor: .compound.iconSecondary)
|
||||
}
|
||||
}
|
||||
if context.viewState.showLiveLocationSharingButton {
|
||||
Button { } label: {
|
||||
LocationSharingLabel(text: L10n.actionShareLiveLocation,
|
||||
icon: \.locationPinSolid,
|
||||
iconColor: .compound.iconAccentPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.padding(.top, 38)
|
||||
.readHeight($sharingOptionsSheetHeight)
|
||||
.interactiveDismissDisabled()
|
||||
.presentationBackground(.compound.bgCanvasDefault)
|
||||
.presentationBackgroundInteraction(.enabled)
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationDetents([.height(sharingOptionsSheetHeight)])
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocationSharingLabel: View {
|
||||
let text: String
|
||||
let icon: KeyPath<CompoundIcons, Image>
|
||||
let iconColor: Color
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Text(text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 14)
|
||||
.rowDivider(alignment: .top, horizontalInsets: 16.0)
|
||||
} icon: {
|
||||
CompoundIcon(icon)
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct LocationSharingScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = LocationSharingScreenViewModel(interactionMode: .viewStatic(senderID: "@dan:matrix.org", geoURI: .init(latitude: 41.9027835,
|
||||
longitude: 12.4963655)),
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: true,
|
||||
roomProxy: JoinedRoomProxyMock(.init()),
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
|
||||
static let pinViewModel = LocationSharingScreenViewModel(interactionMode: .viewStatic(senderID: nil, geoURI: .init(latitude: 41.9027835,
|
||||
longitude: 12.4963655)),
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: true,
|
||||
roomProxy: JoinedRoomProxyMock(.init()),
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
|
||||
static let pickerViewModel = LocationSharingScreenViewModel(interactionMode: .picker,
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
liveLocationSharingEnabled: true,
|
||||
roomProxy: JoinedRoomProxyMock(.init()),
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
LocationSharingScreen(context: pickerViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Picker")
|
||||
|
||||
ElementNavigationStack {
|
||||
LocationSharingScreen(context: viewModel.context)
|
||||
}
|
||||
.previewDisplayName("User Static Location")
|
||||
|
||||
ElementNavigationStack {
|
||||
LocationSharingScreen(context: pinViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Pin Static Location")
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGPoint {
|
||||
static let bottomCenter: Self = .init(x: 0.5, y: 1)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
//
|
||||
// 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 Compound
|
||||
import SwiftUI
|
||||
|
||||
struct StaticLocationScreen: View {
|
||||
@Bindable var context: StaticLocationScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let locationDescription = context.viewState.locationDescription {
|
||||
Text(locationDescription)
|
||||
.lineLimit(2)
|
||||
.foregroundColor(Color.compound.textPrimary)
|
||||
.font(.compound.bodyMD)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
mapView
|
||||
}
|
||||
.track(screen: context.viewState.isLocationPickerMode ? .LocationSend : .LocationView)
|
||||
.navigationTitle(context.viewState.navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
private var mapView: some View {
|
||||
ZStack(alignment: .center) {
|
||||
MapLibreMapView(mapURLBuilder: context.viewState.mapURLBuilder,
|
||||
options: mapOptions,
|
||||
showsUserLocationMode: $context.showsUserLocationMode,
|
||||
error: $context.mapError,
|
||||
mapCenterCoordinate: $context.mapCenterLocation,
|
||||
isLocationAuthorized: $context.isLocationAuthorized,
|
||||
geolocationUncertainty: $context.geolocationUncertainty) {
|
||||
context.send(viewAction: .userDidPan)
|
||||
}
|
||||
.ignoresSafeArea(.all, edges: mapSafeAreaEdges)
|
||||
|
||||
if context.viewState.isLocationPickerMode {
|
||||
LocationMarkerView()
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
centerToUserLocationButton
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
closeButton
|
||||
}
|
||||
|
||||
if context.viewState.showShareAction {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
shareButton
|
||||
.popover(isPresented: $context.showShareSheet) { shareSheet }
|
||||
}
|
||||
}
|
||||
|
||||
if context.viewState.isLocationPickerMode {
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
selectLocationButton
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mapOptions: MapLibreMapView.Options {
|
||||
var annotations: [LocationAnnotation] = []
|
||||
if context.viewState.isLocationPickerMode == false {
|
||||
let annotation = LocationAnnotation(coordinate: context.viewState.initialMapCenter, anchorPoint: .bottomCenter) {
|
||||
LocationMarkerView()
|
||||
}
|
||||
annotations.append(annotation)
|
||||
}
|
||||
|
||||
return .init(zoomLevel: context.viewState.zoomLevel,
|
||||
initialZoomLevel: context.viewState.initialZoomLevel,
|
||||
mapCenter: context.viewState.initialMapCenter,
|
||||
annotations: annotations)
|
||||
}
|
||||
|
||||
private var mapSafeAreaEdges: Edge.Set {
|
||||
context.viewState.isLocationPickerMode ? .horizontal : [.horizontal, .bottom]
|
||||
}
|
||||
|
||||
private var selectLocationButton: some View {
|
||||
Button {
|
||||
context.send(viewAction: .selectLocation)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
CompoundIcon(\.shareIos)
|
||||
Text(context.viewState.isSharingUserLocation ? L10n.screenShareMyLocationAction : L10n.screenShareThisLocationAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var centerToUserLocationButton: some View {
|
||||
Button {
|
||||
context.send(viewAction: .centerToUser)
|
||||
} label: {
|
||||
CompoundIcon(context.viewState.isSharingUserLocation ? \.locationNavigatorCentred : \.locationNavigator)
|
||||
.padding(8)
|
||||
.background(.compound.bgCanvasDefault, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.dynamicTypeSize(.large)
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(L10n.actionCancel) {
|
||||
context.send(viewAction: .close)
|
||||
}
|
||||
}
|
||||
|
||||
private var shareButton: some View {
|
||||
Button {
|
||||
context.showShareSheet = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var shareSheet: some View {
|
||||
let location = context.viewState.initialMapCenter
|
||||
let locationDescription = context.viewState.locationDescription
|
||||
AppActivityView(activityItems: [ShareToMapsAppActivity.MapsAppType.apple.activityURL(for: location, locationDescription: locationDescription)],
|
||||
applicationActivities: ShareToMapsAppActivity.MapsAppType.allCases.map { ShareToMapsAppActivity(type: $0, location: location, locationDescription: locationDescription) })
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationCompactAdaptation(shareSheetCompactPresentation)
|
||||
.presentationDragIndicator(.hidden)
|
||||
}
|
||||
|
||||
private var shareSheetCompactPresentation: PresentationAdaptation {
|
||||
if #available(iOS 26.0, *) {
|
||||
.none // ShareLinks use a popover presentation on iOS 26, let it match that.
|
||||
} else {
|
||||
.sheet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct StaticLocationScreenViewer_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835,
|
||||
longitude: 12.4963655)),
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
static let pickerViewModel = StaticLocationScreenViewModel(interactionMode: .picker,
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
static let descriptionViewModel = StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835,
|
||||
longitude: 12.4963655),
|
||||
description: "Cool position"),
|
||||
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
|
||||
timelineController: MockTimelineController(),
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
StaticLocationScreen(context: pickerViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Picker")
|
||||
|
||||
ElementNavigationStack {
|
||||
StaticLocationScreen(context: viewModel.context)
|
||||
}
|
||||
.previewDisplayName("View Only")
|
||||
|
||||
ElementNavigationStack {
|
||||
StaticLocationScreen(context: descriptionViewModel.context)
|
||||
}
|
||||
.previewDisplayName("View Only (with description)")
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGPoint {
|
||||
static let bottomCenter: Self = .init(x: 0.5, y: 1)
|
||||
}
|
||||
+4
-3
@@ -26,7 +26,7 @@ struct PinnedEventsTimelineScreenCoordinatorParameters {
|
||||
enum PinnedEventsTimelineScreenCoordinatorAction {
|
||||
case dismiss
|
||||
case displayUser(userID: String)
|
||||
case presentLocationViewer(geoURI: GeoURI, description: String?)
|
||||
case presentLocationViewer(senderID: String?, geoURI: GeoURI)
|
||||
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||
case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?)
|
||||
}
|
||||
@@ -87,8 +87,9 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
|
||||
case .displayMediaPreview(let mediaPreviewViewModel):
|
||||
viewModel.displayMediaPreview(mediaPreviewViewModel)
|
||||
case .displayLocation(_, let geoURI, let description):
|
||||
actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description))
|
||||
case .displayLocation(let senderID, let geoURI):
|
||||
actionsSubject.send(.presentLocationViewer(senderID: senderID,
|
||||
geoURI: geoURI))
|
||||
case .viewInRoomTimeline(let eventID, let threadRootEventID):
|
||||
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID))
|
||||
// These other actions will not be handled in this view
|
||||
|
||||
@@ -39,7 +39,7 @@ enum RoomScreenCoordinatorAction {
|
||||
case presentRoomDetails
|
||||
case presentLocationPicker
|
||||
case presentPollForm(mode: PollFormMode)
|
||||
case presentLocationViewer(body: String, geoURI: GeoURI, description: String?)
|
||||
case presentLocationViewer(senderID: String?, geoURI: GeoURI)
|
||||
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case presentRoomMemberDetails(userID: String)
|
||||
case presentMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||
@@ -137,8 +137,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentRoomMemberDetails(userID: userID))
|
||||
case .displayMessageForwarding(let forwardingItem):
|
||||
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
|
||||
case .displayLocation(let body, let geoURI, let description):
|
||||
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
|
||||
case .displayLocation(let senderID, let geoURI):
|
||||
actionsSubject.send(.presentLocationViewer(senderID: senderID, geoURI: geoURI))
|
||||
case .displayResolveSendFailure(let failure, let sendHandle):
|
||||
actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle))
|
||||
case .displayThread(let itemID):
|
||||
|
||||
+2
@@ -67,6 +67,8 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
||||
var linkPreviewsEnabled: Bool { get set }
|
||||
|
||||
var linkNewDeviceEnabled: Bool { get set }
|
||||
|
||||
var liveLocationSharingEnabled: Bool { get set }
|
||||
}
|
||||
|
||||
extension AppSettings: DeveloperOptionsProtocol { }
|
||||
|
||||
+4
@@ -76,6 +76,10 @@ struct DeveloperOptionsScreen: View {
|
||||
Text("Can leak the device IP address when loading link metadata.")
|
||||
.foregroundStyle(.compound.textCriticalPrimary)
|
||||
}
|
||||
|
||||
Toggle(isOn: $context.liveLocationSharingEnabled) {
|
||||
Text("Live location sharing")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Join rules") {
|
||||
|
||||
@@ -33,7 +33,7 @@ enum ThreadTimelineScreenCoordinatorAction {
|
||||
case presentMediaUploadPicker(mode: MediaPickerScreenMode)
|
||||
case presentMediaUploadPreviewScreen(mediaURLs: [URL])
|
||||
case presentLocationPicker
|
||||
case presentLocationViewer(body: String, geoURI: GeoURI, description: String?)
|
||||
case presentLocationViewer(senderID: String?, geoURI: GeoURI)
|
||||
case presentPollForm(mode: PollFormMode)
|
||||
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case presentRoomMemberDetails(userID: String)
|
||||
@@ -118,10 +118,9 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
viewModel.displayMediaPreview(mediaPreviewViewModel)
|
||||
case .displayLocationPicker:
|
||||
actionsSubject.send(.presentLocationPicker)
|
||||
case .displayLocation(let body, let geoURI, let description):
|
||||
actionsSubject.send(.presentLocationViewer(body: body,
|
||||
geoURI: geoURI,
|
||||
description: description))
|
||||
case .displayLocation(let senderID, let geoURI):
|
||||
actionsSubject.send(.presentLocationViewer(senderID: senderID,
|
||||
geoURI: geoURI))
|
||||
case .displayPollForm(let mode):
|
||||
actionsSubject.send(.presentPollForm(mode: mode))
|
||||
case .displayMediaUploadPreviewScreen(let mediaURLs):
|
||||
|
||||
@@ -536,7 +536,8 @@ class TimelineInteractionHandler {
|
||||
switch timelineItem {
|
||||
case let item as LocationRoomTimelineItem:
|
||||
guard let geoURI = item.content.geoURI else { return .none }
|
||||
return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description)
|
||||
return .displayLocation(senderID: item.content.kind == .sender ? item.sender.id : nil,
|
||||
geoURI: geoURI)
|
||||
case is ImageRoomTimelineItem,
|
||||
is VideoRoomTimelineItem:
|
||||
return await mediaPreviewAction(for: timelineItem, messageTypes: [.image, .video])
|
||||
|
||||
@@ -23,7 +23,7 @@ enum TimelineViewModelAction {
|
||||
case displaySenderDetails(userID: String)
|
||||
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||
case displayMediaPreview(TimelineMediaPreviewViewModel)
|
||||
case displayLocation(body: String, geoURI: GeoURI, description: String?)
|
||||
case displayLocation(senderID: String?, geoURI: GeoURI)
|
||||
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
|
||||
case displayThread(itemID: TimelineItemIdentifier)
|
||||
case composer(action: TimelineComposerAction)
|
||||
|
||||
@@ -655,8 +655,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
|
||||
let mediaPreviewViewModel = makeMediaPreviewViewModel(item: item, timelineViewModelKind: timelineViewModelKind)
|
||||
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
|
||||
case .displayLocation(let body, let geoURI, let description):
|
||||
actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description))
|
||||
case .displayLocation(let senderID, let geoURI):
|
||||
actionsSubject.send(.displayLocation(senderID: senderID, geoURI: geoURI))
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -705,20 +705,7 @@ private struct MockTimelineContent: View {
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "Fallback geo uri description",
|
||||
geoURI: .init(latitude: 41.902782,
|
||||
longitude: 12.496366),
|
||||
description: "Location description description description description description description description description"),
|
||||
properties: .init(replyDetails: replyDetails,
|
||||
isThreaded: isThreaded,
|
||||
threadSummary: threadSummary)))
|
||||
|
||||
LocationRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "Fallback geo uri description",
|
||||
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil),
|
||||
longitude: 12.496366)),
|
||||
properties: .init(replyDetails: replyDetails,
|
||||
isThreaded: isThreaded,
|
||||
threadSummary: threadSummary)))
|
||||
|
||||
+12
-32
@@ -16,7 +16,7 @@ struct LocationRoomTimelineView: View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
mainContent
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.accessibilityLabel(L10n.commonSharedLocation)
|
||||
.onTapGesture {
|
||||
guard context.viewState.mapTilerConfiguration.isEnabled else { return }
|
||||
context.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||
@@ -27,41 +27,20 @@ struct LocationRoomTimelineView: View {
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if let geoURI = timelineItem.content.geoURI {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
descriptionView
|
||||
.frame(maxWidth: mapAspectRatio * mapMaxHeight, alignment: .leading)
|
||||
|
||||
MapLibreStaticMapView(geoURI: geoURI,
|
||||
mapURLBuilder: context.viewState.mapTilerConfiguration,
|
||||
mapSize: .init(width: mapAspectRatio * mapMaxHeight, height: mapMaxHeight)) {
|
||||
LocationMarkerView()
|
||||
}
|
||||
.frame(maxHeight: mapMaxHeight)
|
||||
.aspectRatio(mapAspectRatio, contentMode: .fit)
|
||||
.clipped()
|
||||
MapLibreStaticMapView(geoURI: geoURI,
|
||||
mapURLBuilder: context.viewState.mapTilerConfiguration,
|
||||
mapSize: .init(width: mapAspectRatio * mapMaxHeight, height: mapMaxHeight)) {
|
||||
LocationMarkerView(userProfile: timelineItem.content.kind == .sender ? .init(sender: timelineItem.sender) : nil, mediaProvider: context.mediaProvider)
|
||||
}
|
||||
.frame(maxHeight: mapMaxHeight)
|
||||
.aspectRatio(mapAspectRatio, contentMode: .fit)
|
||||
.clipped()
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.body, additionalWhitespacesCount: timelineItem.additionalWhitespaces())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
if let description = timelineItem.content.description {
|
||||
return "\(L10n.commonSharedLocation), \(description)"
|
||||
}
|
||||
|
||||
return L10n.commonSharedLocation
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var descriptionView: some View {
|
||||
if let description = timelineItem.content.description, !description.isEmpty {
|
||||
FormattedBodyText(text: description)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private let mapAspectRatio: Double = 3 / 2
|
||||
private let mapMaxHeight: Double = 300
|
||||
@@ -107,9 +86,9 @@ struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
|
||||
content: .init(body: "Fallback geo uri description",
|
||||
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description")))
|
||||
geoURI: .init(latitude: 41.902782, longitude: 12.496366))))
|
||||
LocationRoomTimelineView(timelineItem: .init(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
@@ -117,7 +96,8 @@ struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "Fallback geo uri description",
|
||||
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description"),
|
||||
geoURI: .init(latitude: 41.902782, longitude: 12.496366),
|
||||
kind: .pin),
|
||||
properties: .init(replyDetails: .loaded(sender: .init(id: "Someone"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "The thread content goes 'ere.")))),
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ enum TimelineControllerAction {
|
||||
}
|
||||
|
||||
case displayMediaPreview(item: EventBasedMessageTimelineItemProtocol, timelineViewModel: TimelineViewModelKind)
|
||||
case displayLocation(body: String, geoURI: GeoURI, description: String?)
|
||||
case displayLocation(senderID: String?, geoURI: GeoURI)
|
||||
case none
|
||||
}
|
||||
|
||||
|
||||
+19
-3
@@ -6,16 +6,32 @@
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import MatrixRustSDK
|
||||
|
||||
struct LocationRoomTimelineItemContent: Hashable {
|
||||
enum Kind {
|
||||
case sender
|
||||
case pin
|
||||
|
||||
init(from asset: AssetType?) {
|
||||
self = switch asset {
|
||||
case .pin:
|
||||
.pin
|
||||
case .sender, .none:
|
||||
.sender
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let body: String
|
||||
let geoURI: GeoURI?
|
||||
let description: String?
|
||||
let kind: Kind
|
||||
|
||||
init(body: String,
|
||||
geoURI: GeoURI? = nil,
|
||||
description: String? = nil) {
|
||||
kind: Kind = .sender) {
|
||||
self.body = body
|
||||
self.geoURI = geoURI
|
||||
self.description = description
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,7 +583,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
private func buildLocationTimelineItemContent(_ locationContent: LocationContent) -> LocationRoomTimelineItemContent {
|
||||
LocationRoomTimelineItemContent(body: locationContent.body,
|
||||
geoURI: .init(string: locationContent.geoUri),
|
||||
description: locationContent.description)
|
||||
kind: .init(from: locationContent.asset))
|
||||
}
|
||||
|
||||
private func buildFileTimelineItemContent(_ messageContent: FileMessageContent) -> FileRoomTimelineItemContent {
|
||||
|
||||
@@ -44,6 +44,10 @@ struct UserProfileProxy: Equatable, Hashable {
|
||||
avatarURL = sdkRoomHero.avatarUrl.flatMap(URL.init(string:))
|
||||
}
|
||||
|
||||
init(member: RoomMemberProxyProtocol) {
|
||||
self.init(member: RoomMemberDetails(withProxy: member))
|
||||
}
|
||||
|
||||
/// A user is meant to be "verified" when the GET profile returns back either the display name or the avatar
|
||||
/// If isn't we aren't sure that the related matrix id really exists.
|
||||
var isVerified: Bool {
|
||||
|
||||
Reference in New Issue
Block a user