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:
Mauro
2026-03-06 12:51:16 +01:00
committed by GitHub
parent 0c79b5404d
commit bbbb2e9dee
85 changed files with 917 additions and 626 deletions
@@ -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)
}
@@ -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,
@@ -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)
}
@@ -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):
@@ -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 { }
@@ -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)))
@@ -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.")))),