Tap on locations in timeline to see a larger map

Show a fully-featured MapView, centered on the dropped pin,
which allows panning/zooming. Share button allows opening
in a map application.

Supports showing a description at the top of the screen,
if one is supplied with the event.

Out of scope: showing the local user's location (being
done as a separate story).

Includes some minor tidying: remove duplicate Location,
and make GeoURI parsing a method on that class; fix the
pointer location in MapView (I broke it earlier, whoops!)
This commit is contained in:
Chris Smith 2023-07-04 12:04:41 +01:00
parent 29d5d3baa7
commit 7a2cc5df0a
21 changed files with 659 additions and 44 deletions

View file

@ -17,6 +17,7 @@
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("kotlin-parcelize")
} }
android { android {

View file

@ -16,19 +16,25 @@
package io.element.android.features.location.api package io.element.android.features.location.api
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?""" private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
@Parcelize
data class Location( data class Location(
val lat: Double, val lat: Double,
val lon: Double, val lon: Double,
val accuracy: Float, val accuracy: Float,
) ) : Parcelable {
companion object {
fun parseGeoUri(geoUri: String): Location? { fun fromGeoUri(geoUri: String): Location? {
val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null
return Location ( return Location(
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null, lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null, lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f, accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f,
) )
}
}
} }

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
interface ViewLocationEntryPoint : FeatureEntryPoint {
data class Inputs(val location: Location, val description: String?) : NodeInputs
fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs): Node
}

View file

@ -19,57 +19,57 @@ package io.element.android.features.location.api
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.Test import org.junit.Test
internal class GeoUrisKtTest { internal class LocationKtTest {
@Test @Test
fun `parseGeoUri - returns null for invalid urls`() { fun `parseGeoUri - returns null for invalid urls`() {
assertThat(parseGeoUri("")).isNull() assertThat(Location.fromGeoUri("")).isNull()
assertThat(parseGeoUri("http://example.com/")).isNull() assertThat(Location.fromGeoUri("http://example.com/")).isNull()
assertThat(parseGeoUri("geo:")).isNull() assertThat(Location.fromGeoUri("geo:")).isNull()
assertThat(parseGeoUri("geo:1.234")).isNull() assertThat(Location.fromGeoUri("geo:1.234")).isNull()
assertThat(parseGeoUri("geo:1.234,")).isNull() assertThat(Location.fromGeoUri("geo:1.234,")).isNull()
assertThat(parseGeoUri("geo:,1.234")).isNull() assertThat(Location.fromGeoUri("geo:,1.234")).isNull()
assertThat(parseGeoUri("notgeo:1.234,5.678")).isNull() assertThat(Location.fromGeoUri("notgeo:1.234,5.678")).isNull()
assertThat(parseGeoUri("geo:+1.234,5.678")).isNull() assertThat(Location.fromGeoUri("geo:+1.234,5.678")).isNull()
assertThat(parseGeoUri("geo:+1.234,*5.678")).isNull() assertThat(Location.fromGeoUri("geo:+1.234,*5.678")).isNull()
assertThat(parseGeoUri("geo:not,good")).isNull() assertThat(Location.fromGeoUri("geo:not,good")).isNull()
assertThat(parseGeoUri("geo:1.234,5.678;u=wrong")).isNull() assertThat(Location.fromGeoUri("geo:1.234,5.678;u=wrong")).isNull()
assertThat(parseGeoUri("geo:1.234,5.678trailing")).isNull() assertThat(Location.fromGeoUri("geo:1.234,5.678trailing")).isNull()
} }
@Test @Test
fun `parseGeoUri - returns location for valid urls`() { fun `parseGeoUri - returns location for valid urls`() {
assertThat(parseGeoUri("geo:1.234,5.678")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location(
lat = 1.234, lat = 1.234,
lon = 5.678, lon = 5.678,
accuracy = 0f, accuracy = 0f,
)) ))
assertThat(parseGeoUri("geo:1,5")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
lat = 1.0, lat = 1.0,
lon = 5.0, lon = 5.0,
accuracy = 0f, accuracy = 0f,
)) ))
assertThat(parseGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
lat = 1.234, lat = 1.234,
lon = 5.678, lon = 5.678,
accuracy = 3000f, accuracy = 3000f,
)) ))
assertThat(parseGeoUri("geo:1,5;u=3000")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:1,5;u=3000")).isEqualTo(Location(
lat = 1.0, lat = 1.0,
lon = 5.0, lon = 5.0,
accuracy = 3000f, accuracy = 3000f,
)) ))
assertThat(parseGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location(
lat = -1.234, lat = -1.234,
lon = -5.678, lon = -5.678,
accuracy = 9.10f, accuracy = 9.10f,
)) ))
assertThat(parseGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location( assertThat(Location.fromGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location(
lat = -1.0, lat = -1.0,
lon = -5.0, lon = -5.0,
accuracy = 9.10f, accuracy = 9.10f,

View file

@ -36,6 +36,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.services.analytics.api)
implementation(libs.maplibre) implementation(libs.maplibre)
implementation(libs.maplibre.annotation) implementation(libs.maplibre.annotation)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)

View file

@ -24,6 +24,7 @@ import androidx.core.content.getSystemService
import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat import androidx.core.location.LocationRequestCompat
import io.element.android.features.location.api.Location
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.asExecutor import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose

View file

@ -19,6 +19,7 @@ package io.element.android.features.location.impl.map
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.Gravity import android.view.Gravity
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -29,7 +30,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -44,10 +47,12 @@ import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.buildTileServerUrl import io.element.android.features.location.api.internal.buildTileServerUrl
import io.element.android.features.location.impl.location.Location
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -69,7 +74,11 @@ fun MapView(
// When in preview, early return a Box with the received modifier preserving layout // When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) { if (LocalInspectionMode.current) {
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return. @Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
Box(modifier = modifier) Box(
modifier = modifier.background(Color.DarkGray)
) {
Text("[MapView]", modifier = Modifier.align(Alignment.Center))
}
return return
} }
@ -155,7 +164,7 @@ fun MapView(
.withLatLng(LatLng(location.lat, location.lon)) .withLatLng(LatLng(location.lat, location.lon))
.withIconImage("pin") .withIconImage("pin")
.withIconSize(1.3f) .withIconSize(1.3f)
.withIconOffset(arrayOf(0f, 0.5f)) .withIconAnchor(ICON_ANCHOR_BOTTOM)
) )
Timber.d("Shown pin at location: $location") Timber.d("Shown pin at location: $location")
} }

View file

@ -0,0 +1,67 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.Location
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidLocationActions @Inject constructor(
private val coroutineDispatchers: CoroutineDispatchers
) : LocationActions {
private var activityContext: Context? = null
@Composable
override fun Configure() {
val context = LocalContext.current
return DisposableEffect(Unit) {
activityContext = context
onDispose {
activityContext = null
}
}
}
override suspend fun share(location: Location, label: String?) {
runCatching {
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
val suffix = if (label != null) "(${Uri.encode(label)})" else ""
val uri = Uri.parse("geo:0,0?q=${location.lat},${location.lon}$suffix")
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
val chooserIntent = Intent.createChooser(showMapsIntent, null)
withContext(coroutineDispatchers.main) {
activityContext!!.startActivity(chooserIntent)
}
}.onSuccess {
Timber.v("Open location succeed")
}.onFailure {
Timber.e(it, "Open location failed")
}
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import androidx.compose.runtime.Composable
import io.element.android.features.location.api.Location
interface LocationActions {
@Composable
fun Configure()
suspend fun share(location: Location, label: String?)
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.ViewLocationEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class ViewLocationEntryPointImpl @Inject constructor() : ViewLocationEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext, inputs: ViewLocationEntryPoint.Inputs): Node {
return parentNode.createNode<ViewLocationNode>(buildContext, listOf(inputs))
}
}

View file

@ -14,13 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.location.impl.location package io.element.android.features.location.impl.view
/** sealed interface ViewLocationEvents {
* Represents a location sample emitted by the device's location subsystem. object Share : ViewLocationEvents
*/ }
data class Location(
val lat: Double,
val lon: Double,
val accuracy: Float,
)

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.location.api.ViewLocationEntryPoint
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
class ViewLocationNode @AssistedInject constructor(
presenterFactory: ViewLocationPresenter.Factory,
analyticsService: AnalyticsService,
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationView))
}
)
}
private val inputs: ViewLocationEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(inputs.location, inputs.description)
@Composable
override fun View(modifier: Modifier) {
ViewLocationView(
state = presenter.present(),
modifier = modifier,
onBackPressed = ::navigateUp
)
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.location.api.Location
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ViewLocationPresenter @AssistedInject constructor(
private val actions: LocationActions,
@Assisted private val location: Location,
@Assisted private val description: String?
) : Presenter<ViewLocationState> {
@AssistedFactory
interface Factory {
fun create(location: Location, description: String?): ViewLocationPresenter
}
@Composable
override fun present(): ViewLocationState {
val coroutineScope = rememberCoroutineScope()
actions.Configure()
return ViewLocationState(
location = location,
description = description
) {
when (it) {
ViewLocationEvents.Share -> coroutineScope.share(location, description)
}
}
}
private fun CoroutineScope.share(location: Location, label: String?) = launch {
actions.share(location, label)
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import io.element.android.features.location.api.Location
data class ViewLocationState(
val location: Location,
val description: String?,
val eventSink: (ViewLocationEvents) -> Unit,
)

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location
class ViewLocationStateProvider : PreviewParameterProvider<ViewLocationState> {
override val values: Sequence<ViewLocationState>
get() = sequenceOf(
ViewLocationState(
Location(1.23, 2.34, 4f),
description = null,
eventSink = {},
),
ViewLocationState(
Location(1.23, 2.34, 4f),
description = "My favourite place!",
eventSink = {},
),
ViewLocationState(
Location(1.23, 2.34, 4f),
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!",
eventSink = {},
),
)
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.location.impl.map.MapState
import io.element.android.features.location.impl.map.MapView
import io.element.android.features.location.impl.map.rememberMapState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.compound.generated.TypographyTokens
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ViewLocationView(
state: ViewLocationState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
val mapState = rememberMapState(
location = state.location,
position = MapState.CameraPosition(state.location.lat, state.location.lon, 15.0),
)
Scaffold(modifier,
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(CommonStrings.screen_view_location_title),
style = TypographyTokens.fontBodyLgMedium,
)
},
navigationIcon = {
BackButton(onClick = onBackPressed)
},
actions = {
IconButton(onClick = { state.eventSink(ViewLocationEvents.Share) }) {
Icon(imageVector = Icons.Outlined.Share, contentDescription = stringResource(CommonStrings.action_share))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize(),
) {
state.description?.let {
Text(
text = it,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TypographyTokens.fontBodyMdRegular,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
)
}
MapView(
mapState = mapState,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Preview
@Composable
internal fun ViewLocationViewLightPreview(@PreviewParameter(ViewLocationStateProvider::class) state: ViewLocationState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun ViewLocationViewDarkPreview(@PreviewParameter(ViewLocationStateProvider::class) state: ViewLocationState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: ViewLocationState) {
ViewLocationView(
state = state,
onBackPressed = {},
)
}

View file

@ -16,18 +16,19 @@
package io.element.android.features.location.impl.location package io.element.android.features.location.impl.location
import io.element.android.features.location.api.Location
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
fun fakeLocationUpdatesFlow(): Flow<io.element.android.features.location.impl.location.Location> = flow { fun fakeLocationUpdatesFlow(): Flow<Location> = flow {
while (true) { while (true) {
delay(1_000) delay(1_000)
emit(aLocation()) emit(aLocation())
} }
} }
private fun aLocation() = io.element.android.features.location.impl.location.Location( private fun aLocation() = Location(
lat = 51.49404, lat = 51.49404,
lon = -0.25484, lon = -0.25484,
accuracy = 5f accuracy = 5f

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import androidx.compose.runtime.Composable
import io.element.android.features.location.api.Location
class FakeLocationActions : LocationActions {
var configured = false
private set
var sharedLocation: Location? = null
private set
var sharedLabel: String? = null
private set
@Composable
override fun Configure() {
configured = true
}
override suspend fun share(location: Location, label: String?) {
sharedLocation = location
sharedLabel = label
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl.view
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.location.api.Location
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ViewLocationPresenterTest {
private val actions = FakeLocationActions()
private val location = Location(1.23, 4.56, 7.8f)
@Test
fun `emits initial state`() = runTest {
val presenter = ViewLocationPresenter(
actions,
location,
A_DESCRIPTION,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.location).isEqualTo(location)
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
}
}
@Test
fun `uses action to share location`() = runTest {
val presenter = ViewLocationPresenter(
actions,
location,
A_DESCRIPTION,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ViewLocationEvents.Share)
Truth.assertThat(actions.configured).isTrue()
Truth.assertThat(actions.sharedLocation).isEqualTo(location)
Truth.assertThat(actions.sharedLabel).isEqualTo(A_DESCRIPTION)
}
}
companion object {
private const val A_DESCRIPTION = "My happy place"
}
}

View file

@ -29,7 +29,9 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.SendLocationEntryPoint import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ViewLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint 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.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
@ -41,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNo
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@ -59,6 +62,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val sendLocationEntryPoint: SendLocationEntryPoint, private val sendLocationEntryPoint: SendLocationEntryPoint,
private val viewLocationEntryPoint: ViewLocationEntryPoint,
) : BackstackNode<MessagesFlowNode.NavTarget>( ) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Messages, initialElement = NavTarget.Messages,
@ -82,6 +86,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class AttachmentPreview(val attachment: Attachment) : NavTarget data class AttachmentPreview(val attachment: Attachment) : NavTarget
@Parcelize
data class LocationViewer(val location: Location, val description: String?) : NavTarget
@Parcelize @Parcelize
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
@ -147,6 +154,10 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment) val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs)) createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
} }
is NavTarget.LocationViewer -> {
val inputs = ViewLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
viewLocationEntryPoint.createNode(this, buildContext, inputs)
}
is NavTarget.EventDebugInfo -> { is NavTarget.EventDebugInfo -> {
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo) val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
createNode<EventDebugInfoNode>(buildContext, listOf(inputs)) createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
@ -213,6 +224,13 @@ class MessagesFlowNode @AssistedInject constructor(
) )
backstack.push(navTarget) backstack.push(navTarget)
} }
is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer(
location = event.content.location,
description = event.content.description,
)
backstack.push(navTarget)
}
else -> Unit else -> Unit
} }
} }

View file

@ -16,7 +16,7 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.location.api.parseGeoUri import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -68,7 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
) )
} }
is LocationMessageType -> { is LocationMessageType -> {
val location = parseGeoUri(messageType.geoUri) val location = Location.fromGeoUri(messageType.geoUri)
if (location == null) { if (location == null) {
TimelineItemTextContent( TimelineItemTextContent(
body = messageType.body, body = messageType.body,