Skip to content

Commit 4162d16

Browse files
author
Marco Romano
authored
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: - element-hq/element-meta#1678
1 parent 2ccedc1 commit 4162d16

File tree

16 files changed

+188
-38
lines changed

16 files changed

+188
-38
lines changed

features/location/impl/src/main/kotlin/io/element/android/features/location/impl/MapDefaults.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package io.element.android.features.location.impl
1818

19+
import android.Manifest
1920
import android.view.Gravity
2021
import androidx.compose.runtime.Composable
2122
import androidx.compose.runtime.ReadOnlyComposable
@@ -60,4 +61,6 @@ object MapDefaults {
6061
.build()
6162

6263
const val DEFAULT_ZOOM = 15.0
64+
65+
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
6366
}

features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616

1717
package io.element.android.features.location.impl.send
1818

19-
import android.Manifest.permission.ACCESS_COARSE_LOCATION
20-
import android.Manifest.permission.ACCESS_FINE_LOCATION
2119
import androidx.compose.runtime.Composable
2220
import androidx.compose.runtime.LaunchedEffect
2321
import androidx.compose.runtime.derivedStateOf
@@ -56,9 +54,7 @@ class SendLocationPresenter @Inject constructor(
5654
private val buildMeta: BuildMeta,
5755
) : Presenter<SendLocationState> {
5856

59-
private val permissionsPresenter = permissionsPresenterFactory.create(
60-
listOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)
61-
)
57+
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
6258

6359
@Composable
6460
override fun present(): SendLocationState {

features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ package io.element.android.features.location.impl.show
1818

1919
sealed interface ShowLocationEvents {
2020
object Share : ShowLocationEvents
21+
data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents
2122
}

features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,21 @@
1717
package io.element.android.features.location.impl.show
1818

1919
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.getValue
21+
import androidx.compose.runtime.mutableStateOf
22+
import androidx.compose.runtime.remember
23+
import androidx.compose.runtime.setValue
2024
import dagger.assisted.Assisted
2125
import dagger.assisted.AssistedFactory
2226
import dagger.assisted.AssistedInject
2327
import io.element.android.features.location.api.Location
28+
import io.element.android.features.location.impl.MapDefaults
29+
import io.element.android.features.location.impl.permissions.PermissionsPresenter
30+
import io.element.android.features.location.impl.permissions.PermissionsState
2431
import io.element.android.libraries.architecture.Presenter
2532

2633
class ShowLocationPresenter @AssistedInject constructor(
34+
permissionsPresenterFactory: PermissionsPresenter.Factory,
2735
private val actions: LocationActions,
2836
@Assisted private val location: Location,
2937
@Assisted private val description: String?
@@ -34,15 +42,26 @@ class ShowLocationPresenter @AssistedInject constructor(
3442
fun create(location: Location, description: String?): ShowLocationPresenter
3543
}
3644

45+
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
46+
3747
@Composable
3848
override fun present(): ShowLocationState {
39-
return ShowLocationState(
40-
location = location,
41-
description = description
42-
) {
43-
when (it) {
49+
val permissionsState: PermissionsState = permissionsPresenter.present()
50+
var isTrackMyLocation by remember { mutableStateOf(false) }
51+
52+
fun handleEvents(event: ShowLocationEvents) {
53+
when (event) {
4454
ShowLocationEvents.Share -> actions.share(location, description)
55+
is ShowLocationEvents.TrackMyLocation -> isTrackMyLocation = event.enabled
4556
}
4657
}
58+
59+
return ShowLocationState(
60+
location = location,
61+
description = description,
62+
hasLocationPermission = permissionsState.isAnyGranted,
63+
isTrackMyLocation = isTrackMyLocation,
64+
eventSink = ::handleEvents,
65+
)
4766
}
4867
}

features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,7 @@ import io.element.android.features.location.api.Location
2121
data class ShowLocationState(
2222
val location: Location,
2323
val description: String?,
24+
val hasLocationPermission: Boolean,
25+
val isTrackMyLocation: Boolean,
2426
val eventSink: (ShowLocationEvents) -> Unit,
2527
)

features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,37 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
2525
ShowLocationState(
2626
Location(1.23, 2.34, 4f),
2727
description = null,
28+
hasLocationPermission = false,
29+
isTrackMyLocation = false,
30+
eventSink = {},
31+
),
32+
ShowLocationState(
33+
Location(1.23, 2.34, 4f),
34+
description = null,
35+
hasLocationPermission = true,
36+
isTrackMyLocation = false,
37+
eventSink = {},
38+
),
39+
ShowLocationState(
40+
Location(1.23, 2.34, 4f),
41+
description = null,
42+
hasLocationPermission = true,
43+
isTrackMyLocation = true,
2844
eventSink = {},
2945
),
3046
ShowLocationState(
3147
Location(1.23, 2.34, 4f),
3248
description = "My favourite place!",
49+
hasLocationPermission = false,
50+
isTrackMyLocation = false,
3351
eventSink = {},
3452
),
3553
ShowLocationState(
3654
Location(1.23, 2.34, 4f),
3755
description = "For some reason I decided to write a small essay in the location description. " +
3856
"It is so long that it will wrap onto more than two lines!",
57+
hasLocationPermission = false,
58+
isTrackMyLocation = false,
3959
eventSink = {},
4060
),
4161
)

features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ import androidx.compose.foundation.layout.fillMaxSize
2323
import androidx.compose.foundation.layout.fillMaxWidth
2424
import androidx.compose.foundation.layout.padding
2525
import androidx.compose.material.icons.Icons
26+
import androidx.compose.material.icons.filled.LocationSearching
27+
import androidx.compose.material.icons.filled.MyLocation
2628
import androidx.compose.material.icons.outlined.Share
2729
import androidx.compose.material3.ExperimentalMaterial3Api
2830
import androidx.compose.runtime.Composable
31+
import androidx.compose.runtime.LaunchedEffect
2932
import androidx.compose.ui.Modifier
3033
import androidx.compose.ui.res.stringResource
3134
import androidx.compose.ui.text.style.TextAlign
@@ -37,15 +40,19 @@ import com.mapbox.mapboxsdk.camera.CameraPosition
3740
import com.mapbox.mapboxsdk.geometry.LatLng
3841
import io.element.android.features.location.api.internal.rememberTileStyleUrl
3942
import io.element.android.features.location.impl.MapDefaults
43+
import io.element.android.features.location.impl.send.SendLocationState
4044
import io.element.android.libraries.designsystem.components.button.BackButton
4145
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
4246
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
4347
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
48+
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
4449
import io.element.android.libraries.designsystem.theme.components.Icon
4550
import io.element.android.libraries.designsystem.theme.components.IconButton
4651
import io.element.android.libraries.designsystem.theme.components.Scaffold
4752
import io.element.android.libraries.designsystem.theme.components.Text
4853
import io.element.android.libraries.designsystem.theme.components.TopAppBar
54+
import io.element.android.libraries.maplibre.compose.CameraMode
55+
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
4956
import io.element.android.libraries.maplibre.compose.IconAnchor
5057
import io.element.android.libraries.maplibre.compose.MapboxMap
5158
import io.element.android.libraries.maplibre.compose.Symbol
@@ -64,7 +71,33 @@ fun ShowLocationView(
6471
modifier: Modifier = Modifier,
6572
onBackPressed: () -> Unit = {},
6673
) {
67-
Scaffold(modifier,
74+
val cameraPositionState = rememberCameraPositionState {
75+
position = CameraPosition.Builder()
76+
.target(LatLng(state.location.lat, state.location.lon))
77+
.zoom(MapDefaults.DEFAULT_ZOOM)
78+
.build()
79+
}
80+
81+
LaunchedEffect(state.isTrackMyLocation) {
82+
when (state.isTrackMyLocation) {
83+
false -> cameraPositionState.cameraMode = CameraMode.NONE
84+
true -> {
85+
cameraPositionState.position = CameraPosition.Builder()
86+
.zoom(MapDefaults.DEFAULT_ZOOM)
87+
.build()
88+
cameraPositionState.cameraMode = CameraMode.TRACKING
89+
}
90+
}
91+
}
92+
93+
LaunchedEffect(cameraPositionState.isMoving) {
94+
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
95+
state.eventSink(ShowLocationEvents.TrackMyLocation(false))
96+
}
97+
}
98+
99+
Scaffold(
100+
modifier = modifier,
68101
topBar = {
69102
TopAppBar(
70103
title = {
@@ -82,7 +115,19 @@ fun ShowLocationView(
82115
}
83116
}
84117
)
85-
}
118+
},
119+
floatingActionButton = {
120+
if (state.hasLocationPermission) {
121+
FloatingActionButton(
122+
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
123+
) {
124+
when (state.isTrackMyLocation) {
125+
false -> Icon(imageVector = Icons.Default.LocationSearching, contentDescription = null)
126+
true -> Icon(imageVector = Icons.Default.MyLocation, contentDescription = null)
127+
}
128+
}
129+
}
130+
},
86131
) { paddingValues ->
87132
Column(
88133
modifier = Modifier
@@ -107,14 +152,12 @@ fun ShowLocationView(
107152
styleUri = rememberTileStyleUrl(),
108153
modifier = Modifier.fillMaxSize(),
109154
images = mapOf(PIN_ID to DesignSystemR.drawable.pin).toImmutableMap(),
110-
cameraPositionState = rememberCameraPositionState {
111-
position = CameraPosition.Builder()
112-
.target(LatLng(state.location.lat, state.location.lon))
113-
.zoom(MapDefaults.DEFAULT_ZOOM)
114-
.build()
115-
},
155+
cameraPositionState = cameraPositionState,
116156
uiSettings = MapDefaults.uiSettings,
117157
symbolManagerSettings = MapDefaults.symbolManagerSettings,
158+
locationSettings = MapDefaults.locationSettings.copy(
159+
locationEnabled = state.hasLocationPermission,
160+
),
118161
) {
119162
Symbol(
120163
iconId = PIN_ID,

features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,39 +21,72 @@ import app.cash.molecule.moleculeFlow
2121
import app.cash.turbine.test
2222
import com.google.common.truth.Truth
2323
import io.element.android.features.location.api.Location
24+
import io.element.android.features.location.impl.permissions.PermissionsPresenter
25+
import io.element.android.features.location.impl.permissions.PermissionsPresenterFake
26+
import io.element.android.features.location.impl.permissions.PermissionsState
27+
import kotlinx.coroutines.delay
2428
import kotlinx.coroutines.test.runTest
2529
import org.junit.Test
2630

2731
class ShowLocationPresenterTest {
2832

33+
private val permissionsPresenterFake = PermissionsPresenterFake()
2934
private val actions = FakeLocationActions()
3035
private val location = Location(1.23, 4.56, 7.8f)
36+
private val presenter = ShowLocationPresenter(
37+
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
38+
override fun create(permissions: List<String>): PermissionsPresenter = permissionsPresenterFake
39+
},
40+
actions,
41+
location,
42+
A_DESCRIPTION,
43+
)
3144

3245
@Test
33-
fun `emits initial state`() = runTest {
34-
val presenter = ShowLocationPresenter(
35-
actions,
36-
location,
37-
A_DESCRIPTION,
38-
)
46+
fun `emits initial state with no location permission`() = runTest {
47+
moleculeFlow(RecompositionClock.Immediate) {
48+
presenter.present()
49+
}.test {
50+
val initialState = awaitItem()
51+
Truth.assertThat(initialState.location).isEqualTo(location)
52+
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
53+
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false)
54+
Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
55+
}
56+
}
57+
58+
@Test
59+
fun `emits initial state with location permission`() = runTest {
60+
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
3961

4062
moleculeFlow(RecompositionClock.Immediate) {
4163
presenter.present()
4264
}.test {
4365
val initialState = awaitItem()
4466
Truth.assertThat(initialState.location).isEqualTo(location)
4567
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
68+
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
69+
Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
4670
}
4771
}
4872

4973
@Test
50-
fun `uses action to share location`() = runTest {
51-
val presenter = ShowLocationPresenter(
52-
actions,
53-
location,
54-
A_DESCRIPTION,
55-
)
74+
fun `emits initial state with partial location permission`() = runTest {
75+
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
76+
77+
moleculeFlow(RecompositionClock.Immediate) {
78+
presenter.present()
79+
}.test {
80+
val initialState = awaitItem()
81+
Truth.assertThat(initialState.location).isEqualTo(location)
82+
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
83+
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
84+
Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
85+
}
86+
}
5687

88+
@Test
89+
fun `uses action to share location`() = runTest {
5790
moleculeFlow(RecompositionClock.Immediate) {
5891
presenter.present()
5992
}.test {
@@ -65,6 +98,27 @@ class ShowLocationPresenterTest {
6598
}
6699
}
67100

101+
@Test
102+
fun `centers on user location`() = runTest {
103+
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
104+
105+
moleculeFlow(RecompositionClock.Immediate) {
106+
presenter.present()
107+
}.test {
108+
val initialState = awaitItem()
109+
Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
110+
Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
111+
112+
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
113+
val trackMyLocationState = awaitItem()
114+
115+
delay(1)
116+
117+
Truth.assertThat(trackMyLocationState.hasLocationPermission).isEqualTo(true)
118+
Truth.assertThat(trackMyLocationState.isTrackMyLocation).isEqualTo(true)
119+
}
120+
}
121+
68122
companion object {
69123
private const val A_DESCRIPTION = "My happy place"
70124
}
Loading
Loading

0 commit comments

Comments
 (0)