Files
letro-ios/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/LiveLocationRoomTimelineView.swift
Mauro 2e9a499fc3 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
2026-04-17 11:13:16 +00:00

310 lines
14 KiB
Swift

//
// 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 Combine
import Compound
import SwiftUI
struct LiveLocationRoomTimelineView: View {
@Environment(\.timelineContext) private var context: TimelineViewModel.Context!
@State private var hasExpired: Bool
let timelineItem: LiveLocationRoomTimelineItem
private let currentDate: Date
init(currentDate: Date = .now, timelineItem: LiveLocationRoomTimelineItem, isStopped: Bool = false) {
self.currentDate = currentDate
self.timelineItem = timelineItem
_hasExpired = State(initialValue: isStopped || currentDate >= timelineItem.content.timeoutDate)
}
/// A publisher that fires once when the timeoutDate is reached, setting `hasExpired` to true.
private var timeoutPublisher: AnyPublisher<Void, Never> {
guard timelineItem.content.isLive else {
return Empty().eraseToAnyPublisher()
}
let delay = timelineItem.content.timeoutDate.timeIntervalSinceNow
guard delay > 0 else {
return Empty().eraseToAnyPublisher()
}
return Just(())
.delay(for: .seconds(delay), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
private var isLive: Bool {
timelineItem.content.isLive && !hasExpired
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
mainContent
}
}
private var mainContent: some View {
ZStack(alignment: .bottom) {
mapView
.clipped()
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonSharedLiveLocation)
liveLocationInfoView
}
.frame(maxHeight: mapMaxHeight)
.aspectRatio(mapAspectRatio, contentMode: .fit)
.overlay {
if !isLive {
RoundedRectangle(cornerRadius: 12)
.inset(by: 0.5)
.stroke(Color.compound.borderDisabled)
}
}
.onTapGesture {
guard context.viewState.mapTilerConfiguration.isEnabled,
timelineItem.content.lastGeoURI != nil,
isLive else {
return
}
context.send(viewAction: .mediaTapped(itemID: timelineItem.id))
}
}
@ViewBuilder
private var mapView: some View {
if isLive {
liveContent
.onReceive(timeoutPublisher) { _ in
hasExpired = true
}
} else {
ZStack {
Image(asset: Asset.Images.placeholderMap)
.resizable()
LocationMarkerView(kind: .placeholder)
}
}
}
@ViewBuilder
private var liveContent: some View {
if let geoURI = timelineItem.content.lastGeoURI {
MapLibreStaticMapView(geoURI: geoURI,
mapURLBuilder: context.viewState.mapTilerConfiguration,
attributionPlacement: .topLeft,
mapSize: .init(width: mapAspectRatio * mapMaxHeight, height: mapMaxHeight)) {
LocationMarkerView(kind: .liveUser(.init(sender: timelineItem.sender)),
mediaProvider: context.mediaProvider)
}
} else {
Image(asset: Asset.Images.mapBlurred)
.resizable()
}
}
private var liveLocationStateString: String {
isLive ? L10n.commonLiveLocation : L10n.commonLiveLocationEnded
}
private var liveLocationStateColor: Color {
isLive ? .compound.textPrimary : .compound.textSecondary
}
private var liveLocationIconColor: Color {
if isLive {
timelineItem.content.lastGeoURI != nil ? .compound.iconAccentPrimary : .compound.iconSecondary
} else {
.compound.iconDisabled
}
}
private var liveLocationBackgroundColor: Color {
isLive ? .compound.bgCanvasDefault : .compound.bgSubtleSecondary
}
private var blurBackground: some View {
Color.compound.bgCanvasDefault
.opacity(0.8)
.background(.ultraThinMaterial)
}
private var infoIcon: KeyPath<CompoundIcons, Image> {
if timelineItem.content.lastGeoURI != nil || !isLive {
\.locationPinSolid
} else {
\.spinner
}
}
private var liveLocationInfoView: some View {
HStack(spacing: 8) {
CompoundIcon(infoIcon, size: .medium, relativeTo: .compound.bodySMSemibold)
.foregroundStyle(liveLocationIconColor)
.padding(4)
.background(liveLocationBackgroundColor)
.cornerRadius(8)
.overlay {
if isLive {
RoundedRectangle(cornerRadius: 8)
.inset(by: 0.5)
.stroke(Color.compound.iconQuaternaryAlpha)
}
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 0) {
Text(liveLocationStateString)
.foregroundStyle(liveLocationStateColor)
.font(.compound.bodySMSemibold)
if isLive {
Text(L10n.commonEndsAt(timelineItem.content.timeoutDate.formattedExpiration()))
.foregroundStyle(.compound.textPrimary)
.font(.compound.bodySM)
}
}
.accessibilityElement(children: .combine)
Spacer()
if isLive, timelineItem.isOutgoing {
StopButton { stop() }
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(blurBackground)
}
private func stop() {
hasExpired = true
context?.send(viewAction: .stopLiveLocationSharing(timelineItem.id))
}
// MARK: - Constants
private let mapAspectRatio: Double = 3 / 2
private let mapMaxHeight: Double = 300
}
private extension Date {
/// A fixed date representing today at 4:10 AM, used for mocks and previews.
static var mockToday410: Date {
// swiftlint:disable:next force_unwrapping
Calendar.current.date(bySettingHour: 4, minute: 10, second: 0, of: .now)!
}
/// A fixed date representing today at 4:20 AM, used for mocks and previews.
static var mockToday420: Date {
// swiftlint:disable:next force_unwrapping
Calendar.current.date(bySettingHour: 4, minute: 20, second: 0, of: .now)!
}
/// A fixed date representing today at 4:30 AM, used for mocks and previews.
static var mockToday430: Date {
// swiftlint:disable:next force_unwrapping
Calendar.current.date(bySettingHour: 4, minute: 30, second: 0, of: .now)!
}
}
struct LiveLocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
PreviewScrollView {
VStack(spacing: 8) {
states
}
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
.previewLayout(.sizeThatFits)
.previewDisplayName("Bubbles")
}
@ViewBuilder
static var states: some View {
// No location yet (beacon not yet received)
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob"),
content: .init(isLive: true,
timeoutDate: .mockToday420,
lastGeoURI: nil)))
// With a known location
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(isLive: true,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366))))
// Stopped live location
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(isLive: false,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366))))
// Expired live location
LiveLocationRoomTimelineView(currentDate: .mockToday430,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(isLive: true,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366))))
// Replying to a live location
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(isLive: true,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366)),
properties: .init(replyDetails: .loaded(sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
eventID: "123",
eventContent: .liveLocation))))
// Replying to a live location when the content is not live
LiveLocationRoomTimelineView(currentDate: .mockToday410,
timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: .mockMXCUserAvatar),
content: .init(isLive: false,
timeoutDate: .mockToday420,
lastGeoURI: .init(latitude: 41.902782, longitude: 12.496366)),
properties: .init(replyDetails: .loaded(sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
eventID: "123",
eventContent: .liveLocation))))
}
}