Location expanded view: show own location (#916)

If the location permission is granted:
- Shows the user's own location
- Shows a button to center the map on it

Part of:
- https://github.com/vector-im/element-meta/issues/1678
This commit is contained in:
Marco Romano
2023-07-19 15:26:06 +02:00
committed by GitHub
parent b2436a9d7c
commit c135da2562
16 changed files with 188 additions and 38 deletions

View File

@@ -16,6 +16,7 @@
package io.element.android.features.location.impl
import android.Manifest
import android.view.Gravity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
@@ -60,4 +61,6 @@ object MapDefaults {
.build()
const val DEFAULT_ZOOM = 15.0
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
}

View File

@@ -16,8 +16,6 @@
package io.element.android.features.location.impl.send
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -56,9 +54,7 @@ class SendLocationPresenter @Inject constructor(
private val buildMeta: BuildMeta,
) : Presenter<SendLocationState> {
private val permissionsPresenter = permissionsPresenterFactory.create(
listOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)
)
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
@Composable
override fun present(): SendLocationState {

View File

@@ -18,4 +18,5 @@ package io.element.android.features.location.impl.show
sealed interface ShowLocationEvents {
object Share : ShowLocationEvents
data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents
}

View File

@@ -17,13 +17,21 @@
package io.element.android.features.location.impl.show
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.MapDefaults
import io.element.android.features.location.impl.permissions.PermissionsPresenter
import io.element.android.features.location.impl.permissions.PermissionsState
import io.element.android.libraries.architecture.Presenter
class ShowLocationPresenter @AssistedInject constructor(
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val actions: LocationActions,
@Assisted private val location: Location,
@Assisted private val description: String?
@@ -34,15 +42,26 @@ class ShowLocationPresenter @AssistedInject constructor(
fun create(location: Location, description: String?): ShowLocationPresenter
}
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
@Composable
override fun present(): ShowLocationState {
return ShowLocationState(
location = location,
description = description
) {
when (it) {
val permissionsState: PermissionsState = permissionsPresenter.present()
var isTrackMyLocation by remember { mutableStateOf(false) }
fun handleEvents(event: ShowLocationEvents) {
when (event) {
ShowLocationEvents.Share -> actions.share(location, description)
is ShowLocationEvents.TrackMyLocation -> isTrackMyLocation = event.enabled
}
}
return ShowLocationState(
location = location,
description = description,
hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation,
eventSink = ::handleEvents,
)
}
}

View File

@@ -21,5 +21,7 @@ import io.element.android.features.location.api.Location
data class ShowLocationState(
val location: Location,
val description: String?,
val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean,
val eventSink: (ShowLocationEvents) -> Unit,
)

View File

@@ -25,17 +25,37 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
ShowLocationState(
Location(1.23, 2.34, 4f),
description = null,
hasLocationPermission = false,
isTrackMyLocation = false,
eventSink = {},
),
ShowLocationState(
Location(1.23, 2.34, 4f),
description = null,
hasLocationPermission = true,
isTrackMyLocation = false,
eventSink = {},
),
ShowLocationState(
Location(1.23, 2.34, 4f),
description = null,
hasLocationPermission = true,
isTrackMyLocation = true,
eventSink = {},
),
ShowLocationState(
Location(1.23, 2.34, 4f),
description = "My favourite place!",
hasLocationPermission = false,
isTrackMyLocation = false,
eventSink = {},
),
ShowLocationState(
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!",
hasLocationPermission = false,
isTrackMyLocation = false,
eventSink = {},
),
)

View File

@@ -23,9 +23,12 @@ 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.filled.LocationSearching
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -37,15 +40,19 @@ import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.MapDefaults
import io.element.android.features.location.impl.send.SendLocationState
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.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
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.designsystem.theme.components.TopAppBar
import io.element.android.libraries.maplibre.compose.CameraMode
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
import io.element.android.libraries.maplibre.compose.IconAnchor
import io.element.android.libraries.maplibre.compose.MapboxMap
import io.element.android.libraries.maplibre.compose.Symbol
@@ -64,7 +71,33 @@ fun ShowLocationView(
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
Scaffold(modifier,
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.Builder()
.target(LatLng(state.location.lat, state.location.lon))
.zoom(MapDefaults.DEFAULT_ZOOM)
.build()
}
LaunchedEffect(state.isTrackMyLocation) {
when (state.isTrackMyLocation) {
false -> cameraPositionState.cameraMode = CameraMode.NONE
true -> {
cameraPositionState.position = CameraPosition.Builder()
.zoom(MapDefaults.DEFAULT_ZOOM)
.build()
cameraPositionState.cameraMode = CameraMode.TRACKING
}
}
}
LaunchedEffect(cameraPositionState.isMoving) {
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
state.eventSink(ShowLocationEvents.TrackMyLocation(false))
}
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
@@ -82,7 +115,19 @@ fun ShowLocationView(
}
}
)
}
},
floatingActionButton = {
if (state.hasLocationPermission) {
FloatingActionButton(
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
) {
when (state.isTrackMyLocation) {
false -> Icon(imageVector = Icons.Default.LocationSearching, contentDescription = null)
true -> Icon(imageVector = Icons.Default.MyLocation, contentDescription = null)
}
}
}
},
) { paddingValues ->
Column(
modifier = Modifier
@@ -107,14 +152,12 @@ fun ShowLocationView(
styleUri = rememberTileStyleUrl(),
modifier = Modifier.fillMaxSize(),
images = mapOf(PIN_ID to DesignSystemR.drawable.pin).toImmutableMap(),
cameraPositionState = rememberCameraPositionState {
position = CameraPosition.Builder()
.target(LatLng(state.location.lat, state.location.lon))
.zoom(MapDefaults.DEFAULT_ZOOM)
.build()
},
cameraPositionState = cameraPositionState,
uiSettings = MapDefaults.uiSettings,
symbolManagerSettings = MapDefaults.symbolManagerSettings,
locationSettings = MapDefaults.locationSettings.copy(
locationEnabled = state.hasLocationPermission,
),
) {
Symbol(
iconId = PIN_ID,

View File

@@ -21,21 +21,43 @@ 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 io.element.android.features.location.impl.permissions.PermissionsPresenter
import io.element.android.features.location.impl.permissions.PermissionsPresenterFake
import io.element.android.features.location.impl.permissions.PermissionsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ShowLocationPresenterTest {
private val permissionsPresenterFake = PermissionsPresenterFake()
private val actions = FakeLocationActions()
private val location = Location(1.23, 4.56, 7.8f)
private val presenter = ShowLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permissions: List<String>): PermissionsPresenter = permissionsPresenterFake
},
actions,
location,
A_DESCRIPTION,
)
@Test
fun `emits initial state`() = runTest {
val presenter = ShowLocationPresenter(
actions,
location,
A_DESCRIPTION,
)
fun `emits initial state with no location permission`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.location).isEqualTo(location)
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false)
Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
}
}
@Test
fun `emits initial state with location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -43,17 +65,28 @@ class ShowLocationPresenterTest {
val initialState = awaitItem()
Truth.assertThat(initialState.location).isEqualTo(location)
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
}
}
@Test
fun `emits initial state with partial location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.location).isEqualTo(location)
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
}
}
@Test
fun `uses action to share location`() = runTest {
val presenter = ShowLocationPresenter(
actions,
location,
A_DESCRIPTION,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -65,6 +98,27 @@ class ShowLocationPresenterTest {
}
}
@Test
fun `centers on user location`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val trackMyLocationState = awaitItem()
delay(1)
Truth.assertThat(trackMyLocationState.hasLocationPermission).isEqualTo(true)
Truth.assertThat(trackMyLocationState.isTrackMyLocation).isEqualTo(true)
}
}
companion object {
private const val A_DESCRIPTION = "My happy place"
}