mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 15:26:40 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user