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:
@@ -189,7 +189,7 @@
|
||||
"common_about" = "About";
|
||||
"common_acceptable_use_policy" = "Acceptable use policy";
|
||||
"common_add_account" = "Add an account";
|
||||
"common_add_another_account" = "Add another account";
|
||||
"common_add_another_account" = "Add account";
|
||||
"common_adding_caption" = "Adding caption";
|
||||
"common_advanced_settings" = "Advanced settings";
|
||||
"common_an_image" = "an image";
|
||||
@@ -903,6 +903,9 @@
|
||||
"screen_link_new_device_root_title" = "What type of device do you want to link?";
|
||||
"screen_link_new_device_wrong_number_subtitle" = "Please try again and make sure that you’ve entered the 2-digit code correctly. If the numbers still don’t match then contact your account provider.";
|
||||
"screen_link_new_device_wrong_number_title" = "The numbers don’t match";
|
||||
"screen_live_location_sheet_nobody_sharing" = "Nobody is sharing their location";
|
||||
"screen_live_location_sheet_sharing_live_location" = "Sharing live location";
|
||||
"screen_live_location_sheet_title" = "On the map";
|
||||
"screen_login_error_deactivated_account" = "This account has been deactivated.";
|
||||
"screen_login_error_invalid_credentials" = "Incorrect username and/or password";
|
||||
"screen_login_error_invalid_user_id" = "This is not a valid user identifier. Expected format: ‘@user:homeserver.org’";
|
||||
@@ -1417,14 +1420,6 @@
|
||||
"screen_signout_save_recovery_key_title" = "Make sure you have access to your recovery key before removing this device";
|
||||
"screen_space_add_room_action" = "Room";
|
||||
"screen_space_add_rooms_room_access_description" = "Adding a room will not affect the room access. To change the access go to Room settings > Security & privacy.";
|
||||
"screen_space_announcement_item1" = "View spaces you've created or joined";
|
||||
"screen_space_announcement_item2" = "Accept or decline invites to spaces";
|
||||
"screen_space_announcement_item3" = "Discover any rooms you can join in your spaces";
|
||||
"screen_space_announcement_item4" = "Join public spaces";
|
||||
"screen_space_announcement_item5" = "Leave any spaces you’ve joined";
|
||||
"screen_space_announcement_notice" = "Filtering, creating and managing spaces is coming soon.";
|
||||
"screen_space_announcement_subtitle" = "Welcome to the beta version of Spaces! With this first version you can:";
|
||||
"screen_space_announcement_title" = "Introducing Spaces";
|
||||
"screen_space_empty_state_title" = "Add your first room";
|
||||
"screen_space_list_description" = "Spaces you have created or joined.";
|
||||
"screen_space_list_details" = "%1$@ • %2$@";
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"common_about" = "About";
|
||||
"common_acceptable_use_policy" = "Acceptable use policy";
|
||||
"common_add_account" = "Add an account";
|
||||
"common_add_another_account" = "Add another account";
|
||||
"common_add_another_account" = "Add account";
|
||||
"common_adding_caption" = "Adding caption";
|
||||
"common_advanced_settings" = "Advanced settings";
|
||||
"common_an_image" = "an image";
|
||||
@@ -903,6 +903,9 @@
|
||||
"screen_link_new_device_root_title" = "What type of device do you want to link?";
|
||||
"screen_link_new_device_wrong_number_subtitle" = "Please try again and make sure that you’ve entered the 2-digit code correctly. If the numbers still don’t match then contact your account provider.";
|
||||
"screen_link_new_device_wrong_number_title" = "The numbers don’t match";
|
||||
"screen_live_location_sheet_nobody_sharing" = "Nobody is sharing their location";
|
||||
"screen_live_location_sheet_sharing_live_location" = "Sharing live location";
|
||||
"screen_live_location_sheet_title" = "On the map";
|
||||
"screen_login_error_deactivated_account" = "This account has been deactivated.";
|
||||
"screen_login_error_invalid_credentials" = "Incorrect username and/or password";
|
||||
"screen_login_error_invalid_user_id" = "This is not a valid user identifier. Expected format: ‘@user:homeserver.org’";
|
||||
@@ -1417,14 +1420,6 @@
|
||||
"screen_signout_save_recovery_key_title" = "Make sure you have access to your recovery key before removing this device";
|
||||
"screen_space_add_room_action" = "Room";
|
||||
"screen_space_add_rooms_room_access_description" = "Adding a room will not affect the room access. To change the access go to Room settings > Security & privacy.";
|
||||
"screen_space_announcement_item1" = "View spaces you've created or joined";
|
||||
"screen_space_announcement_item2" = "Accept or decline invites to spaces";
|
||||
"screen_space_announcement_item3" = "Discover any rooms you can join in your spaces";
|
||||
"screen_space_announcement_item4" = "Join public spaces";
|
||||
"screen_space_announcement_item5" = "Leave any spaces you’ve joined";
|
||||
"screen_space_announcement_notice" = "Filtering, creating and managing spaces is coming soon.";
|
||||
"screen_space_announcement_subtitle" = "Welcome to the beta version of Spaces! With this first version you can:";
|
||||
"screen_space_announcement_title" = "Introducing Spaces";
|
||||
"screen_space_empty_state_title" = "Add your first room";
|
||||
"screen_space_list_description" = "Spaces you have created or joined.";
|
||||
"screen_space_list_details" = "%1$@ • %2$@";
|
||||
|
||||
@@ -354,6 +354,22 @@
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@COUNT@</string>
|
||||
</dict>
|
||||
<key>screen_live_location_sheet_subtitle</key>
|
||||
<dict>
|
||||
<key>COUNT</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%1$d person</string>
|
||||
<key>other</key>
|
||||
<string>%1$d people</string>
|
||||
</dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@COUNT@</string>
|
||||
</dict>
|
||||
<key>screen_pinned_timeline_screen_title</key>
|
||||
<dict>
|
||||
<key>COUNT</key>
|
||||
|
||||
@@ -418,7 +418,7 @@ internal enum L10n {
|
||||
internal static var commonAcceptableUsePolicy: String { return L10n.tr("Localizable", "common_acceptable_use_policy") }
|
||||
/// Add an account
|
||||
internal static var commonAddAccount: String { return L10n.tr("Localizable", "common_add_account") }
|
||||
/// Add another account
|
||||
/// Add account
|
||||
internal static var commonAddAnotherAccount: String { return L10n.tr("Localizable", "common_add_another_account") }
|
||||
/// Adding caption
|
||||
internal static var commonAddingCaption: String { return L10n.tr("Localizable", "common_adding_caption") }
|
||||
@@ -2118,6 +2118,16 @@ internal enum L10n {
|
||||
internal static var screenLinkNewDeviceWrongNumberSubtitle: String { return L10n.tr("Localizable", "screen_link_new_device_wrong_number_subtitle") }
|
||||
/// The numbers don’t match
|
||||
internal static var screenLinkNewDeviceWrongNumberTitle: String { return L10n.tr("Localizable", "screen_link_new_device_wrong_number_title") }
|
||||
/// Nobody is sharing their location
|
||||
internal static var screenLiveLocationSheetNobodySharing: String { return L10n.tr("Localizable", "screen_live_location_sheet_nobody_sharing") }
|
||||
/// Sharing live location
|
||||
internal static var screenLiveLocationSheetSharingLiveLocation: String { return L10n.tr("Localizable", "screen_live_location_sheet_sharing_live_location") }
|
||||
/// Plural format key: "%#@COUNT@"
|
||||
internal static func screenLiveLocationSheetSubtitle(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "screen_live_location_sheet_subtitle", p1)
|
||||
}
|
||||
/// On the map
|
||||
internal static var screenLiveLocationSheetTitle: String { return L10n.tr("Localizable", "screen_live_location_sheet_title") }
|
||||
/// This account has been deactivated.
|
||||
internal static var screenLoginErrorDeactivatedAccount: String { return L10n.tr("Localizable", "screen_login_error_deactivated_account") }
|
||||
/// Incorrect username and/or password
|
||||
@@ -3283,22 +3293,6 @@ internal enum L10n {
|
||||
internal static var screenSpaceAddRoomAction: String { return L10n.tr("Localizable", "screen_space_add_room_action") }
|
||||
/// Adding a room will not affect the room access. To change the access go to Room settings > Security & privacy.
|
||||
internal static var screenSpaceAddRoomsRoomAccessDescription: String { return L10n.tr("Localizable", "screen_space_add_rooms_room_access_description") }
|
||||
/// View spaces you've created or joined
|
||||
internal static var screenSpaceAnnouncementItem1: String { return L10n.tr("Localizable", "screen_space_announcement_item1") }
|
||||
/// Accept or decline invites to spaces
|
||||
internal static var screenSpaceAnnouncementItem2: String { return L10n.tr("Localizable", "screen_space_announcement_item2") }
|
||||
/// Discover any rooms you can join in your spaces
|
||||
internal static var screenSpaceAnnouncementItem3: String { return L10n.tr("Localizable", "screen_space_announcement_item3") }
|
||||
/// Join public spaces
|
||||
internal static var screenSpaceAnnouncementItem4: String { return L10n.tr("Localizable", "screen_space_announcement_item4") }
|
||||
/// Leave any spaces you’ve joined
|
||||
internal static var screenSpaceAnnouncementItem5: String { return L10n.tr("Localizable", "screen_space_announcement_item5") }
|
||||
/// Filtering, creating and managing spaces is coming soon.
|
||||
internal static var screenSpaceAnnouncementNotice: String { return L10n.tr("Localizable", "screen_space_announcement_notice") }
|
||||
/// Welcome to the beta version of Spaces! With this first version you can:
|
||||
internal static var screenSpaceAnnouncementSubtitle: String { return L10n.tr("Localizable", "screen_space_announcement_subtitle") }
|
||||
/// Introducing Spaces
|
||||
internal static var screenSpaceAnnouncementTitle: String { return L10n.tr("Localizable", "screen_space_announcement_title") }
|
||||
/// Add your first room
|
||||
internal static var screenSpaceEmptyStateTitle: String { return L10n.tr("Localizable", "screen_space_empty_state_title") }
|
||||
/// Spaces you have created or joined.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// 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 CoreLocation
|
||||
|
||||
extension CLLocationCoordinate2D: @retroactive Equatable {
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,13 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
mapView.styleURL = dynamicMapURL
|
||||
}
|
||||
|
||||
// If the center coordinate was updated externally (not by the map itself), move the map.
|
||||
if let newCenter = mapCenterCoordinate,
|
||||
newCenter != context.coordinator.lastReportedCenter {
|
||||
context.coordinator.lastReportedCenter = newCenter
|
||||
mapView.setCenter(newCenter, animated: true)
|
||||
}
|
||||
|
||||
// Update existing annotation views with fresh SwiftUI content.
|
||||
// This handles the case where the annotation's view data changes after
|
||||
// the annotation was initially placed (e.g. user avatar loads asynchronously).
|
||||
@@ -169,6 +176,9 @@ extension MapLibreMapView {
|
||||
var mapLibreView: MapLibreMapView
|
||||
|
||||
private var previousUserLocation: MLNUserLocation?
|
||||
/// Tracks the last center coordinate reported by the map (or set programmatically),
|
||||
/// so that `updateUIView` can tell apart external binding changes from internal ones.
|
||||
var lastReportedCenter: CLLocationCoordinate2D?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
@@ -220,8 +230,10 @@ extension MapLibreMapView {
|
||||
|
||||
func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) {
|
||||
// Avoid `Publishing changes from within view update` warnings
|
||||
DispatchQueue.main.async { [mapLibreView] in
|
||||
mapLibreView.mapCenterCoordinate = mapView.centerCoordinate
|
||||
DispatchQueue.main.async { [mapLibreView, weak self] in
|
||||
let center = mapView.centerCoordinate
|
||||
self?.lastReportedCenter = center
|
||||
mapLibreView.mapCenterCoordinate = center
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
ElementX/Sources/Other/SwiftUI/Views/StopButton.swift
Normal file
23
ElementX/Sources/Other/SwiftUI/Views/StopButton.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// 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 StopButton: View {
|
||||
let stopAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button { stopAction() } label: {
|
||||
CompoundIcon(\.stop, size: .small, relativeTo: .compound.bodySMSemibold)
|
||||
.foregroundStyle(.compound.iconOnSolidPrimary)
|
||||
.padding(8)
|
||||
.background(Color.compound.bgCriticalPrimary, in: Circle())
|
||||
.accessibilityLabel(L10n.actionStop)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ enum TestablePreviewsDictionary {
|
||||
"LinkNewDeviceScreen_Previews" : LinkNewDeviceScreen_Previews.self,
|
||||
"LiveLocationRoomTimelineView_Previews" : LiveLocationRoomTimelineView_Previews.self,
|
||||
"LiveLocationSharingBannerView_Previews" : LiveLocationSharingBannerView_Previews.self,
|
||||
"LiveLocationSheet_Previews" : LiveLocationSheet_Previews.self,
|
||||
"LoadableImage_Previews" : LoadableImage_Previews.self,
|
||||
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
|
||||
"LocationPickerSheet_Previews" : LocationPickerSheet_Previews.self,
|
||||
@@ -214,6 +215,7 @@ enum TestablePreviewsDictionary {
|
||||
"UserDetailsEditScreen_Previews" : UserDetailsEditScreen_Previews.self,
|
||||
"UserIndicatorModalView_Previews" : UserIndicatorModalView_Previews.self,
|
||||
"UserIndicatorToastView_Previews" : UserIndicatorToastView_Previews.self,
|
||||
"UserLocationCell_Previews" : UserLocationCell_Previews.self,
|
||||
"UserProfileCell_Previews" : UserProfileCell_Previews.self,
|
||||
"UserProfileScreen_Previews" : UserProfileScreen_Previews.self,
|
||||
"VerificationBadge_Previews" : VerificationBadge_Previews.self,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ struct DeveloperOptionsScreen: View {
|
||||
|
||||
Toggle(isOn: $context.liveLocationSharingEnabled) {
|
||||
Text("Live location sharing")
|
||||
Text("Requires app reboot")
|
||||
}
|
||||
|
||||
Toggle(isOn: $context.knockingEnabled) {
|
||||
|
||||
@@ -173,15 +173,7 @@ struct LiveLocationRoomTimelineView: View {
|
||||
Spacer()
|
||||
|
||||
if isLive, timelineItem.isOutgoing {
|
||||
Button {
|
||||
stop()
|
||||
} label: {
|
||||
CompoundIcon(\.stop, size: .small, relativeTo: .compound.bodySMSemibold)
|
||||
.foregroundStyle(.compound.iconOnSolidPrimary)
|
||||
.padding(5)
|
||||
.background(Color.compound.bgCriticalPrimary, in: Circle())
|
||||
.accessibilityLabel(L10n.actionStop)
|
||||
}
|
||||
StopButton { stop() }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
struct LiveLocationShare: Hashable {
|
||||
struct LiveLocationShare: Hashable, Identifiable {
|
||||
let userID: String
|
||||
let geoURI: GeoURI?
|
||||
let timestamp: Date
|
||||
let timeoutDate: Date
|
||||
|
||||
var id: String {
|
||||
userID
|
||||
}
|
||||
|
||||
init(userID: String, geoURI: GeoURI?, timestamp: Date, timeoutDate: Date) {
|
||||
self.userID = userID
|
||||
self.geoURI = geoURI
|
||||
|
||||
Reference in New Issue
Block a user