mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:06:00 +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:
@@ -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.")))),
|
||||
|
||||
Reference in New Issue
Block a user