diff --git a/build.gradle.kts b/build.gradle.kts index f975ef4fcb..99da9c4175 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -247,7 +247,7 @@ koverMerged { 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.LocalMediaViewState" - excludes += "io.element.android.features.location.api.MapState" + excludes += "io.element.android.features.location.impl.map.MapState" } bound { minValue = 90 diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index a2f73e7def..8d3e20e479 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -16,8 +16,6 @@ plugins { id("io.element.android-compose-library") - alias(libs.plugins.anvil) - alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) } @@ -25,29 +23,14 @@ android { namespace = "io.element.android.features.location.api" } -anvil { - generateDaggerFactories.set(true) -} - dependencies { - implementation(libs.dagger) + implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) - implementation(projects.libraries.network) implementation(projects.libraries.core) implementation(projects.libraries.uiStrings) - implementation(libs.maplibre) - implementation(libs.network.retrofit) - implementation(libs.maplibre.annotation) implementation(libs.coil.compose) - implementation(libs.serialization.json) - implementation(libs.accompanist.permission) ksp(libs.showkase.processor) 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) } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt new file mode 100644 index 0000000000..a1b43d6a5c --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt @@ -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 diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt index f5a15e46c6..d3b9130045 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt @@ -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_ZOOM = 22.0 -internal fun buildTileServerUrl( +fun buildTileServerUrl( darkMode: Boolean ): String = if (!darkMode) { "$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY" diff --git a/features/location/fake/build.gradle.kts b/features/location/fake/build.gradle.kts deleted file mode 100644 index cceab3f2b7..0000000000 --- a/features/location/fake/build.gradle.kts +++ /dev/null @@ -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) -} diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 66d29fd6bb..819c590499 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -17,7 +17,6 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.anvil) - alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) } @@ -30,18 +29,18 @@ anvil { } dependencies { - implementation(libs.dagger) api(projects.features.location.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) implementation(projects.libraries.di) implementation(projects.libraries.designsystem) - 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) + implementation(projects.libraries.uiStrings) + implementation(libs.dagger) + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) ksp(libs.showkase.processor) testImplementation(libs.test.junit) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationEntryPointImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationEntryPointImpl.kt new file mode 100644 index 0000000000..345fcef1a2 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationEntryPointImpl.kt @@ -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) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationEvents.kt new file mode 100644 index 0000000000..360d77d879 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationEvents.kt @@ -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 +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationNode.kt new file mode 100644 index 0000000000..fb6f723a65 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationNode.kt @@ -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, + private val presenter: SendLocationPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + SendLocationView( + state = presenter.present(), + modifier = modifier, + onBackPressed = ::navigateUp, + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationPresenter.kt new file mode 100644 index 0000000000..1be82525e9 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationPresenter.kt @@ -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 { + @Composable + override fun present(): SendLocationState { + + val scope = rememberCoroutineScope() + + var mode by remember { + mutableStateOf(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}", + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationState.kt new file mode 100644 index 0000000000..c09f391b7f --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationState.kt @@ -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 + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationStateProvider.kt new file mode 100644 index 0000000000..1337f809e4 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationStateProvider.kt @@ -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 { + override val values: Sequence + get() = sequenceOf( + SendLocationState(), + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationView.kt new file mode 100644 index 0000000000..b1fd3b88c3 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/SendLocationView.kt @@ -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 = {}, + ) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/Location.kt similarity index 92% rename from features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/Location.kt index 596bc4d1a0..67acf1cb9c 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/Location.kt @@ -14,7 +14,7 @@ * 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. diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowImpl.kt similarity index 96% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowImpl.kt index 11b1e2a02d..b650fd51de 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowImpl.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.location.impl +package io.element.android.features.location.impl.location import android.Manifest import android.content.Context @@ -25,7 +25,6 @@ import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationRequestCompat import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.features.location.api.Location import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt similarity index 91% rename from features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt index 081a35dc34..0d2627b33c 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt @@ -14,15 +14,13 @@ * 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.view.Gravity import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -31,7 +29,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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.plugins.annotation.SymbolManager 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.impl.location.Location import io.element.android.libraries.designsystem.preview.ElementPreviewDark 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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -68,7 +65,6 @@ fun MapView( modifier: Modifier = Modifier, mapState: MapState = rememberMapState(), darkMode: Boolean = !ElementTheme.isLightTheme, - onLocationClick: () -> Unit, ) { // When in preview, early return a Box with the received modifier preserving layout if (LocalInspectionMode.current) { @@ -88,7 +84,10 @@ fun MapView( LaunchedEffect(darkMode) { mapView.awaitMap().let { map -> map.uiSettings.apply { + attributionGravity = Gravity.TOP + logoGravity = Gravity.TOP isCompassEnabled = false + isRotateGesturesEnabled = false } map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style -> mapRefs = MapRefs( @@ -180,20 +179,10 @@ fun MapView( } @Suppress("ModifierReused") - Box(modifier = modifier) { - AndroidView(factory = { mapView }) - FloatingActionButton( - onClick = onLocationClick, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp), - ) { - Icon( - imageVector = Icons.Filled.LocationOn, - contentDescription = null, // TODO - ) - } - } + AndroidView( + factory = { mapView }, + modifier = modifier + ) } @Composable @@ -292,6 +281,5 @@ private fun ContentToPreview() { ) ).toImmutableList() ), - onLocationClick = {}, ) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/SendLocationPresenterTest.kt new file mode 100644 index 0000000000..5d35f06861 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/SendLocationPresenterTest.kt @@ -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) + } + } +} diff --git a/features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowFake.kt similarity index 77% rename from features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt rename to features/location/impl/src/test/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowFake.kt index c3f070acbb..ca88468c0d 100644 --- a/features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowFake.kt @@ -14,21 +14,20 @@ * 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.flow.Flow import kotlinx.coroutines.flow.flow -fun fakeLocationUpdatesFlow(): Flow = flow { +fun fakeLocationUpdatesFlow(): Flow = flow { while (true) { delay(1_000) emit(aLocation()) } } -private fun aLocation() = Location( +private fun aLocation() = io.element.android.features.location.impl.location.Location( lat = 51.49404, lon = -0.25484, accuracy = 5f diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 354659ccfc..44d5d34c41 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) api(projects.features.messages.api) + implementation(projects.features.location.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 901716451f..35a34638e0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -29,6 +29,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject 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.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode @@ -57,6 +58,7 @@ import kotlinx.parcelize.Parcelize class MessagesFlowNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, + private val sendLocationEntryPoint: SendLocationEntryPoint, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.Messages, @@ -88,6 +90,9 @@ class MessagesFlowNode @AssistedInject constructor( @Parcelize data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget + + @Parcelize + object SendLocation : NavTarget } private val callback = plugins().firstOrNull() @@ -123,6 +128,10 @@ class MessagesFlowNode @AssistedInject constructor( override fun onReportMessage(eventId: EventId, senderId: UserId) { backstack.push(NavTarget.ReportMessage(eventId, senderId)) } + + override fun onSendLocationClicked() { + backstack.push(NavTarget.SendLocation) + } } createNode(buildContext, listOf(callback)) } @@ -155,6 +164,9 @@ class MessagesFlowNode @AssistedInject constructor( val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId) createNode(buildContext, listOf(inputs)) } + NavTarget.SendLocation -> { + sendLocationEntryPoint.createNode(this, buildContext) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index baffafc609..8a44d0b748 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -57,6 +57,7 @@ class MessagesNode @AssistedInject constructor( fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) fun onForwardEventClicked(eventId: EventId) fun onReportMessage(eventId: EventId, senderId: UserId) + fun onSendLocationClicked() } init { @@ -93,6 +94,10 @@ class MessagesNode @AssistedInject constructor( override fun onReportContentClicked(eventId: EventId, senderId: UserId) { callback?.onReportMessage(eventId, senderId) } + + private fun onSendLocationClicked() { + callback?.onSendLocationClicked() + } @Composable override fun View(modifier: Modifier) { @@ -104,6 +109,7 @@ class MessagesNode @AssistedInject constructor( onEventClicked = this::onEventClicked, onPreviewAttachments = this::onPreviewAttachments, onUserDataClicked = this::onUserDataClicked, + onSendLocationClicked = this::onSendLocationClicked, modifier = modifier, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 1e9f9636c4..037199aba8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -92,6 +92,7 @@ fun MessagesView( onEventClicked: (event: TimelineItem.Event) -> Unit, onUserDataClicked: (UserId) -> Unit, onPreviewAttachments: (ImmutableList) -> Unit, + onSendLocationClicked: () -> Unit, modifier: Modifier = Modifier, ) { LogCompositions(tag = "MessagesScreen", msg = "Root") @@ -153,7 +154,8 @@ fun MessagesView( state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) } }, - onReactionClicked = ::onEmojiReactionClicked + onReactionClicked = ::onEmojiReactionClicked, + onSendLocationClicked = onSendLocationClicked, ) }, snackbarHost = { @@ -237,6 +239,7 @@ fun MessagesViewContent( onReactionClicked: (key: String, TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, + onSendLocationClicked: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -260,6 +263,7 @@ fun MessagesViewContent( if (state.userHasPermissionToSendMessage) { MessageComposerView( state = state.composerState, + onSendLocationClicked = onSendLocationClicked, modifier = Modifier .fillMaxWidth() .wrapContentHeight(Alignment.Bottom) @@ -347,5 +351,6 @@ private fun ContentToPreview(state: MessagesState) { onEventClicked = {}, onPreviewAttachments = {}, onUserDataClicked = {}, + onSendLocationClicked = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt index 5dd055e711..597a38ccbb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt @@ -25,6 +25,7 @@ import androidx.compose.material.ListItem import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile 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.Videocam import androidx.compose.material3.ExperimentalMaterial3Api @@ -48,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable internal fun AttachmentsBottomSheet( state: MessageComposerState, + onSendLocationClicked: () -> Unit, modifier: Modifier = Modifier, ) { val localView = LocalView.current @@ -78,7 +80,10 @@ internal fun AttachmentsBottomSheet( modifier = modifier, onDismissRequest = { isVisible = false } ) { - AttachmentSourcePickerMenu(eventSink = state.eventSink) + AttachmentSourcePickerMenu( + eventSink = state.eventSink, + onSendLocationClicked = onSendLocationClicked, + ) } } } @@ -87,6 +92,7 @@ internal fun AttachmentsBottomSheet( @Composable internal fun AttachmentSourcePickerMenu( eventSink: (MessageComposerEvents) -> Unit, + onSendLocationClicked: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -113,5 +119,13 @@ internal fun AttachmentSourcePickerMenu( icon = { Icon(Icons.Default.Videocam, null) }, 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)) }, + ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index cc59ab8c65..82fb0982f4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -34,5 +34,6 @@ sealed interface MessageComposerEvents { object FromFiles : PickAttachmentSource object PhotoFromCamera : PickAttachmentSource object VideoFromCamera : PickAttachmentSource + object Location : PickAttachmentSource } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index bb7744f71f..cd2ee81435 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -159,6 +159,10 @@ class MessageComposerPresenter @Inject constructor( showAttachmentSourcePicker = false cameraVideoPicker.launch() } + MessageComposerEvents.PickAttachmentSource.Location -> { + showAttachmentSourcePicker = false + // Navigation to the location picker screen is done at the view layer + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 570d875819..2fa95518ca 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.textcomposer.TextComposer @Composable fun MessageComposerView( state: MessageComposerState, + onSendLocationClicked: () -> Unit, modifier: Modifier = Modifier, ) { fun onFullscreenToggle() { @@ -55,7 +56,10 @@ fun MessageComposerView( } Box { - AttachmentsBottomSheet(state = state) + AttachmentsBottomSheet( + state = state, + onSendLocationClicked = onSendLocationClicked, + ) TextComposer( onSendMessage = ::sendMessage, @@ -83,5 +87,8 @@ internal fun MessageComposerViewDarkPreview(@PreviewParameter(MessageComposerSta @Composable private fun ContentToPreview(state: MessageComposerState) { - MessageComposerView(state) + MessageComposerView( + state = state, + onSendLocationClicked = {} + ) } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.map_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.map_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.map_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.map_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl_null_DefaultGroup_SendLocationViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl_null_DefaultGroup_SendLocationViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3f52a57bc3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl_null_DefaultGroup_SendLocationViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46073600d49a438279e3fb891573a88eb17dd7c74f853078ce7d33dadc81c298 +size 14260 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl_null_DefaultGroup_SendLocationViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl_null_DefaultGroup_SendLocationViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..24dd806db1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl_null_DefaultGroup_SendLocationViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bd98280b4cfbd97bfebe731a772874cef0a50ceea363d0d365318d38d164774 +size 16249