Make sure we can display both Live and Static locations in ShowLocation

This commit is contained in:
ganfra
2026-02-26 22:07:30 +01:00
parent 222b9f0c9e
commit b4cf8c274e
16 changed files with 168 additions and 67 deletions

View File

@@ -15,8 +15,7 @@ import io.element.android.libraries.architecture.NodeInputs
interface ShowLocationEntryPoint : FeatureEntryPoint {
data class Inputs(
val location: Location,
val description: String?,
val mode: ShowLocationMode,
) : NodeInputs
fun createNode(

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 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.
*/
package io.element.android.features.location.api
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.parcelize.Parcelize
sealed interface ShowLocationMode : Parcelable {
@Parcelize
data class Static(
val location: Location,
val senderName: String,
val senderId: UserId,
val senderAvatarUrl: String?,
val timestamp: Long,
val assetType: AssetType?,
) : ShowLocationMode
@Parcelize
data object Live : ShowLocationMode
}

View File

@@ -40,7 +40,7 @@ class ShowLocationNode(
}
private val inputs: ShowLocationEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(inputs.location, inputs.description)
private val presenter = presenterFactory.create(inputs.mode)
@Composable
override fun View(modifier: Modifier) {

View File

@@ -18,7 +18,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
@@ -29,15 +29,14 @@ import io.element.android.libraries.core.meta.BuildMeta
@AssistedInject
class ShowLocationPresenter(
@Assisted private val location: Location,
@Assisted private val description: String?,
@Assisted private val mode: ShowLocationMode,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
) : Presenter<ShowLocationState> {
@AssistedFactory
fun interface Factory {
fun create(location: Location, description: String?): ShowLocationPresenter
fun create(mode: ShowLocationMode): ShowLocationPresenter
}
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
@@ -59,7 +58,16 @@ class ShowLocationPresenter(
fun handleEvent(event: ShowLocationEvents) {
when (event) {
ShowLocationEvents.Share -> locationActions.share(location, description)
ShowLocationEvents.Share -> {
when (mode) {
is ShowLocationMode.Static -> {
locationActions.share(mode.location, null)
}
ShowLocationMode.Live -> {
// TODO: Handle sharing for live locations
}
}
}
is ShowLocationEvents.TrackMyLocation -> {
if (event.enabled) {
when {
@@ -82,8 +90,7 @@ class ShowLocationPresenter(
return ShowLocationState(
permissionDialog = permissionDialog,
location = location,
description = description,
mode = mode,
hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation,
appName = appName,

View File

@@ -8,12 +8,11 @@
package io.element.android.features.location.impl.show
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
data class ShowLocationState(
val permissionDialog: Dialog,
val location: Location,
val description: String?,
val mode: ShowLocationMode,
val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean,
val appName: String,

View File

@@ -10,6 +10,9 @@ package io.element.android.features.location.impl.show
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
private const val APP_NAME = "ApplicationName"
@@ -31,32 +34,47 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
isTrackMyLocation = true,
),
aShowLocationState(
description = "My favourite place!",
mode = aStaticLocationMode(senderName = "My favourite place!"),
),
aShowLocationState(
description = "For some reason I decided to to write a small essay that wraps at just two lines!",
mode = aStaticLocationMode(
senderName = "For some reason I decided to write a small essay that wraps at just two lines!"
),
),
aShowLocationState(
description = "For some reason I decided to write a small essay in the location description. " +
"It is so long that it will wrap onto more than two lines!",
mode = ShowLocationMode.Live,
),
)
}
fun aShowLocationState(
permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None,
location: Location = Location(1.23, 2.34, 4f),
description: String? = null,
mode: ShowLocationMode = aStaticLocationMode(),
hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false,
appName: String = APP_NAME,
eventSink: (ShowLocationEvents) -> Unit = {},
) = ShowLocationState(
permissionDialog = permissionDialog,
location = location,
description = description,
mode = mode,
hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation,
appName = appName,
eventSink = eventSink,
)
fun aStaticLocationMode(
location: Location = Location(1.23, 2.34, 4f),
senderName: String = "Alice",
senderId: UserId = UserId("@alice:matrix.org"),
senderAvatarUrl: String? = null,
timestamp: Long = System.currentTimeMillis(),
assetType: AssetType? = null,
) = ShowLocationMode.Static(
location = location,
senderName = senderName,
senderId = senderId,
senderAvatarUrl = senderAvatarUrl,
timestamp = timestamp,
assetType = assetType,
)

View File

@@ -11,6 +11,9 @@ package io.element.android.features.location.impl.show
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@@ -23,6 +26,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.compound.tokens.generated.TypographyTokens
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.R
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog
@@ -74,12 +78,16 @@ fun ShowLocationView(
)
}
val cameraState = rememberCameraState(
firstPosition = CameraPosition(
target = Position(latitude = state.location.lat, longitude = state.location.lon),
val initialPosition = when (val mode = state.mode) {
is ShowLocationMode.Static -> CameraPosition(
target = Position(latitude = mode.location.lat, longitude = mode.location.lon),
zoom = MapDefaults.DEFAULT_ZOOM
)
)
ShowLocationMode.Live -> CameraPosition(
zoom = MapDefaults.DEFAULT_ZOOM
)
}
val cameraState = rememberCameraState(firstPosition = initialPosition)
val locationProvider = if (state.hasLocationPermission) {
rememberDefaultLocationProvider(
updateInterval = 1.minutes,
@@ -96,10 +104,13 @@ fun ShowLocationView(
}
}
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState()
)
MapBottomSheetScaffold(
scaffoldState = scaffoldState,
cameraState = cameraState,
modifier = modifier,
sheetPeekHeight = 80.dp,
topBar = {
TopAppBar(
titleStr = stringResource(CommonStrings.screen_view_location_title),
@@ -121,17 +132,22 @@ fun ShowLocationView(
)
},
sheetContent = {
state.description?.let {
Text(
text = it,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TypographyTokens.fontBodyMdRegular,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
)
when (val mode = state.mode) {
is ShowLocationMode.Static -> {
Text(
text = mode.senderName,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TypographyTokens.fontBodyMdRegular,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
)
}
ShowLocationMode.Live -> {
// TODO: Show list of active live location sharers
}
}
},
mapContent = {
@@ -140,22 +156,29 @@ fun ShowLocationView(
locationState = userLocationState,
trackUserLocation = state.isTrackMyLocation
)
val senderLocation = rememberGeoJsonSource(
data = GeoJsonData.Features(
Point(
Position(
latitude = state.location.lat,
longitude = state.location.lon
when (val mode = state.mode) {
is ShowLocationMode.Static -> {
val senderLocation = rememberGeoJsonSource(
data = GeoJsonData.Features(
Point(
Position(
latitude = mode.location.lat,
longitude = mode.location.lon
)
)
)
)
)
)
val marker = painterResource(R.drawable.pin_small)
SymbolLayer(
id = "sender_location",
source = senderLocation,
iconImage = image(marker)
)
val marker = painterResource(R.drawable.pin_small)
SymbolLayer(
id = "sender_location",
source = senderLocation,
iconImage = image(marker)
)
}
ShowLocationMode.Live -> {
// TODO: Show pins for all active live location sharers
}
}
},
overlayContent = {
LocationFloatingActionButton(

View File

@@ -28,10 +28,10 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.ShareLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
@@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@@ -148,7 +149,7 @@ class MessagesFlowNode(
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget
@Parcelize
data class LocationViewer(val location: Location, val description: String?) : NavTarget
data class LocationViewer(val mode: ShowLocationMode) : NavTarget
@Parcelize
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
@@ -336,7 +337,7 @@ class MessagesFlowNode(
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
}
is NavTarget.LocationViewer -> {
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
val inputs = ShowLocationEntryPoint.Inputs(navTarget.mode)
showLocationEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
@@ -558,9 +559,16 @@ class MessagesFlowNode(
)
}
is TimelineItemLocationContent -> {
NavTarget.LocationViewer(
val mode = ShowLocationMode.Static(
location = event.content.location,
description = event.content.description,
senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
NavTarget.LocationViewer(
mode = mode
).takeIf { locationService.isServiceAvailable() }
}
else -> null

View File

@@ -9,11 +9,13 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.location.api.Location
import io.element.android.libraries.matrix.api.room.location.AssetType
data class TimelineItemLocationContent(
val body: String,
val location: Location,
val description: String? = null,
val assetType: AssetType? = null,
) : TimelineItemEventContent {
override val type: String = "TimelineItemLocationContent"
}

View File

@@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
@@ -98,8 +99,9 @@ class TimelineItemContentMessageFactoryTest {
@Test
fun `test create LocationMessageType not null`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val assetType = AssetType.SENDER
val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")),
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description", assetType)),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
@@ -107,6 +109,7 @@ class TimelineItemContentMessageFactoryTest {
body = "body",
location = Location(lat = 1.0, lon = 2.0, accuracy = 0.0F),
description = "description",
assetType = assetType,
)
assertThat(result).isEqualTo(expected)
}
@@ -115,7 +118,7 @@ class TimelineItemContentMessageFactoryTest {
fun `test create LocationMessageType null`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "", null)),
content = createMessageContent(type = LocationMessageType("body", "", null, null)),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)

View File

@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.location.AssetType
@Immutable
sealed interface MessageType
@@ -55,6 +56,7 @@ data class LocationMessageType(
val body: String,
val geoUri: String,
val description: String?,
val assetType: AssetType?,
) : MessageType
data class AudioMessageType(

View File

@@ -9,8 +9,14 @@
package io.element.android.libraries.matrix.impl.room.location
import io.element.android.libraries.matrix.api.room.location.AssetType
import org.matrix.rustcomponents.sdk.AssetType as RustAssetType
fun AssetType.toInner(): org.matrix.rustcomponents.sdk.AssetType = when (this) {
AssetType.SENDER -> org.matrix.rustcomponents.sdk.AssetType.SENDER
AssetType.PIN -> org.matrix.rustcomponents.sdk.AssetType.PIN
fun AssetType.into(): RustAssetType = when (this) {
AssetType.SENDER -> RustAssetType.SENDER
AssetType.PIN -> RustAssetType.PIN
}
fun RustAssetType.into(): AssetType = when(this){
RustAssetType.SENDER -> AssetType.SENDER
RustAssetType.PIN -> AssetType.PIN
}

View File

@@ -32,7 +32,7 @@ import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.room.location.into
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
@@ -478,7 +478,7 @@ class RustTimeline(
geoUri = geoUri,
description = description,
zoomLevel = zoomLevel?.toUByte(),
assetType = assetType?.toInner(),
assetType = assetType?.into(),
repliedToEventId = inReplyToEventId?.value,
)
}

View File

@@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.room.location.into
import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper
import org.matrix.rustcomponents.sdk.InReplyToDetails
import org.matrix.rustcomponents.sdk.MessageType
@@ -112,7 +113,12 @@ class EventMessageMapper {
)
}
is RustMessageType.Location -> {
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
LocationMessageType(
body = type.content.body,
geoUri = type.content.geoUri,
description = type.content.description,
assetType = type.content.asset?.into()
)
}
is MessageType.Other -> {
OtherMessageType(type.msgtype, type.body)

View File

@@ -15,7 +15,7 @@ import org.junit.Test
class AssetTypeKtTest {
@Test
fun toInner() {
assertThat(AssetType.SENDER.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.SENDER)
assertThat(AssetType.PIN.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.PIN)
assertThat(AssetType.SENDER.into()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.SENDER)
assertThat(AssetType.PIN.into()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.PIN)
}
}

View File

@@ -71,7 +71,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
),
aMessageContent(
body = "Location",
type = LocationMessageType("Location", "geo:1,2", null),
type = LocationMessageType("Location", "geo:1,2", null, assetType = null),
),
aMessageContent(
body = "Notice",