diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index 4d593e1c53..0e517fd3e6 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -17,6 +17,7 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.ksp) + id("kotlin-parcelize") } android { diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/GeoUris.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt similarity index 60% rename from features/location/api/src/main/kotlin/io/element/android/features/location/api/GeoUris.kt rename to features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt index 2986d50591..d09e163c30 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/GeoUris.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt @@ -16,19 +16,25 @@ package io.element.android.features.location.api +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + private const val GEO_URI_REGEX = """geo:(?-?\d+(?:\.\d+)?),(?-?\d+(?:\.\d+)?)(?:;u=(?\d+(?:\.\d+)?))?""" +@Parcelize data class Location( val lat: Double, val lon: Double, val accuracy: Float, -) - -fun parseGeoUri(geoUri: String): Location? { - val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null - return Location ( - lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null, - lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null, - accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f, - ) +) : Parcelable { + companion object { + fun fromGeoUri(geoUri: String): Location? { + val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null + return Location( + lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null, + lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null, + accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f, + ) + } + } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ViewLocationEntryPoint.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ViewLocationEntryPoint.kt new file mode 100644 index 0000000000..a5fb396775 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ViewLocationEntryPoint.kt @@ -0,0 +1,29 @@ +/* + * 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 com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs + +interface ViewLocationEntryPoint : FeatureEntryPoint { + + data class Inputs(val location: Location, val description: String?) : NodeInputs + + fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs): Node +} diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/GeoUrisKtTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt similarity index 53% rename from features/location/api/src/test/kotlin/io/element/android/features/location/api/GeoUrisKtTest.kt rename to features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt index 1c591bb2bb..61d9bd0351 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/GeoUrisKtTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt @@ -19,57 +19,57 @@ package io.element.android.features.location.api import com.google.common.truth.Truth.assertThat import org.junit.Test -internal class GeoUrisKtTest { +internal class LocationKtTest { @Test fun `parseGeoUri - returns null for invalid urls`() { - assertThat(parseGeoUri("")).isNull() - assertThat(parseGeoUri("http://example.com/")).isNull() - assertThat(parseGeoUri("geo:")).isNull() - assertThat(parseGeoUri("geo:1.234")).isNull() - assertThat(parseGeoUri("geo:1.234,")).isNull() - assertThat(parseGeoUri("geo:,1.234")).isNull() - assertThat(parseGeoUri("notgeo:1.234,5.678")).isNull() - assertThat(parseGeoUri("geo:+1.234,5.678")).isNull() - assertThat(parseGeoUri("geo:+1.234,*5.678")).isNull() - assertThat(parseGeoUri("geo:not,good")).isNull() - assertThat(parseGeoUri("geo:1.234,5.678;u=wrong")).isNull() - assertThat(parseGeoUri("geo:1.234,5.678trailing")).isNull() + assertThat(Location.fromGeoUri("")).isNull() + assertThat(Location.fromGeoUri("http://example.com/")).isNull() + assertThat(Location.fromGeoUri("geo:")).isNull() + assertThat(Location.fromGeoUri("geo:1.234")).isNull() + assertThat(Location.fromGeoUri("geo:1.234,")).isNull() + assertThat(Location.fromGeoUri("geo:,1.234")).isNull() + assertThat(Location.fromGeoUri("notgeo:1.234,5.678")).isNull() + assertThat(Location.fromGeoUri("geo:+1.234,5.678")).isNull() + assertThat(Location.fromGeoUri("geo:+1.234,*5.678")).isNull() + assertThat(Location.fromGeoUri("geo:not,good")).isNull() + assertThat(Location.fromGeoUri("geo:1.234,5.678;u=wrong")).isNull() + assertThat(Location.fromGeoUri("geo:1.234,5.678trailing")).isNull() } @Test fun `parseGeoUri - returns location for valid urls`() { - assertThat(parseGeoUri("geo:1.234,5.678")).isEqualTo(Location( + assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location( lat = 1.234, lon = 5.678, accuracy = 0f, )) - assertThat(parseGeoUri("geo:1,5")).isEqualTo(Location( + assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location( lat = 1.0, lon = 5.0, accuracy = 0f, )) - assertThat(parseGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location( + assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location( lat = 1.234, lon = 5.678, accuracy = 3000f, )) - assertThat(parseGeoUri("geo:1,5;u=3000")).isEqualTo(Location( + assertThat(Location.fromGeoUri("geo:1,5;u=3000")).isEqualTo(Location( lat = 1.0, lon = 5.0, accuracy = 3000f, )) - assertThat(parseGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location( + assertThat(Location.fromGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location( lat = -1.234, lon = -5.678, accuracy = 9.10f, )) - assertThat(parseGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location( + assertThat(Location.fromGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location( lat = -1.0, lon = -5.0, accuracy = 9.10f, diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index eed060cbf8..dfb5192bea 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.core) implementation(projects.libraries.matrixui) + implementation(projects.services.analytics.api) implementation(libs.maplibre) implementation(libs.maplibre.annotation) implementation(projects.libraries.uiStrings) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowImpl.kt index b650fd51de..f4fcd25de1 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowImpl.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowImpl.kt @@ -24,6 +24,7 @@ import androidx.core.content.getSystemService import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationRequestCompat +import io.element.android.features.location.api.Location import io.element.android.libraries.core.coroutine.CoroutineDispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.awaitClose diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt index b3104d1ed9..0eac47bf6c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt @@ -19,6 +19,7 @@ package io.element.android.features.location.impl.map import android.annotation.SuppressLint import android.view.Gravity import androidx.annotation.DrawableRes +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -29,7 +30,9 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview @@ -44,10 +47,12 @@ 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 com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM +import io.element.android.features.location.api.Location 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.Text import io.element.android.libraries.theme.ElementTheme import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -69,7 +74,11 @@ fun MapView( // When in preview, early return a Box with the received modifier preserving layout if (LocalInspectionMode.current) { @Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return. - Box(modifier = modifier) + Box( + modifier = modifier.background(Color.DarkGray) + ) { + Text("[MapView]", modifier = Modifier.align(Alignment.Center)) + } return } @@ -155,7 +164,7 @@ fun MapView( .withLatLng(LatLng(location.lat, location.lon)) .withIconImage("pin") .withIconSize(1.3f) - .withIconOffset(arrayOf(0f, 0.5f)) + .withIconAnchor(ICON_ANCHOR_BOTTOM) ) Timber.d("Shown pin at location: $location") } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/AndroidLocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/AndroidLocationActions.kt new file mode 100644 index 0000000000..55de9a305e --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/AndroidLocationActions.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.view + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.location.api.Location +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.AppScope +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class AndroidLocationActions @Inject constructor( + private val coroutineDispatchers: CoroutineDispatchers +) : LocationActions { + + private var activityContext: Context? = null + + @Composable + override fun Configure() { + val context = LocalContext.current + return DisposableEffect(Unit) { + activityContext = context + onDispose { + activityContext = null + } + } + } + + override suspend fun share(location: Location, label: String?) { + runCatching { + // Ref: https://developer.android.com/guide/components/intents-common#ViewMap + val suffix = if (label != null) "(${Uri.encode(label)})" else "" + val uri = Uri.parse("geo:0,0?q=${location.lat},${location.lon}$suffix") + val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri) + val chooserIntent = Intent.createChooser(showMapsIntent, null) + withContext(coroutineDispatchers.main) { + activityContext!!.startActivity(chooserIntent) + } + }.onSuccess { + Timber.v("Open location succeed") + }.onFailure { + Timber.e(it, "Open location failed") + } + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/LocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/LocationActions.kt new file mode 100644 index 0000000000..6ab9058024 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/LocationActions.kt @@ -0,0 +1,29 @@ +/* + * 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.view + +import androidx.compose.runtime.Composable +import io.element.android.features.location.api.Location + +interface LocationActions { + + @Composable + fun Configure() + + suspend fun share(location: Location, label: String?) + +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationEntryPointImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationEntryPointImpl.kt new file mode 100644 index 0000000000..5d565f575b --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationEntryPointImpl.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.view + +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.ViewLocationEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class ViewLocationEntryPointImpl @Inject constructor() : ViewLocationEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext, inputs: ViewLocationEntryPoint.Inputs): Node { + return parentNode.createNode(buildContext, listOf(inputs)) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/Location.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationEvents.kt similarity index 72% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/Location.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationEvents.kt index 67acf1cb9c..21676a0e71 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/location/Location.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationEvents.kt @@ -14,13 +14,8 @@ * limitations under the License. */ -package io.element.android.features.location.impl.location +package io.element.android.features.location.impl.view -/** - * Represents a location sample emitted by the device's location subsystem. - */ -data class Location( - val lat: Double, - val lon: Double, - val accuracy: Float, -) +sealed interface ViewLocationEvents { + object Share : ViewLocationEvents +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationNode.kt new file mode 100644 index 0000000000..ae6e42a3f6 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationNode.kt @@ -0,0 +1,61 @@ +/* + * 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.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe +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 im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.location.api.ViewLocationEntryPoint +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(RoomScope::class) +class ViewLocationNode @AssistedInject constructor( + presenterFactory: ViewLocationPresenter.Factory, + analyticsService: AnalyticsService, + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext, plugins = plugins) { + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationView)) + } + ) + } + + private val inputs: ViewLocationEntryPoint.Inputs = inputs() + private val presenter = presenterFactory.create(inputs.location, inputs.description) + + @Composable + override fun View(modifier: Modifier) { + ViewLocationView( + state = presenter.present(), + modifier = modifier, + onBackPressed = ::navigateUp + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationPresenter.kt new file mode 100644 index 0000000000..64739f1d33 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationPresenter.kt @@ -0,0 +1,58 @@ +/* + * 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.view + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.location.api.Location +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class ViewLocationPresenter @AssistedInject constructor( + private val actions: LocationActions, + @Assisted private val location: Location, + @Assisted private val description: String? +) : Presenter { + + @AssistedFactory + interface Factory { + fun create(location: Location, description: String?): ViewLocationPresenter + } + + @Composable + override fun present(): ViewLocationState { + val coroutineScope = rememberCoroutineScope() + actions.Configure() + + return ViewLocationState( + location = location, + description = description + ) { + when (it) { + ViewLocationEvents.Share -> coroutineScope.share(location, description) + } + } + } + + private fun CoroutineScope.share(location: Location, label: String?) = launch { + actions.share(location, label) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationState.kt new file mode 100644 index 0000000000..db8387e71e --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationState.kt @@ -0,0 +1,25 @@ +/* + * 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.view + +import io.element.android.features.location.api.Location + +data class ViewLocationState( + val location: Location, + val description: String?, + val eventSink: (ViewLocationEvents) -> Unit, +) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationStateProvider.kt new file mode 100644 index 0000000000..a4de1b039c --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationStateProvider.kt @@ -0,0 +1,42 @@ +/* + * 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.view + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.location.api.Location + +class ViewLocationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ViewLocationState( + Location(1.23, 2.34, 4f), + description = null, + eventSink = {}, + ), + ViewLocationState( + Location(1.23, 2.34, 4f), + description = "My favourite place!", + eventSink = {}, + ), + ViewLocationState( + Location(1.23, 2.34, 4f), + description = "For some reason I decided to write a small essay in the location description. " + + "It is so long that it will wrap onto more than two lines!", + eventSink = {}, + ), + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationView.kt new file mode 100644 index 0000000000..897d38eaf6 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/view/ViewLocationView.kt @@ -0,0 +1,125 @@ +/* + * 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.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.location.impl.map.MapState +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.CenterAlignedTopAppBar +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.compound.generated.TypographyTokens +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ViewLocationView( + state: ViewLocationState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + val mapState = rememberMapState( + location = state.location, + position = MapState.CameraPosition(state.location.lat, state.location.lon, 15.0), + ) + + Scaffold(modifier, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(CommonStrings.screen_view_location_title), + style = TypographyTokens.fontBodyLgMedium, + ) + }, + navigationIcon = { + BackButton(onClick = onBackPressed) + }, + actions = { + IconButton(onClick = { state.eventSink(ViewLocationEvents.Share) }) { + Icon(imageVector = Icons.Outlined.Share, contentDescription = stringResource(CommonStrings.action_share)) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .fillMaxSize(), + ) { + state.description?.let { + Text( + text = it, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = TypographyTokens.fontBodyMdRegular, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) + } + + MapView( + mapState = mapState, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview +@Composable +internal fun ViewLocationViewLightPreview(@PreviewParameter(ViewLocationStateProvider::class) state: ViewLocationState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun ViewLocationViewDarkPreview(@PreviewParameter(ViewLocationStateProvider::class) state: ViewLocationState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ViewLocationState) { + ViewLocationView( + state = state, + onBackPressed = {}, + ) +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowFake.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowFake.kt index ca88468c0d..861657a7e7 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowFake.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/location/LocationUpdatesFlowFake.kt @@ -16,18 +16,19 @@ 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() = io.element.android.features.location.impl.location.Location( +private fun aLocation() = Location( lat = 51.49404, lon = -0.25484, accuracy = 5f diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/view/FakeLocationActions.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/view/FakeLocationActions.kt new file mode 100644 index 0000000000..03fdf5222a --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/view/FakeLocationActions.kt @@ -0,0 +1,42 @@ +/* + * 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.view + +import androidx.compose.runtime.Composable +import io.element.android.features.location.api.Location + +class FakeLocationActions : LocationActions { + + var configured = false + private set + + var sharedLocation: Location? = null + private set + + var sharedLabel: String? = null + private set + + @Composable + override fun Configure() { + configured = true + } + + override suspend fun share(location: Location, label: String?) { + sharedLocation = location + sharedLabel = label + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/view/ViewLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/view/ViewLocationPresenterTest.kt new file mode 100644 index 0000000000..44dac70be8 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/view/ViewLocationPresenterTest.kt @@ -0,0 +1,73 @@ +/* + * 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.view + +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.features.location.api.Location +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ViewLocationPresenterTest { + + private val actions = FakeLocationActions() + private val location = Location(1.23, 4.56, 7.8f) + + @Test + fun `emits initial state`() = runTest { + val presenter = ViewLocationPresenter( + actions, + location, + A_DESCRIPTION, + ) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.location).isEqualTo(location) + Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION) + } + } + + @Test + fun `uses action to share location`() = runTest { + val presenter = ViewLocationPresenter( + actions, + location, + A_DESCRIPTION, + ) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ViewLocationEvents.Share) + + Truth.assertThat(actions.configured).isTrue() + Truth.assertThat(actions.sharedLocation).isEqualTo(location) + Truth.assertThat(actions.sharedLabel).isEqualTo(A_DESCRIPTION) + } + } + + companion object { + private const val A_DESCRIPTION = "My happy place" + } + +} 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 35a34638e0..dffffd0329 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,7 +29,9 @@ 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.Location import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.features.location.api.ViewLocationEntryPoint 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 @@ -41,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNo import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -59,6 +62,7 @@ class MessagesFlowNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val sendLocationEntryPoint: SendLocationEntryPoint, + private val viewLocationEntryPoint: ViewLocationEntryPoint, ) : BackstackNode( backstack = BackStack( initialElement = NavTarget.Messages, @@ -82,6 +86,9 @@ class MessagesFlowNode @AssistedInject constructor( @Parcelize data class AttachmentPreview(val attachment: Attachment) : NavTarget + @Parcelize + data class LocationViewer(val location: Location, val description: String?) : NavTarget + @Parcelize data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget @@ -147,6 +154,10 @@ class MessagesFlowNode @AssistedInject constructor( val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment) createNode(buildContext, listOf(inputs)) } + is NavTarget.LocationViewer -> { + val inputs = ViewLocationEntryPoint.Inputs(navTarget.location, navTarget.description) + viewLocationEntryPoint.createNode(this, buildContext, inputs) + } is NavTarget.EventDebugInfo -> { val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo) createNode(buildContext, listOf(inputs)) @@ -213,6 +224,13 @@ class MessagesFlowNode @AssistedInject constructor( ) backstack.push(navTarget) } + is TimelineItemLocationContent -> { + val navTarget = NavTarget.LocationViewer( + location = event.content.location, + description = event.content.description, + ) + backstack.push(navTarget) + } else -> Unit } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 6d70d8318b..64b5cd697e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -16,7 +16,7 @@ package io.element.android.features.messages.impl.timeline.factories.event -import io.element.android.features.location.api.parseGeoUri +import io.element.android.features.location.api.Location import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent @@ -68,7 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor( ) } is LocationMessageType -> { - val location = parseGeoUri(messageType.geoUri) + val location = Location.fromGeoUri(messageType.geoUri) if (location == null) { TimelineItemTextContent( body = messageType.body,