Start implementing LLS timeline item

This commit is contained in:
ganfra 2026-03-24 16:38:12 +01:00
parent a22c9871e3
commit b082f59f9c
16 changed files with 356 additions and 121 deletions

View file

@ -9,7 +9,9 @@
package io.element.android.features.location.api
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.Extras
import coil3.compose.AsyncImagePainter
@ -38,68 +42,131 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Shows a static map image downloaded via a third party service's static maps API.
*
* Handles 4 distinct cases:
* 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching
* 2. Null location - shows blurred placeholder, no pin, no loading
* 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
* 4. Success (location != null, loaded) - shows actual map with pin
*/
@Composable
fun StaticMapView(
lat: Double,
lon: Double,
location: Location?,
zoom: Double,
pinVariant: PinVariant,
contentDescription: String?,
modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.isLightTheme,
) {
// Using BoxWithConstraints to:
// 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints.
// 2) Request the static map image of the exact required size in Px to fill the AsyncImage.
BoxWithConstraints(
modifier = modifier,
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder() }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
null
} else {
ImageRequest.Builder(context)
.data(
builder.build(
lat = lat,
lon = lon,
zoom = zoom,
darkMode = darkMode,
width = constraints.maxWidth,
height = constraints.maxHeight,
density = LocalDensity.current.density,
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)
.apply {
extras.set(Extras.Key("retry_hash"), retryHash).build()
}
.build()
// Case 1: Stale location - show stale map with stale pin, no fetching
when {
pinVariant is PinVariant.StaleLocation -> {
StaleMapContent(
pinVariant = pinVariant,
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
)
}
)
// Case 2: Null location - show blurred placeholder, no pin, no loading
location == null -> {
StaticMapPlaceholder(
painter = painterResource(R.drawable.blurred_map),
canReload = false,
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
onLoadMapClick = {}
)
}
// Cases 3 & 4: Non-null location - fetch map
else -> LoadableMapContent(
location = location,
zoom = zoom,
pinVariant = pinVariant,
contentDescription = contentDescription,
darkMode = darkMode,
)
}
}
}
val collectedState = painter.state.collectAsState()
if (collectedState.value is AsyncImagePainter.State.Success) {
@Composable
private fun BoxWithConstraintsScope.StaleMapContent(
pinVariant: PinVariant,
contentDescription: String?,
width: Dp,
height: Dp,
) {
Box(contentAlignment = Alignment.Center) {
Image(
painter = painterResource(R.drawable.stale_map),
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(width = width, height = height)
)
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this@StaleMapContent))
}
}
@Composable
private fun BoxWithConstraintsScope.LoadableMapContent(
location: Location,
zoom: Double,
pinVariant: PinVariant,
contentDescription: String?,
darkMode: Boolean,
) {
val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder() }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero
null
} else {
ImageRequest.Builder(context)
.data(
builder.build(
lat = location.lat,
lon = location.lon,
zoom = zoom,
darkMode = darkMode,
width = constraints.maxWidth,
height = constraints.maxHeight,
density = LocalDensity.current.density,
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)
.apply {
extras.set(Extras.Key("retry_hash"), retryHash).build()
}
.build()
}
)
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Success -> {
Image(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight),
// The returned image can be smaller than the requested size due to the static maps API having
// a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
// a max width and height of 2048 px. We apply ContentScale.Fit to handle this.
contentScale = ContentScale.Fit,
)
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
} else {
}
else -> {
StaticMapPlaceholder(
showProgress = collectedState.value.isLoading(),
canReload = builder.isServiceAvailable(),
painter = painterResource(R.drawable.blurred_map),
canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
@ -109,17 +176,11 @@ fun StaticMapView(
}
}
private fun AsyncImagePainter.State.isLoading(): Boolean {
return this is AsyncImagePainter.State.Empty ||
this is AsyncImagePainter.State.Loading
}
@PreviewsDayNight
@Composable
internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView(
lat = 0.0,
lon = 0.0,
location = Location(0.0, 0.0),
zoom = 0.0,
contentDescription = null,
pinVariant = PinVariant.PinnedLocation,

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -34,7 +35,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun StaticMapPlaceholder(
showProgress: Boolean,
painter: Painter,
canReload: Boolean,
contentDescription: String?,
width: Dp,
@ -46,17 +47,15 @@ internal fun StaticMapPlaceholder(
contentAlignment = Alignment.Center,
modifier = modifier
.size(width = width, height = height)
.then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick))
.clickable(enabled = canReload, onClick = onLoadMapClick)
) {
Image(
painter = painterResource(id = R.drawable.blurred_map),
painter = painter,
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(width = width, height = height)
)
if (showProgress) {
CircularProgressIndicator()
} else if (canReload) {
if (canReload) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -77,13 +76,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview {
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(
true to false,
false to true,
false to false,
).forEach { (showProgress, canReload) ->
listOf(false, true)
.forEach { canReload ->
StaticMapPlaceholder(
showProgress = showProgress,
painter = painterResource(R.drawable.blurred_map),
canReload = canReload,
contentDescription = null,
width = 400.dp,

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -57,4 +57,6 @@ sealed interface TimelineEvent {
data class EditPoll(
val pollStartId: EventId,
) : TimelineItemPollEvent
data object StopLiveLocationShare : TimelineItemEvent
}

View file

@ -197,6 +197,7 @@ class TimelinePresenter(
is TimelineEvent.EditPoll -> {
navigator.navigateToEditPoll(event.pollStartId)
}
is TimelineEvent.StopLiveLocationShare -> Unit
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
delay(event.debounce)

View file

@ -269,7 +269,9 @@ fun TimelineItemEventRow(
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
ThreadSummaryView(
modifier = if (event.isMine) {
Modifier.align(Alignment.End).padding(end = 16.dp)
Modifier
.align(Alignment.End)
.padding(end = 16.dp)
} else {
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
}.padding(top = 2.dp),
@ -674,6 +676,7 @@ private fun MessageEventBubbleContent(
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
TimestampPosition.Hidden -> Box(modifier) { content {} }
}
}
@ -763,11 +766,11 @@ private fun MessageEventBubbleContent(
}
}
val timestampPosition = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemStickerContent,
is TimelineItemLocationContent -> TimestampPosition.Overlay
val timestampPosition = when (val content = event.content) {
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> if (content.hideTimestamp) TimestampPosition.Hidden else TimestampPosition.Overlay
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
@ -833,25 +836,27 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
groupPosition = TimelineItemGroupPosition.First,
threadInfo = TimelineItemThreadInfo.ThreadRoot(
latestEventText = "This is the latest message in the thread",
summary = ThreadSummary(AsyncData.Success(
EmbeddedEventInfo(
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
content = MessageContent(
body = "This is the latest message in the thread",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("This is the latest message in the thread", null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
avatarUrl = null,
displayNameAmbiguous = false,
),
timestamp = 0L,
)
), numberOfReplies = 20L)
summary = ThreadSummary(
AsyncData.Success(
EmbeddedEventInfo(
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
content = MessageContent(
body = "This is the latest message in the thread",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("This is the latest message in the thread", null)
),
senderId = UserId("@user:id"),
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
avatarUrl = null,
displayNameAmbiguous = false,
),
timestamp = 0L,
)
), numberOfReplies = 20L
)
)
),
displayThreadSummaries = true,

View file

@ -22,7 +22,12 @@ enum class TimestampPosition {
/**
* Timestamp should always be rendered below the timeline event content (eg. poll).
*/
Below;
Below,
/**
* Timestamp should be hidden.
*/
Hidden;
companion object {
/**

View file

@ -73,6 +73,7 @@ fun TimelineItemEventContentView(
)
is TimelineItemLocationContent -> TimelineItemLocationView(
content = content,
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
modifier = modifier
)
is TimelineItemImageContent -> TimelineItemImageView(

View file

@ -8,33 +8,147 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.StaticMapView
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemLocationView(
content: TimelineItemLocationContent,
onStopLiveLocationClick: () -> Unit,
modifier: Modifier = Modifier,
) {
StaticMapView(
Box(modifier = modifier.fillMaxWidth()) {
StaticMapView(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 188.dp),
pinVariant = content.pinVariant,
location = content.location,
zoom = 15.0,
contentDescription = content.description
)
if (content.mode is TimelineItemLocationContent.Mode.Live) {
LiveLocationOverlay(
mode = content.mode,
onStopClick = onStopLiveLocationClick,
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
}
@Composable
private fun LiveLocationOverlay(
mode: TimelineItemLocationContent.Mode.Live,
onStopClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(max = 188.dp),
pinVariant = content.pinVariant,
lat = content.location.lat,
lon = content.location.lon,
zoom = 15.0,
contentDescription = content.description
)
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val iconShape = RoundedCornerShape(8.dp)
Box(
modifier = Modifier
.size(32.dp)
.border(
width = 1.dp,
color = if (mode.isActive) ElementTheme.colors.iconQuaternaryAlpha else Color.Transparent,
shape = iconShape,
)
.background(
color = if (mode.isActive) {
ElementTheme.colors.bgCanvasDefault
} else {
ElementTheme.colors.bgSubtleSecondary
},
shape = iconShape
)
) {
if (mode.isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = ElementTheme.colors.iconSecondary,
modifier = Modifier
.align(Alignment.Center)
.size(20.dp)
)
} else {
Icon(
imageVector = CompoundIcons.LocationPinSolid(),
contentDescription = null,
tint = if (mode.isActive) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.iconDisabled
},
modifier = Modifier.align(Alignment.Center)
)
}
}
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (mode.isActive) "Live location" else "Live location ended",
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary,
)
if (mode.isActive) {
Text(
text = mode.endsAt,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
if (mode.isActive && mode.canStop) {
IconButton(
onClick = onStopClick,
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgCriticalPrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
) {
Icon(
imageVector = CompoundIcons.Stop(),
contentDescription = null,
)
}
}
}
}
@PreviewsDayNight
@ -43,5 +157,6 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat
ElementPreview {
TimelineItemLocationView(
content = content,
onStopLiveLocationClick = {},
)
}

View file

@ -15,6 +15,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
@ -103,18 +105,18 @@ class TimelineItemContentFactory(
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
Location.fromGeoUri(beacon.geoUri)
}.lastOrNull()
if (lastKnownLocation != null) {
TimelineItemLocationContent(
description = itemContent.description?.trimEnd(),
assetType = itemContent.assetType,
senderId = sender,
senderProfile = senderProfile,
location = lastKnownLocation,
mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive)
)
} else {
TimelineItemUnknownContent
}
// Always create content - location can be null for "loading/waiting" state
TimelineItemLocationContent(
description = itemContent.description?.trimEnd(),
assetType = itemContent.assetType,
senderId = sender,
senderProfile = senderProfile,
mode = TimelineItemLocationContent.Mode.Live(
lastKnownLocation = lastKnownLocation,
isActive = itemContent.isLive,
endsAt = "",
),
)
}
}
}

View file

@ -150,12 +150,11 @@ class TimelineItemContentMessageFactory(
)
} else {
TimelineItemLocationContent(
location = location,
description = messageType.description,
senderId = senderId,
senderProfile = senderProfile,
assetType = messageType.assetType,
mode = TimelineItemLocationContent.Mode.Static
mode = TimelineItemLocationContent.Mode.Static(location = location)
)
}
}

View file

@ -35,7 +35,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemUnknownContent(),
aTimelineItemTextContent().copy(isEdited = true),
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", lastKnownLocation = null)),
)
}

View file

@ -21,12 +21,23 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayNam
data class TimelineItemLocationContent(
val senderId: UserId,
val senderProfile: ProfileDetails,
val location: Location,
val description: String? = null,
val assetType: AssetType? = null,
val mode: Mode,
) : TimelineItemEventContent {
val pinVariant = when (mode) {
val hideTimestamp = mode is Mode.Live && mode.canStop
val location = when (mode) {
is Mode.Live -> mode.lastKnownLocation
is Mode.Static -> mode.location
}
/**
* The pin variant to display on the map.
* Returns a default variant when location is null (map will show loading placeholder anyway).
*/
val pinVariant: PinVariant = when (mode) {
is Mode.Live -> {
if (mode.isActive) {
PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true)
@ -34,7 +45,7 @@ data class TimelineItemLocationContent(
PinVariant.StaleLocation
}
}
Mode.Static -> {
is Mode.Static -> {
when (assetType) {
AssetType.PIN -> PinVariant.PinnedLocation
AssetType.SENDER,
@ -52,8 +63,18 @@ data class TimelineItemLocationContent(
)
sealed interface Mode {
data object Static : Mode
data class Live(val isActive: Boolean) : Mode
data class Static(
val location: Location,
) : Mode
data class Live(
val lastKnownLocation: Location?,
val isActive: Boolean,
val endsAt: String,
val canStop: Boolean = false
) : Mode {
val isLoading = lastKnownLocation == null && isActive
}
}
override val type: String = "TimelineItemLocationContent"

View file

@ -18,8 +18,35 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
override val values: Sequence<TimelineItemLocationContent>
get() = sequenceOf(
aTimelineItemLocationContent(),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
canStop = true,
lastKnownLocation = aLocation()
),
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
lastKnownLocation = aLocation()
),
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
lastKnownLocation = null
),
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = false,
endsAt = "",
lastKnownLocation = aLocation()
),
),
)
}
@ -27,15 +54,16 @@ fun aTimelineItemLocationContent(
senderId: UserId = UserId("@sender:matrix.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(),
description: String? = null,
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static,
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
) = TimelineItemLocationContent(
location = Location(
lat = 52.2445,
lon = 0.7186,
accuracy = 5000f,
),
senderId = senderId,
senderProfile = senderProfile,
description = description,
mode = mode
mode = mode,
)
fun aLocation() = Location(
lat = 52.2445,
lon = 0.7186,
accuracy = 5000f,
)

View file

@ -110,7 +110,6 @@ class TimelineItemContentMessageFactoryTest {
eventId = AN_EVENT_ID,
)
val expected = TimelineItemLocationContent(
body = "body",
location = Location(lat = 1.0, lon = 2.0, accuracy = null),
description = "description",
assetType = assetType,