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 package io.element.android.features.location.impl
import android.Manifest
import android.view.Gravity import android.view.Gravity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
@ -60,4 +61,6 @@ object MapDefaults {
.build() .build()
const val DEFAULT_ZOOM = 15.0 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 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -56,9 +54,7 @@ class SendLocationPresenter @Inject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : Presenter<SendLocationState> { ) : Presenter<SendLocationState> {
private val permissionsPresenter = permissionsPresenterFactory.create( private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
listOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)
)
@Composable @Composable
override fun present(): SendLocationState { override fun present(): SendLocationState {

View file

@ -18,4 +18,5 @@ package io.element.android.features.location.impl.show
sealed interface ShowLocationEvents { sealed interface ShowLocationEvents {
object Share : 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 package io.element.android.features.location.impl.show
import androidx.compose.runtime.Composable 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.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.location.api.Location 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 import io.element.android.libraries.architecture.Presenter
class ShowLocationPresenter @AssistedInject constructor( class ShowLocationPresenter @AssistedInject constructor(
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val actions: LocationActions, private val actions: LocationActions,
@Assisted private val location: Location, @Assisted private val location: Location,
@Assisted private val description: String? @Assisted private val description: String?
@ -34,15 +42,26 @@ class ShowLocationPresenter @AssistedInject constructor(
fun create(location: Location, description: String?): ShowLocationPresenter fun create(location: Location, description: String?): ShowLocationPresenter
} }
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
@Composable @Composable
override fun present(): ShowLocationState { override fun present(): ShowLocationState {
return ShowLocationState( val permissionsState: PermissionsState = permissionsPresenter.present()
location = location, var isTrackMyLocation by remember { mutableStateOf(false) }
description = description
) { fun handleEvents(event: ShowLocationEvents) {
when (it) { when (event) {
ShowLocationEvents.Share -> actions.share(location, description) 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( data class ShowLocationState(
val location: Location, val location: Location,
val description: String?, val description: String?,
val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean,
val eventSink: (ShowLocationEvents) -> Unit, val eventSink: (ShowLocationEvents) -> Unit,
) )

View file

@ -25,17 +25,37 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
ShowLocationState( ShowLocationState(
Location(1.23, 2.34, 4f), Location(1.23, 2.34, 4f),
description = null, 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 = {}, eventSink = {},
), ),
ShowLocationState( ShowLocationState(
Location(1.23, 2.34, 4f), Location(1.23, 2.34, 4f),
description = "My favourite place!", description = "My favourite place!",
hasLocationPermission = false,
isTrackMyLocation = false,
eventSink = {}, eventSink = {},
), ),
ShowLocationState( ShowLocationState(
Location(1.23, 2.34, 4f), Location(1.23, 2.34, 4f),
description = "For some reason I decided to write a small essay in the location description. " + 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!", "It is so long that it will wrap onto more than two lines!",
hasLocationPermission = false,
isTrackMyLocation = false,
eventSink = {}, eventSink = {},
), ),
) )

View file

@ -23,9 +23,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons 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.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -37,15 +40,19 @@ import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng import com.mapbox.mapboxsdk.geometry.LatLng
import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.MapDefaults 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.components.button.BackButton
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.aliasScreenTitle 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.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton 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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.IconAnchor
import io.element.android.libraries.maplibre.compose.MapboxMap import io.element.android.libraries.maplibre.compose.MapboxMap
import io.element.android.libraries.maplibre.compose.Symbol import io.element.android.libraries.maplibre.compose.Symbol
@ -64,7 +71,33 @@ fun ShowLocationView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {}, 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 = { topBar = {
TopAppBar( TopAppBar(
title = { 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 -> ) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
@ -107,14 +152,12 @@ fun ShowLocationView(
styleUri = rememberTileStyleUrl(), styleUri = rememberTileStyleUrl(),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
images = mapOf(PIN_ID to DesignSystemR.drawable.pin).toImmutableMap(), images = mapOf(PIN_ID to DesignSystemR.drawable.pin).toImmutableMap(),
cameraPositionState = rememberCameraPositionState { cameraPositionState = cameraPositionState,
position = CameraPosition.Builder()
.target(LatLng(state.location.lat, state.location.lon))
.zoom(MapDefaults.DEFAULT_ZOOM)
.build()
},
uiSettings = MapDefaults.uiSettings, uiSettings = MapDefaults.uiSettings,
symbolManagerSettings = MapDefaults.symbolManagerSettings, symbolManagerSettings = MapDefaults.symbolManagerSettings,
locationSettings = MapDefaults.locationSettings.copy(
locationEnabled = state.hasLocationPermission,
),
) { ) {
Symbol( Symbol(
iconId = PIN_ID, iconId = PIN_ID,

View file

@ -21,21 +21,43 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.location.api.Location 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 kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class ShowLocationPresenterTest { class ShowLocationPresenterTest {
private val permissionsPresenterFake = PermissionsPresenterFake()
private val actions = FakeLocationActions() private val actions = FakeLocationActions()
private val location = Location(1.23, 4.56, 7.8f) 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 @Test
fun `emits initial state`() = runTest { fun `emits initial state with no location permission`() = runTest {
val presenter = ShowLocationPresenter( moleculeFlow(RecompositionClock.Immediate) {
actions, presenter.present()
location, }.test {
A_DESCRIPTION, 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) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -43,17 +65,28 @@ class ShowLocationPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.location).isEqualTo(location) Truth.assertThat(initialState.location).isEqualTo(location)
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION) 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 @Test
fun `uses action to share location`() = runTest { fun `uses action to share location`() = runTest {
val presenter = ShowLocationPresenter(
actions,
location,
A_DESCRIPTION,
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.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 { companion object {
private const val A_DESCRIPTION = "My happy place" private const val A_DESCRIPTION = "My happy place"
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f19878925f3b5b377a91885540fb15d29a5b78d8be2282d64e8809af0bbf5ff4 oid sha256:8d0a5de3c4e09d76b6453ccc6ace5c690d540d945a62e630474932734209058a
size 12195 size 11716

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c936d2d804bc9e98fcc49430f11ddaa572b05fc8d3a0df93ad6521ee8e78f708 oid sha256:9218d1d514342c400051bebb10ef458c99103fda618bba45e61a524c5a58eb63
size 21806 size 11903

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f19878925f3b5b377a91885540fb15d29a5b78d8be2282d64e8809af0bbf5ff4
size 12195

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c936d2d804bc9e98fcc49430f11ddaa572b05fc8d3a0df93ad6521ee8e78f708
size 21806

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:065d2a09680e35540b870862ec0ba5c54182e016017938ef64e510b6132333c9 oid sha256:e39c5ba30983b034886a6adc330d1510b3a48c40511697edcaf6530716c7ba2e
size 13389 size 12500

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0bb88c64bc68b10b1fb709135f445de5a5e4d78623448d0fef97504e025d5f6d oid sha256:e4ff75e74c19280308878e3001a8791aaa735439cc667946fefd21070611630e
size 24551 size 12698

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:065d2a09680e35540b870862ec0ba5c54182e016017938ef64e510b6132333c9
size 13389

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0bb88c64bc68b10b1fb709135f445de5a5e4d78623448d0fef97504e025d5f6d
size 24551