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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user