Send pin-drop location (#636)

Share pindrop location

This feature allows the user to share any location by just selecting it from the map.

Closes: https://github.com/vector-im/element-x-android/issues/690
This commit is contained in:
Marco Romano 2023-06-30 00:07:47 +02:00 committed by GitHub
parent b292c3a29b
commit 0858cfb272
30 changed files with 534 additions and 113 deletions

View file

@ -247,7 +247,7 @@ koverMerged {
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*" excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState" excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState" excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
excludes += "io.element.android.features.location.api.MapState" excludes += "io.element.android.features.location.impl.map.MapState"
} }
bound { bound {
minValue = 90 minValue = 90

View file

@ -16,8 +16,6 @@
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
} }
@ -25,29 +23,14 @@ android {
namespace = "io.element.android.features.location.api" namespace = "io.element.android.features.location.api"
} }
anvil {
generateDaggerFactories.set(true)
}
dependencies { dependencies {
implementation(libs.dagger) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.network)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
} }

View file

@ -0,0 +1,26 @@
/*
* 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 io.element.android.libraries.architecture.SimpleFeatureEntryPoint
/**
* The "Send location" screen.
*
* Allows a user to share a location message within a room.
*/
interface SendLocationEntryPoint : SimpleFeatureEntryPoint

View file

@ -27,7 +27,7 @@ private const val STATIC_MAP_SCALE = "" // Either "" (empty string) for normal i
private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048 private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048
private const val STATIC_MAP_MAX_ZOOM = 22.0 private const val STATIC_MAP_MAX_ZOOM = 22.0
internal fun buildTileServerUrl( fun buildTileServerUrl(
darkMode: Boolean darkMode: Boolean
): String = if (!darkMode) { ): String = if (!darkMode) {
"$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY" "$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY"

View file

@ -1,52 +0,0 @@
/*
* Copyright (c) 2022 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.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.location.fake"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.dagger)
api(projects.features.location.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.network)
implementation(projects.libraries.core)
implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
implementation(libs.accompanist.permission)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -17,7 +17,6 @@
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
} }
@ -30,18 +29,18 @@ anvil {
} }
dependencies { dependencies {
implementation(libs.dagger)
api(projects.features.location.api) api(projects.features.location.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.network)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(libs.maplibre) implementation(libs.maplibre)
implementation(libs.network.retrofit)
implementation(libs.maplibre.annotation) implementation(libs.maplibre.annotation)
implementation(libs.coil.compose) implementation(projects.libraries.uiStrings)
implementation(libs.serialization.json) implementation(libs.dagger)
implementation(libs.accompanist.permission) implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

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
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.SendLocationEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class SendLocationEntryPointImpl @Inject constructor() : SendLocationEntryPoint {
override fun createNode(
parentNode: Node, buildContext: BuildContext
): SendLocationNode = parentNode.createNode(buildContext)
}

View file

@ -0,0 +1,22 @@
/*
* 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
sealed interface SendLocationEvents {
data class ShareLocation(val lat: Double, val lng: Double) : SendLocationEvents
data class SwitchMode(val mode: SendLocationState.Mode) : SendLocationEvents
}

View file

@ -0,0 +1,43 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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 io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class SendLocationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SendLocationPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
SendLocationView(
state = presenter.present(),
modifier = modifier,
onBackPressed = ::navigateUp,
)
}
}

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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.launch
import javax.inject.Inject
class SendLocationPresenter @Inject constructor(
private val room: MatrixRoom,
) : Presenter<SendLocationState> {
@Composable
override fun present(): SendLocationState {
val scope = rememberCoroutineScope()
var mode by remember {
mutableStateOf<SendLocationState.Mode>(SendLocationState.Mode.ALocation)
}
fun handleEvents(event: SendLocationEvents) {
when (event) {
is SendLocationEvents.ShareLocation -> scope.launch {
shareLocation(event)
}
is SendLocationEvents.SwitchMode -> {
mode = event.mode
}
}
}
return SendLocationState(
mode = mode,
eventSink = ::handleEvents,
)
}
private suspend fun shareLocation(
event: SendLocationEvents.ShareLocation
) {
room.sendLocation(
body = "Location at latitude: ${event.lat}, longitude: ${event.lng}",
geoUri = "geo:${event.lat},${event.lng}",
)
}
}

View file

@ -0,0 +1,27 @@
/*
* 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
data class SendLocationState(
val mode: Mode = Mode.ALocation,
val eventSink: (SendLocationEvents) -> Unit = {},
) {
sealed interface Mode {
object MyLocation : Mode
object ALocation : Mode
}
}

View file

@ -0,0 +1,26 @@
/*
* 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
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> {
override val values: Sequence<SendLocationState>
get() = sequenceOf(
SendLocationState(),
)
}

View file

@ -0,0 +1,142 @@
/*
* 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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.location.api.R
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.BottomSheetScaffold
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.Text
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SendLocationView(
state: SendLocationState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
val mapState = rememberMapState()
BottomSheetScaffold(
sheetContent = {
Spacer(modifier = Modifier.height(16.dp))
ListItem(
headlineContent = {
Text(stringResource(CommonStrings.screen_share_this_location_action))
},
modifier = Modifier.clickable {
state.eventSink(
SendLocationEvents.ShareLocation(
lat = mapState.position.lat,
lng = mapState.position.lon
)
)
onBackPressed()
},
leadingContent = {
Icon(Icons.Default.LocationOn, null)
},
)
Spacer(modifier = Modifier.height(16.dp))
},
modifier = modifier,
scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded),
),
sheetDragHandle = {},
sheetSwipeEnabled = false,
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(CommonStrings.screen_share_location_title),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = {
BackButton(onClick = onBackPressed)
},
)
},
) {
Box(
modifier = Modifier
.padding(it)
.consumeWindowInsets(it),
contentAlignment = Alignment.Center
) {
MapView(
modifier = Modifier.fillMaxSize(),
mapState = mapState,
)
Icon(
resourceId = R.drawable.pin,
contentDescription = null,
tint = Color.Unspecified
)
}
}
}
@Preview
@Composable
internal fun SendLocationViewLightPreview(@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun SendLocationViewDarkPreview(@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: SendLocationState) {
SendLocationView(
state = state,
onBackPressed = {},
)
}

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.location.api package io.element.android.features.location.impl.location
/** /**
* Represents a location sample emitted by the device's location subsystem. * Represents a location sample emitted by the device's location subsystem.

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.location.impl package io.element.android.features.location.impl.location
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@ -25,7 +25,6 @@ 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.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.features.location.api.Location
import kotlinx.coroutines.asExecutor import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking

View file

@ -14,15 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.location.api package io.element.android.features.location.impl.map
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.Gravity
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -31,7 +29,6 @@ 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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
@ -47,11 +44,11 @@ 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 io.element.android.features.location.api.R
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.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
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
@ -68,7 +65,6 @@ fun MapView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
mapState: MapState = rememberMapState(), mapState: MapState = rememberMapState(),
darkMode: Boolean = !ElementTheme.isLightTheme, darkMode: Boolean = !ElementTheme.isLightTheme,
onLocationClick: () -> Unit,
) { ) {
// 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) {
@ -88,7 +84,10 @@ fun MapView(
LaunchedEffect(darkMode) { LaunchedEffect(darkMode) {
mapView.awaitMap().let { map -> mapView.awaitMap().let { map ->
map.uiSettings.apply { map.uiSettings.apply {
attributionGravity = Gravity.TOP
logoGravity = Gravity.TOP
isCompassEnabled = false isCompassEnabled = false
isRotateGesturesEnabled = false
} }
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style -> map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style ->
mapRefs = MapRefs( mapRefs = MapRefs(
@ -180,20 +179,10 @@ fun MapView(
} }
@Suppress("ModifierReused") @Suppress("ModifierReused")
Box(modifier = modifier) { AndroidView(
AndroidView(factory = { mapView }) factory = { mapView },
FloatingActionButton( modifier = modifier
onClick = onLocationClick, )
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) {
Icon(
imageVector = Icons.Filled.LocationOn,
contentDescription = null, // TODO
)
}
}
} }
@Composable @Composable
@ -292,6 +281,5 @@ private fun ContentToPreview() {
) )
).toImmutableList() ).toImmutableList()
), ),
onLocationClick = {},
) )
} }

View file

@ -0,0 +1,64 @@
/*
* 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
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.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SendLocationPresenterTest {
private val room = FakeMatrixRoom()
private val presenter = SendLocationPresenter(room)
@Test
fun `emits initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(awaitItem().mode).isEqualTo(SendLocationState.Mode.ALocation)
}
}
@Test
fun `share location event shares a location`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SendLocationEvents.ShareLocation(1.0, 2.0))
delay(1)
Truth.assertThat(room.sendLocationCount).isEqualTo(1)
}
}
@Test
fun `switches mode`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SendLocationEvents.SwitchMode(SendLocationState.Mode.MyLocation))
Truth.assertThat(awaitItem().mode).isEqualTo(SendLocationState.Mode.MyLocation)
}
}
}

View file

@ -14,21 +14,20 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.location.fake 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<Location> = flow { fun fakeLocationUpdatesFlow(): Flow<io.element.android.features.location.impl.location.Location> = flow {
while (true) { while (true) {
delay(1_000) delay(1_000)
emit(aLocation()) emit(aLocation())
} }
} }
private fun aLocation() = Location( private fun aLocation() = io.element.android.features.location.impl.location.Location(
lat = 51.49404, lat = 51.49404,
lon = -0.25484, lon = -0.25484,
accuracy = 5f accuracy = 5f

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
api(projects.features.messages.api) api(projects.features.messages.api)
implementation(projects.features.location.api)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)

View file

@ -29,6 +29,7 @@ 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.SendLocationEntryPoint
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
@ -57,6 +58,7 @@ import kotlinx.parcelize.Parcelize
class MessagesFlowNode @AssistedInject constructor( class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val sendLocationEntryPoint: SendLocationEntryPoint,
) : BackstackNode<MessagesFlowNode.NavTarget>( ) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Messages, initialElement = NavTarget.Messages,
@ -88,6 +90,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
@Parcelize
object SendLocation : NavTarget
} }
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull() private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
@ -123,6 +128,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onReportMessage(eventId: EventId, senderId: UserId) { override fun onReportMessage(eventId: EventId, senderId: UserId) {
backstack.push(NavTarget.ReportMessage(eventId, senderId)) backstack.push(NavTarget.ReportMessage(eventId, senderId))
} }
override fun onSendLocationClicked() {
backstack.push(NavTarget.SendLocation)
}
} }
createNode<MessagesNode>(buildContext, listOf(callback)) createNode<MessagesNode>(buildContext, listOf(callback))
} }
@ -155,6 +164,9 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId) val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
createNode<ReportMessageNode>(buildContext, listOf(inputs)) createNode<ReportMessageNode>(buildContext, listOf(inputs))
} }
NavTarget.SendLocation -> {
sendLocationEntryPoint.createNode(this, buildContext)
}
} }
} }

View file

@ -57,6 +57,7 @@ class MessagesNode @AssistedInject constructor(
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId) fun onForwardEventClicked(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId) fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClicked()
} }
init { init {
@ -94,6 +95,10 @@ class MessagesNode @AssistedInject constructor(
callback?.onReportMessage(eventId, senderId) callback?.onReportMessage(eventId, senderId)
} }
private fun onSendLocationClicked() {
callback?.onSendLocationClicked()
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -104,6 +109,7 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked, onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments, onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked, onUserDataClicked = this::onUserDataClicked,
onSendLocationClicked = this::onSendLocationClicked,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -92,6 +92,7 @@ fun MessagesView(
onEventClicked: (event: TimelineItem.Event) -> Unit, onEventClicked: (event: TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit, onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LogCompositions(tag = "MessagesScreen", msg = "Root") LogCompositions(tag = "MessagesScreen", msg = "Root")
@ -153,7 +154,8 @@ fun MessagesView(
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
} }
}, },
onReactionClicked = ::onEmojiReactionClicked onReactionClicked = ::onEmojiReactionClicked,
onSendLocationClicked = onSendLocationClicked,
) )
}, },
snackbarHost = { snackbarHost = {
@ -237,6 +239,7 @@ fun MessagesViewContent(
onReactionClicked: (key: String, TimelineItem.Event) -> Unit, onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -260,6 +263,7 @@ fun MessagesViewContent(
if (state.userHasPermissionToSendMessage) { if (state.userHasPermissionToSendMessage) {
MessageComposerView( MessageComposerView(
state = state.composerState, state = state.composerState,
onSendLocationClicked = onSendLocationClicked,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight(Alignment.Bottom) .wrapContentHeight(Alignment.Bottom)
@ -347,5 +351,6 @@ private fun ContentToPreview(state: MessagesState) {
onEventClicked = {}, onEventClicked = {},
onPreviewAttachments = {}, onPreviewAttachments = {},
onUserDataClicked = {}, onUserDataClicked = {},
onSendLocationClicked = {},
) )
} }

View file

@ -25,6 +25,7 @@ import androidx.compose.material.ListItem
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Collections import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -48,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
internal fun AttachmentsBottomSheet( internal fun AttachmentsBottomSheet(
state: MessageComposerState, state: MessageComposerState,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val localView = LocalView.current val localView = LocalView.current
@ -78,7 +80,10 @@ internal fun AttachmentsBottomSheet(
modifier = modifier, modifier = modifier,
onDismissRequest = { isVisible = false } onDismissRequest = { isVisible = false }
) { ) {
AttachmentSourcePickerMenu(eventSink = state.eventSink) AttachmentSourcePickerMenu(
eventSink = state.eventSink,
onSendLocationClicked = onSendLocationClicked,
)
} }
} }
} }
@ -87,6 +92,7 @@ internal fun AttachmentsBottomSheet(
@Composable @Composable
internal fun AttachmentSourcePickerMenu( internal fun AttachmentSourcePickerMenu(
eventSink: (MessageComposerEvents) -> Unit, eventSink: (MessageComposerEvents) -> Unit,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -113,5 +119,13 @@ internal fun AttachmentSourcePickerMenu(
icon = { Icon(Icons.Default.Videocam, null) }, icon = { Icon(Icons.Default.Videocam, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) }, text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
) )
ListItem(
modifier = Modifier.clickable {
eventSink(MessageComposerEvents.PickAttachmentSource.Location)
onSendLocationClicked()
},
icon = { Icon(Icons.Default.LocationOn, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
)
} }
} }

View file

@ -34,5 +34,6 @@ sealed interface MessageComposerEvents {
object FromFiles : PickAttachmentSource object FromFiles : PickAttachmentSource
object PhotoFromCamera : PickAttachmentSource object PhotoFromCamera : PickAttachmentSource
object VideoFromCamera : PickAttachmentSource object VideoFromCamera : PickAttachmentSource
object Location : PickAttachmentSource
} }
} }

View file

@ -159,6 +159,10 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
cameraVideoPicker.launch() cameraVideoPicker.launch()
} }
MessageComposerEvents.PickAttachmentSource.Location -> {
showAttachmentSourcePicker = false
// Navigation to the location picker screen is done at the view layer
}
} }
} }

View file

@ -28,6 +28,7 @@ import io.element.android.libraries.textcomposer.TextComposer
@Composable @Composable
fun MessageComposerView( fun MessageComposerView(
state: MessageComposerState, state: MessageComposerState,
onSendLocationClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onFullscreenToggle() { fun onFullscreenToggle() {
@ -55,7 +56,10 @@ fun MessageComposerView(
} }
Box { Box {
AttachmentsBottomSheet(state = state) AttachmentsBottomSheet(
state = state,
onSendLocationClicked = onSendLocationClicked,
)
TextComposer( TextComposer(
onSendMessage = ::sendMessage, onSendMessage = ::sendMessage,
@ -83,5 +87,8 @@ internal fun MessageComposerViewDarkPreview(@PreviewParameter(MessageComposerSta
@Composable @Composable
private fun ContentToPreview(state: MessageComposerState) { private fun ContentToPreview(state: MessageComposerState) {
MessageComposerView(state) MessageComposerView(
state = state,
onSendLocationClicked = {}
)
} }

View file

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

View file

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