LLS Sheet implementation (#5420)

* Add LiveLocationSheet and refactor existing views to share code

* Implement logic for highlighting a specific LLS from a user once selected in the sheet

* Updated tests, project and added new previews for the LLS sheet.

# Conflicts:
#	PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPad-pseudo.png
#	PreviewTests/Sources/__Snapshots__/PreviewTests/liveLocationRoomTimelineView.Bubbles-iPhone-pseudo.png

* add Equatable conformance to CLLocationCoordinate2D
This commit is contained in:
Mauro
2026-04-17 13:13:16 +02:00
committed by GitHub
parent eef4a2497a
commit bfb1036aa0
52 changed files with 518 additions and 172 deletions
@@ -65,6 +65,7 @@ struct LocationSharingScreenViewState: BindableState {
let ownUserID: String
var userProfiles: [String: UserProfileProxy]
var liveLocationShares: [LiveLocationShare] = []
var isStoppingLiveLocation = false
var annotations: [LocationAnnotation] {
switch interactionMode {
@@ -80,6 +81,8 @@ struct LocationSharingScreenViewState: BindableState {
case .viewLive:
return liveLocationShares.compactMap { share in
guard let geoURI = share.geoURI else { return nil }
if share.userID == ownUserID, isStoppingLiveLocation { return nil }
let profile = userProfiles[share.userID] ?? UserProfileProxy(userID: share.userID)
let kind = LocationMarkerKind.liveUser(profile)
let coordinate = CLLocationCoordinate2D(latitude: geoURI.latitude, longitude: geoURI.longitude)
@@ -175,6 +178,7 @@ enum LocationSharingScreenViewAction {
case startLiveLocation
case centerToUser
case userDidPan
case stopLiveLocation
}
extension AlertInfo where T == LocationSharingViewAlert {
@@ -83,11 +83,21 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
primaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.commonSettings, action: action))
}
case .stopLiveLocation:
stopLiveLocation()
}
}
// MARK: - Private
private func stopLiveLocation() {
state.isStoppingLiveLocation = true
if let index = state.liveLocationShares.firstIndex(where: { $0.userID == roomProxy.ownUserID }) {
state.liveLocationShares.remove(at: index)
}
Task { await liveLocationManager.stopLiveLocation(roomID: roomProxy.id) }
}
private func setupLiveLocationSubscription() async {
let liveLocationService = await roomProxy.makeLiveLocationService()
self.liveLocationService = liveLocationService
@@ -96,7 +106,15 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
.sink { [weak self] liveLocationsShares in
guard let self else { return }
MXLog.info("Received live location shares update: \(liveLocationsShares.count) share(s)")
let ownUserID = roomProxy.ownUserID
let isStoppingLiveLocation = state.isStoppingLiveLocation
state.liveLocationShares = liveLocationsShares
.filter { !(isStoppingLiveLocation && ownUserID == $0.userID) }
.sorted { lhs, rhs in
if lhs.userID == ownUserID { return true }
if rhs.userID == ownUserID { return false }
return lhs.timestamp > rhs.timestamp
}
updateUserProfiles(members: roomProxy.membersPublisher.value)
}
.store(in: &cancellables)
@@ -281,6 +299,8 @@ extension LocationSharingScreenViewModel {
case picker
case staticSenderLocation
case staticPinLocation
case viewLive
case viewLiveEmpty
}
static func mock(type: MockType,
@@ -301,12 +321,41 @@ extension LocationSharingScreenViewModel {
longitude: 12.4963655),
kind: .sender,
timestamp: .mock))
case .viewLive, .viewLiveEmpty:
.viewLive(sender: .init(id: senderID, displayName: "Me"),
initialLiveLocationShare: LiveLocationShare(userID: senderID,
geoURI: .init(latitude: 41.9027835, longitude: 12.4963655),
timestamp: .mock,
timeoutDate: .distantFuture))
}
let liveLocationShares: [LiveLocationShare] = if type == .viewLive {
[
LiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID,
geoURI: .init(latitude: 41.9027835, longitude: 12.4963655),
timestamp: .mock,
timeoutDate: .distantFuture),
LiveLocationShare(userID: RoomMemberProxyMock.mockAlice.userID,
geoURI: .init(latitude: 48.8566, longitude: 2.3522),
timestamp: .mock,
timeoutDate: .distantFuture),
LiveLocationShare(userID: RoomMemberProxyMock.mockBob.userID,
geoURI: .init(latitude: 51.5074, longitude: -0.1278),
timestamp: .mock,
timeoutDate: .distantFuture)
]
} else {
[]
}
let liveLocationServiceMock = RoomLiveLocationServiceMock(.init(shares: liveLocationShares))
let roomProxy = JoinedRoomProxyMock(.init(members: .allMembers, ownUserID: RoomMemberProxyMock.mockMe.userID))
roomProxy.makeLiveLocationServiceReturnValue = liveLocationServiceMock
return LocationSharingScreenViewModel(interactionMode: interactionMode,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
liveLocationSharingEnabled: liveLocationSharingEnabled,
roomProxy: JoinedRoomProxyMock(.init()),
roomProxy: roomProxy,
timelineController: MockTimelineController(),
liveLocationManager: LiveLocationManagerMock(),
analytics: ServiceLocator.shared.analytics,
@@ -0,0 +1,96 @@
//
// 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 Compound
import SwiftUI
struct LiveLocationSheet: View {
@Bindable var context: LocationSharingScreenViewModel.Context
@State private var currentDetent: PresentationDetent = supportedDetents[1]
private static let supportedDetents: [PresentationDetent] = [.fraction(0.13), .fraction(0.3)]
private var isCurrentDetentSmall: Bool {
currentDetent == Self.supportedDetents[0]
}
var body: some View {
mainContent
.interactiveDismissDisabled()
.presentationBackground(.compound.bgCanvasDefault)
.presentationBackgroundInteraction(.enabled)
.presentationDragIndicator(context.viewState.liveLocationShares.isEmpty ? .hidden : .visible)
.presentationDetents(context.viewState.liveLocationShares.isEmpty ? .init([Self.supportedDetents[0]]) : .init(Self.supportedDetents),
selection: $currentDetent)
.animation(.elementDefault, value: currentDetent)
.animation(.elementDefault, value: context.viewState.liveLocationShares.isEmpty)
}
private var mainContent: some View {
VStack(spacing: 0) {
title
if isCurrentDetentSmall {
subtitle
} else {
locationSharesList
}
}
.popover(item: $context.sharedAnnotation) { annotation in
LocationShareSheet(annotation: annotation)
}
}
private var title: some View {
Text(context.viewState.liveLocationShares.isEmpty ? L10n.screenLiveLocationSheetNobodySharing : L10n.screenLiveLocationSheetTitle)
.foregroundStyle(.compound.textPrimary)
.font(.compound.bodyLGSemibold)
.padding(.bottom, isCurrentDetentSmall ? 0 : 25)
.padding(.top, isCurrentDetentSmall ? 0 : 29)
}
private var subtitle: some View {
Text(L10n.screenLiveLocationSheetSubtitle(context.viewState.liveLocationShares.count))
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
.opacity(context.viewState.liveLocationShares.isEmpty ? 0 : 1)
.allowsHitTesting(!context.viewState.liveLocationShares.isEmpty)
}
private var locationSharesList: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(context.viewState.liveLocationShares) { liveLocationShare in
if let profile = context.viewState.userProfiles[liveLocationShare.userID] {
Button {
guard let geoURI = liveLocationShare.geoURI else { return }
context.mapCenterLocation = .init(latitude: geoURI.latitude, longitude: geoURI.longitude)
} label: {
UserLocationCell(profile: profile,
isOwnUser: context.viewState.isOwnUser(liveLocationShare.userID),
kind: .live,
mediaProvider: context.mediaProvider,
onShare: { context.sharedAnnotation = context.viewState.annotations.first { $0.id == liveLocationShare.id }},
onStop: { context.send(viewAction: .stopLiveLocation) })
}
}
}
}
}
}
}
struct LiveLocationSheet_Previews: PreviewProvider, TestablePreview {
static let viewModel = LocationSharingScreenViewModel.mock(type: .viewLive, senderID: RoomMemberProxyMock.mockMe.userID)
static let emptyViewModel = LocationSharingScreenViewModel.mock(type: .viewLiveEmpty, senderID: RoomMemberProxyMock.mockMe.userID)
static var previews: some View {
LiveLocationSheet(context: viewModel.context)
.previewDisplayName("Live location")
LiveLocationSheet(context: emptyViewModel.context)
.previewDisplayName("Live locations are empty")
}
}
@@ -25,12 +25,13 @@ struct LocationSharingScreen: View {
.sheet(isPresented: .constant(true)) {
StaticLocationSheet(context: context)
.alert(item: $context.alertInfo)
.popover(item: $context.sharedAnnotation) { annotation in
LocationShareSheet(annotation: annotation)
}
}
case .viewLive:
mainContent
.sheet(isPresented: .constant(true)) {
LiveLocationSheet(context: context)
.alert(item: $context.alertInfo)
}
}
}
@@ -37,70 +37,26 @@ struct StaticLocationSheet: View {
if case let .viewStatic(location) = context.viewState.interactionMode,
let profile = context.viewState.userProfiles.values.first {
Button {
context.sharedAnnotation = context.viewState.annotations.first
context.mapCenterLocation = .init(latitude: location.geoURI.latitude,
longitude: location.geoURI.longitude)
} label: {
UserLocationCell(profile: profile,
isOwnUser: context.viewState.isOwnUser(profile.userID),
isUserLocation: location.kind == .sender,
timestamp: location.timestamp,
mediaProvider: context.mediaProvider)
kind: .static(isUserLocation: location.kind == .sender,
timestamp: location.timestamp),
mediaProvider: context.mediaProvider,
onShare: {
context.sharedAnnotation = context.viewState.annotations.first
})
}
.popover(item: $context.sharedAnnotation) { annotation in
LocationShareSheet(annotation: annotation)
}
}
}
}
}
/// This may be reused for live location sharing sheet in the future with some tweaks
private struct UserLocationCell: View {
let profile: UserProfileProxy
let isOwnUser: Bool
let isUserLocation: Bool
let timestamp: Date
var mediaProvider: MediaProviderProtocol?
private var name: String {
isOwnUser ? L10n.commonYou : profile.displayName ?? profile.userID
}
var body: some View {
HStack(spacing: 12) {
LoadableAvatarImage(url: profile.avatarURL,
name: profile.displayName,
contentID: profile.id,
avatarSize: .user(on: .map),
mediaProvider: mediaProvider)
.accessibilityHidden(true)
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 0) {
Text(name)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
HStack(spacing: 4) {
CompoundIcon(isUserLocation ? \.locationNavigatorCentred : \.locationNavigator,
size: .xSmall,
relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.iconSecondary)
.accessibilityLabel(isUserLocation ? L10n.a11ySenderLocation : L10n.a11yPinnedLocation)
Text(L10n.screenStaticLocationSheetTimestampDescription(timestamp.formatted(.relative(presentation: .named))))
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
}
}
Spacer()
CompoundIcon(\.shareIos)
.foregroundStyle(.compound.iconSecondary)
.accessibilityLabel(L10n.actionShare)
}
.padding(.vertical, 12)
.rowDivider(alignment: .top)
}
.padding(.horizontal, 16)
.accessibilityElement(children: .combine)
}
}
struct StaticLocationSheet_Previews: PreviewProvider, TestablePreview {
static let viewModel = LocationSharingScreenViewModel.mock(type: .staticSenderLocation, senderID: RoomMemberProxyMock.mockMe.userID)
@@ -0,0 +1,109 @@
//
// 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 Compound
import SwiftUI
struct UserLocationCell: View {
let profile: UserProfileProxy
let isOwnUser: Bool
let kind: Kind
var mediaProvider: MediaProviderProtocol?
var onShare: (() -> Void)?
var onStop: (() -> Void)?
enum Kind {
case `static`(isUserLocation: Bool, timestamp: Date)
case live
}
private var name: String {
isOwnUser ? L10n.commonYou : profile.displayName ?? profile.userID
}
var body: some View {
HStack(spacing: 12) {
LoadableAvatarImage(url: profile.avatarURL,
name: profile.displayName,
contentID: profile.id,
avatarSize: .user(on: .map),
mediaProvider: mediaProvider)
.accessibilityHidden(true)
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 0) {
Text(name)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
HStack(spacing: 4) {
if case let .static(isUserLocation, timestamp) = kind {
CompoundIcon(isUserLocation ? \.locationNavigatorCentred : \.locationNavigator,
size: .xSmall,
relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.iconSecondary)
.accessibilityLabel(isUserLocation ? L10n.a11ySenderLocation : L10n.a11yPinnedLocation)
Text(L10n.screenStaticLocationSheetTimestampDescription(timestamp.formatted(.relative(presentation: .named))))
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
} else {
CompoundIcon(\.locationPinSolid,
size: .xSmall,
relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.iconAccentPrimary)
.accessibilityHidden(true)
Text(L10n.screenLiveLocationSheetSharingLiveLocation)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
}
}
.accessibilityElement(children: .combine)
Spacer()
if case .live = kind, isOwnUser {
StopButton { onStop?() }
}
Button { onShare?() } label: {
CompoundIcon(\.shareIos)
.foregroundStyle(.compound.iconPrimary)
.padding(5)
.overlay(RoundedRectangle(cornerRadius: 99)
.inset(by: -0.5)
.stroke(.compound.borderInteractiveSecondary, lineWidth: 1))
.accessibilityLabel(L10n.actionShare)
}
}
.padding(.vertical, 12)
.rowDivider(alignment: .top)
}
.padding(.horizontal, 16)
}
}
struct UserLocationCell_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
UserLocationCell(profile: .mockDan,
isOwnUser: true,
kind: .static(isUserLocation: true, timestamp: .mock),
mediaProvider: MediaProviderMock(configuration: .init()))
.previewDisplayName("Stiatc user locaton")
.previewLayout(.sizeThatFits)
UserLocationCell(profile: .mockDan,
isOwnUser: false,
kind: .static(isUserLocation: false, timestamp: .mock),
mediaProvider: MediaProviderMock(configuration: .init()))
.previewDisplayName("Static pin location")
.previewLayout(.sizeThatFits)
UserLocationCell(profile: .mockDan,
isOwnUser: true,
kind: .live,
mediaProvider: MediaProviderMock(configuration: .init()))
.previewDisplayName("Live location")
.previewLayout(.sizeThatFits)
}
}