Render the live location timeline item (#5209)

* created the data for the timeline item

# Conflicts:
#	ElementX/Sources/Other/Logging/Tracing.swift
#	ElementX/Sources/Services/Authentication/AuthenticationService.swift
#	ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift

* Implemented the LLS timeline item

* updated preview tests

* pr and design suggestions

* previewscrollview is debug only
This commit is contained in:
Mauro
2026-03-24 10:34:00 +01:00
committed by GitHub
parent 610c296125
commit b8d337e4ce
51 changed files with 799 additions and 93 deletions
@@ -34,6 +34,25 @@ extension Date {
}
}
/// The date of an expiration formatted with the minimal necessary units given how long in the future it is.
func formattedExpiration() -> String {
let calendar = Calendar.current
let now = Date.now
guard let tomorrow = calendar.date(byAdding: .hour, value: 24, to: now),
let oneYearFromNow = calendar.date(byAdding: .year, value: 1, to: now) else {
return formatted(date: .omitted, time: .shortened)
}
if self < tomorrow {
return formatted(date: .omitted, time: .shortened)
} else if self < oneYearFromNow {
return formatted(.dateTime.day().month())
} else {
return formatted(.dateTime.year())
}
}
/// Similar to ``formattedMinimal`` but returning "Today" instead of the time and
/// including the year when it the date is from a previous year (rather than over a year ago).
func formattedDateSeparator() -> String {
@@ -60,6 +79,12 @@ extension Date {
formatted(date: .omitted, time: .shortened)
}
/// A fixed date representing today at 4:20 AM, used for mocks and previews.
static var mockToday420: Date {
// swiftlint:disable:next force_unwrap
Calendar.current.date(bySettingHour: 4, minute: 20, second: 0, of: .now)!
}
/// A fixed date used for mocks, previews etc.
static var mock: Date {
DateComponents(calendar: .current, year: 2007, month: 1, day: 9, hour: 9, minute: 41).date ?? .now
@@ -34,6 +34,19 @@ struct MapLibreStaticMapView<PinAnnotation: View>: View {
self.pinAnnotationView = pinAnnotationView()
}
init(geoURI: GeoURI,
mapURLBuilder: MapTilerURLBuilderProtocol,
attributionPlacement: MapTilerAttributionPlacement = .bottomLeft,
mapSize: CGSize,
@ViewBuilder pinAnnotationView: () -> PinAnnotation) {
self.init(coordinates: .init(latitude: geoURI.latitude, longitude: geoURI.longitude),
zoomLevel: 15,
attributionPlacement: attributionPlacement,
mapURLBuilder: mapURLBuilder,
mapSize: mapSize,
pinAnnotationView: pinAnnotationView)
}
var body: some View {
GeometryReader { geometry in
if let url = mapURLBuilder.staticMapTileImageURL(for: colorScheme.mapStyle,
@@ -0,0 +1,27 @@
//
// 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 SwiftUI
#if DEBUG
/// Only use in Previews! This useful scroll view allows you to still have a scroll view when previewing in Xcode
/// but ignores it when running the tests, which allows you to still use it's content directly in preview tests
/// and render the preview with `sizeThatFits` layout.
struct PreviewScrollView<Content: View>: View {
var content: () -> Content
var body: some View {
if ProcessInfo.isRunningTests {
content()
} else {
ScrollView {
content()
}
}
}
}
#endif
@@ -10,16 +10,10 @@ import Compound
import SwiftUI
struct LocationMarkerView: View {
var userProfile: UserProfileProxy?
var fillColor = Color.compound.bgCanvasDefault
var strokeColor = Color.compound.iconSecondaryAlpha
var dotColor = Color.compound.iconPrimary
var kind: LocationMarkerKind
@ScaledMetric var size: CGFloat = 42
var mediaProvider: MediaProviderProtocol?
private let circleCenter = CGPoint(x: 21, y: 21) // in SVG space
private let circleRadius: CGFloat = 6 // in SVG space
var body: some View {
// Generated from the SVG
Canvas { context, canvasSize in
@@ -55,7 +49,7 @@ struct LocationMarkerView: View {
p.closeSubpath()
}
context.stroke(pinPath, with: .color(strokeColor), lineWidth: 2 * scaleX)
context.stroke(pinPath, with: .color(externalStrokeColor), lineWidth: 2 * scaleX)
context.fill(pinPath, with: .color(fillColor))
// Dot
@@ -66,20 +60,20 @@ struct LocationMarkerView: View {
context.fill(dotPath, with: .color(dotColor))
// Draw resolved symbol centered on the circle
if userProfile != nil, let symbol = context.resolveSymbol(id: 0) {
if kind.userProfile != nil, let symbol = context.resolveSymbol(id: 0) {
let center = CGPoint(x: circleCenter.x * scaleX,
y: circleCenter.y * scaleY)
context.draw(symbol, at: center, anchor: .center)
}
} symbols: {
if let userProfile {
if let userProfile = kind.userProfile {
LoadableAvatarImage(url: userProfile.avatarURL,
name: userProfile.displayName,
contentID: userProfile.userID,
avatarSize: .user(on: .map),
mediaProvider: mediaProvider)
.overlay {
Circle().inset(by: 0.5).stroke(strokeColor)
Circle().inset(by: 0.5).stroke(internalStrokeColor)
}
.tag(0)
}
@@ -89,22 +83,77 @@ struct LocationMarkerView: View {
dimensions[.bottom]
}
}
private let circleCenter = CGPoint(x: 21, y: 21) // in SVG space
private let circleRadius: CGFloat = 6 // in SVG space
private var fillColor: Color {
switch kind {
case .pin, .staticUser:
.compound.bgCanvasDefault
case .liveUser:
.compound.iconAccentPrimary
case .placeholder:
.compound.bgSubtleSecondary
}
}
private var externalStrokeColor: Color {
switch kind {
case .pin, .staticUser:
.compound.iconSecondaryAlpha
case .liveUser:
.compound.iconAccentPrimary
case .placeholder:
.compound.iconDisabled
}
}
private var internalStrokeColor: Color {
switch kind {
case .pin, .staticUser:
.compound.iconSecondaryAlpha
case .liveUser:
.compound.bgCanvasDefault
case .placeholder:
.compound.iconDisabled
}
}
private var dotColor: Color {
switch kind {
case .placeholder:
.compound.iconDisabled
default:
.compound.iconPrimary
}
}
}
struct LocationMarkerView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 30) {
LocationMarkerView()
// Placeholder
LocationMarkerView(kind: .placeholder)
LocationMarkerView()
.colorScheme(.dark)
// Pin (no user)
LocationMarkerView(kind: .pin)
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
// Static user with avatar
LocationMarkerView(kind: .staticUser(.mockDan),
mediaProvider: MediaProviderMock(configuration: .init()))
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
// Static user without avatar
LocationMarkerView(kind: .staticUser(.init(userID: "@someone:matrix.org",
displayName: "Someone")))
// Live user with avatar
LocationMarkerView(kind: .liveUser(.mockDan),
mediaProvider: MediaProviderMock(configuration: .init()))
.colorScheme(.dark)
// Live user without avatar
LocationMarkerView(kind: .liveUser(.init(userID: "@someone:matrix.org",
displayName: "Someone")))
}
.padding(16)
.background(Color(red: 0.9, green: 0.85, blue: 0.8))
@@ -80,6 +80,7 @@ enum TestablePreviewsDictionary {
"LeaveSpaceView_Previews" : LeaveSpaceView_Previews.self,
"LegalInformationScreen_Previews" : LegalInformationScreen_Previews.self,
"LinkNewDeviceScreen_Previews" : LinkNewDeviceScreen_Previews.self,
"LiveLocationRoomTimelineView_Previews" : LiveLocationRoomTimelineView_Previews.self,
"LoadableImage_Previews" : LoadableImage_Previews.self,
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
"LocationPickerSheet_Previews" : LocationPickerSheet_Previews.self,