Merge pull request #6342 from element-hq/feature/fga/live_location_sharing_setup
Setup live location sharing feature
This commit is contained in:
@@ -307,6 +307,7 @@ licensee {
|
|||||||
allow("BSD-2-Clause")
|
allow("BSD-2-Clause")
|
||||||
allow("BSD-3-Clause")
|
allow("BSD-3-Clause")
|
||||||
allow("EPL-1.0")
|
allow("EPL-1.0")
|
||||||
|
allowUrl("https://opensource.org/license/bsd-3-clause")
|
||||||
allowUrl("https://opensource.org/licenses/MIT")
|
allowUrl("https://opensource.org/licenses/MIT")
|
||||||
allowUrl("https://developer.android.com/studio/terms.html")
|
allowUrl("https://developer.android.com/studio/terms.html")
|
||||||
allowUrl("https://www.zetetic.net/sqlcipher/license/")
|
allowUrl("https://www.zetetic.net/sqlcipher/license/")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitu
|
|||||||
data class Location(
|
data class Location(
|
||||||
val lat: Double,
|
val lat: Double,
|
||||||
val lon: Double,
|
val lon: Double,
|
||||||
val accuracy: Float,
|
val accuracy: Float? = null,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromGeoUri(geoUri: String): Location? {
|
fun fromGeoUri(geoUri: String): Location? {
|
||||||
@@ -27,12 +27,15 @@ data class Location(
|
|||||||
return Location(
|
return Location(
|
||||||
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
|
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
|
||||||
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
|
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
|
||||||
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f,
|
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toGeoUri(): String {
|
fun toGeoUri(): String = buildString {
|
||||||
return "geo:$lat,$lon;u=$accuracy"
|
append("geo:$lat,$lon")
|
||||||
|
if (accuracy != null) {
|
||||||
|
append(";u=$accuracy")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
|
|||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "Send location" screen.
|
* The "Share location" screen.
|
||||||
*
|
*
|
||||||
* Allows a user to share a location message within a room.
|
* Allows a user to share a location message within a room.
|
||||||
*/
|
*/
|
||||||
interface SendLocationEntryPoint : FeatureEntryPoint {
|
interface ShareLocationEntryPoint : FeatureEntryPoint {
|
||||||
fun createNode(
|
fun createNode(
|
||||||
parentNode: Node,
|
parentNode: Node,
|
||||||
buildContext: BuildContext,
|
buildContext: BuildContext,
|
||||||
@@ -15,8 +15,7 @@ import io.element.android.libraries.architecture.NodeInputs
|
|||||||
|
|
||||||
interface ShowLocationEntryPoint : FeatureEntryPoint {
|
interface ShowLocationEntryPoint : FeatureEntryPoint {
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val location: Location,
|
val mode: ShowLocationMode,
|
||||||
val description: String?,
|
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
fun createNode(
|
fun createNode(
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.api
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
sealed interface ShowLocationMode : Parcelable {
|
||||||
|
@Parcelize
|
||||||
|
data class Static(
|
||||||
|
val location: Location,
|
||||||
|
val senderName: String,
|
||||||
|
val senderId: UserId,
|
||||||
|
val senderAvatarUrl: String?,
|
||||||
|
val timestamp: Long,
|
||||||
|
val assetType: AssetType?,
|
||||||
|
) : ShowLocationMode
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object Live : ShowLocationMode
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -32,10 +31,10 @@ import io.element.android.compound.theme.ElementTheme
|
|||||||
import io.element.android.features.location.api.internal.StaticMapPlaceholder
|
import io.element.android.features.location.api.internal.StaticMapPlaceholder
|
||||||
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
|
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
|
||||||
import io.element.android.features.location.api.internal.centerBottomEdge
|
import io.element.android.features.location.api.internal.centerBottomEdge
|
||||||
|
import io.element.android.libraries.designsystem.components.LocationPin
|
||||||
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a static map image downloaded via a third party service's static maps API.
|
* Shows a static map image downloaded via a third party service's static maps API.
|
||||||
@@ -45,6 +44,7 @@ fun StaticMapView(
|
|||||||
lat: Double,
|
lat: Double,
|
||||||
lon: Double,
|
lon: Double,
|
||||||
zoom: Double,
|
zoom: Double,
|
||||||
|
pinVariant: PinVariant,
|
||||||
contentDescription: String?,
|
contentDescription: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
darkMode: Boolean = !ElementTheme.isLightTheme,
|
darkMode: Boolean = !ElementTheme.isLightTheme,
|
||||||
@@ -95,12 +95,7 @@ fun StaticMapView(
|
|||||||
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
|
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
Icon(
|
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
|
||||||
resourceId = CommonDrawables.pin,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.Unspecified,
|
|
||||||
modifier = Modifier.centerBottomEdge(this),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
StaticMapPlaceholder(
|
StaticMapPlaceholder(
|
||||||
showProgress = collectedState.value.isLoading(),
|
showProgress = collectedState.value.isLoading(),
|
||||||
@@ -127,6 +122,7 @@ internal fun StaticMapViewPreview() = ElementPreview {
|
|||||||
lon = 0.0,
|
lon = 0.0,
|
||||||
zoom = 0.0,
|
zoom = 0.0,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
|
pinVariant = PinVariant.PinnedLocation,
|
||||||
modifier = Modifier.size(400.dp),
|
modifier = Modifier.size(400.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ internal class MapTilerStaticMapUrlBuilder(
|
|||||||
// image smaller than the available space in pixels.
|
// image smaller than the available space in pixels.
|
||||||
// The resulting image will have to be scaled to fit the available space in order
|
// The resulting image will have to be scaled to fit the available space in order
|
||||||
// to keep the perceived content size constant at the expense of sharpness.
|
// to keep the perceived content size constant at the expense of sharpness.
|
||||||
return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft"
|
return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=topright"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isServiceAvailable() = apiKey.isNotEmpty()
|
override fun isServiceAvailable() = apiKey.isNotEmpty()
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ internal class LocationKtTest {
|
|||||||
assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location(
|
assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location(
|
||||||
lat = 1.234,
|
lat = 1.234,
|
||||||
lon = 5.678,
|
lon = 5.678,
|
||||||
accuracy = 0f,
|
accuracy = null,
|
||||||
))
|
))
|
||||||
|
|
||||||
assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
|
assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
|
||||||
lat = 1.0,
|
lat = 1.0,
|
||||||
lon = 5.0,
|
lon = 5.0,
|
||||||
accuracy = 0f,
|
accuracy = null,
|
||||||
))
|
))
|
||||||
|
|
||||||
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
|
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
|
||||||
@@ -68,7 +68,13 @@ internal class LocationKtTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `encode geoUri - returns geoUri from a Location`() {
|
fun `encode geoUri - returns geoUri from a Location without accuracy`() {
|
||||||
|
assertThat(Location(1.0, 2.0, null).toGeoUri())
|
||||||
|
.isEqualTo("geo:1.0,2.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encode geoUri - returns geoUri from a Location with accuracy`() {
|
||||||
assertThat(Location(1.0, 2.0, 3.0f).toGeoUri())
|
assertThat(Location(1.0, 2.0, 3.0f).toGeoUri())
|
||||||
.isEqualTo("geo:1.0,2.0;u=3.0")
|
.isEqualTo("geo:1.0,2.0;u=3.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 600,
|
height = 600,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=topright")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -62,7 +62,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 900,
|
height = 900,
|
||||||
density = 1.5f,
|
density = 1.5f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=topright")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -77,7 +77,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 1200,
|
height = 1200,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=topright")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -92,7 +92,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 1800,
|
height = 1800,
|
||||||
density = 3f,
|
density = 3f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=topright")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -107,7 +107,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 2048,
|
height = 2048,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=topright")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
@@ -119,7 +119,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 4096,
|
height = 4096,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=topright")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
@@ -131,7 +131,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 2048,
|
height = 2048,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=topright")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
@@ -143,7 +143,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 4096,
|
height = 4096,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=topright")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
@@ -155,7 +155,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = Int.MAX_VALUE,
|
height = Int.MAX_VALUE,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=topright")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -170,7 +170,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 0,
|
height = 0,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=topright")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
@@ -182,7 +182,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = 0,
|
height = 0,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=topright")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
@@ -194,6 +194,6 @@ class MapTilerStaticMapUrlBuilderTest {
|
|||||||
height = Int.MIN_VALUE,
|
height = Int.MIN_VALUE,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=topright")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ setupDependencyInjection()
|
|||||||
dependencies {
|
dependencies {
|
||||||
api(projects.features.location.api)
|
api(projects.features.location.api)
|
||||||
implementation(projects.features.messages.api)
|
implementation(projects.features.messages.api)
|
||||||
implementation(projects.libraries.maplibreCompose)
|
implementation(libs.maplibre.compose)
|
||||||
|
implementation(libs.coil)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
@@ -35,14 +36,18 @@ dependencies {
|
|||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.androidutils)
|
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(libs.accompanist.permission)
|
implementation(libs.accompanist.permission)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(projects.libraries.featureflag.api)
|
||||||
|
implementation(projects.libraries.dateformatter.api)
|
||||||
|
|
||||||
testCommonDependencies(libs, true)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.dateformatter.test)
|
||||||
|
testImplementation(projects.services.toolbox.test)
|
||||||
testImplementation(projects.libraries.testtags)
|
testImplementation(projects.libraries.testtags)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.features.messages.test)
|
testImplementation(projects.features.messages.test)
|
||||||
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common
|
||||||
|
|
||||||
|
import io.element.android.features.location.impl.common.actions.LocationActions
|
||||||
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
|
||||||
|
sealed interface LocationConstraintsCheck {
|
||||||
|
data object Success : LocationConstraintsCheck
|
||||||
|
data object PermissionRationale : LocationConstraintsCheck
|
||||||
|
data object PermissionDenied : LocationConstraintsCheck
|
||||||
|
data object LocationServiceDisabled : LocationConstraintsCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkLocationConstraints(
|
||||||
|
permissionsState: PermissionsState,
|
||||||
|
locationActions: LocationActions,
|
||||||
|
): LocationConstraintsCheck {
|
||||||
|
return when {
|
||||||
|
permissionsState.isAnyGranted -> {
|
||||||
|
if (locationActions.isLocationEnabled()) {
|
||||||
|
LocationConstraintsCheck.Success
|
||||||
|
} else {
|
||||||
|
LocationConstraintsCheck.LocationServiceDisabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
permissionsState.shouldShowRationale -> LocationConstraintsCheck.PermissionRationale
|
||||||
|
else -> LocationConstraintsCheck.PermissionDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
|
||||||
|
return when (this) {
|
||||||
|
LocationConstraintsCheck.Success -> LocationConstraintsDialogState.None
|
||||||
|
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
|
||||||
|
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
|
||||||
|
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,57 +9,35 @@
|
|||||||
package io.element.android.features.location.impl.common
|
package io.element.android.features.location.impl.common
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.view.Gravity
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.runtime.Composable
|
import org.maplibre.compose.camera.CameraPosition
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import org.maplibre.compose.map.GestureOptions
|
||||||
import androidx.compose.ui.graphics.Color
|
import org.maplibre.compose.map.MapOptions
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import org.maplibre.compose.map.OrnamentOptions
|
||||||
import io.element.android.libraries.maplibre.compose.MapLocationSettings
|
import org.maplibre.compose.map.RenderOptions
|
||||||
import io.element.android.libraries.maplibre.compose.MapSymbolManagerSettings
|
import org.maplibre.spatialk.geojson.Position
|
||||||
import io.element.android.libraries.maplibre.compose.MapUiSettings
|
|
||||||
import org.maplibre.android.camera.CameraPosition
|
|
||||||
import org.maplibre.android.geometry.LatLng
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common configuration values for the map.
|
* Common configuration values for the map.
|
||||||
*/
|
*/
|
||||||
object MapDefaults {
|
object MapDefaults {
|
||||||
val uiSettings: MapUiSettings
|
val options = MapOptions(
|
||||||
@Composable
|
renderOptions = RenderOptions.Standard,
|
||||||
@ReadOnlyComposable
|
gestureOptions = GestureOptions.Standard,
|
||||||
get() = MapUiSettings(
|
ornamentOptions = OrnamentOptions(
|
||||||
compassEnabled = false,
|
isLogoEnabled = true,
|
||||||
rotationGesturesEnabled = false,
|
logoAlignment = Alignment.BottomStart,
|
||||||
scrollGesturesEnabled = true,
|
isAttributionEnabled = true,
|
||||||
tiltGesturesEnabled = false,
|
attributionAlignment = Alignment.BottomEnd,
|
||||||
zoomGesturesEnabled = true,
|
isCompassEnabled = false,
|
||||||
logoGravity = Gravity.TOP,
|
isScaleBarEnabled = false,
|
||||||
attributionGravity = Gravity.TOP,
|
|
||||||
attributionTintColor = ElementTheme.colors.iconPrimary
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
val symbolManagerSettings: MapSymbolManagerSettings
|
val defaultCameraPosition = CameraPosition(
|
||||||
get() = MapSymbolManagerSettings(
|
target = Position(0.0, 0.0),
|
||||||
iconAllowOverlap = true
|
zoom = 0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
val locationSettings: MapLocationSettings
|
|
||||||
get() = MapLocationSettings(
|
|
||||||
locationEnabled = false,
|
|
||||||
backgroundTintColor = Color.White,
|
|
||||||
foregroundTintColor = Color.Black,
|
|
||||||
backgroundStaleTintColor = Color.White,
|
|
||||||
foregroundStaleTintColor = Color.Black,
|
|
||||||
accuracyColor = Color.Black,
|
|
||||||
pulseEnabled = true,
|
|
||||||
pulseColor = Color.Black,
|
|
||||||
)
|
|
||||||
|
|
||||||
val centerCameraPosition = CameraPosition.Builder()
|
|
||||||
.target(LatLng(49.843, 9.902056))
|
|
||||||
.zoom(2.7)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
const val DEFAULT_ZOOM = 15.0
|
const val DEFAULT_ZOOM = 15.0
|
||||||
|
|
||||||
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun PermissionDeniedDialog(
|
|
||||||
onContinue: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
appName: String,
|
|
||||||
) {
|
|
||||||
ConfirmationDialog(
|
|
||||||
content = stringResource(CommonStrings.error_missing_location_auth_android, appName),
|
|
||||||
onSubmitClick = onContinue,
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
submitText = stringResource(CommonStrings.action_continue),
|
|
||||||
cancelText = stringResource(CommonStrings.action_cancel),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun PermissionRationaleDialog(
|
|
||||||
onContinue: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
appName: String,
|
|
||||||
) {
|
|
||||||
ConfirmationDialog(
|
|
||||||
content = stringResource(CommonStrings.error_missing_location_rationale_android, appName),
|
|
||||||
onSubmitClick = onContinue,
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
submitText = stringResource(CommonStrings.action_continue),
|
|
||||||
cancelText = stringResource(CommonStrings.action_cancel),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,11 @@ package io.element.android.features.location.impl.common.actions
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.location.LocationManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.core.location.LocationManagerCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
@@ -40,9 +43,26 @@ class AndroidLocationActions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun openSettings() {
|
override fun openAppSettings() {
|
||||||
context.openAppSettingsPage()
|
context.openAppSettingsPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun isLocationEnabled(): Boolean {
|
||||||
|
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
return LocationManagerCompat.isLocationEnabled(locationManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openLocationSettings() {
|
||||||
|
runCatchingExceptions {
|
||||||
|
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}.onSuccess {
|
||||||
|
Timber.v("Open location settings succeed")
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Open location settings failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
||||||
|
|||||||
@@ -12,5 +12,7 @@ import io.element.android.features.location.api.Location
|
|||||||
|
|
||||||
interface LocationActions {
|
interface LocationActions {
|
||||||
fun share(location: Location, label: String?)
|
fun share(location: Location, label: String?)
|
||||||
fun openSettings()
|
fun openAppSettings()
|
||||||
|
fun isLocationEnabled(): Boolean
|
||||||
|
fun openLocationSettings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LocationConstraintsDialog(
|
||||||
|
state: LocationConstraintsDialogState,
|
||||||
|
appName: String,
|
||||||
|
onRequestPermissions: () -> Unit,
|
||||||
|
onOpenAppSettings: () -> Unit,
|
||||||
|
onOpenLocationSettings: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
LocationConstraintsDialogState.None -> Unit
|
||||||
|
LocationConstraintsDialogState.PermissionRationale -> ConfirmationDialog(
|
||||||
|
content = stringResource(CommonStrings.error_missing_location_rationale_android, appName),
|
||||||
|
onSubmitClick = onRequestPermissions,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
submitText = stringResource(CommonStrings.action_continue),
|
||||||
|
)
|
||||||
|
LocationConstraintsDialogState.PermissionDenied -> ConfirmationDialog(
|
||||||
|
content = stringResource(CommonStrings.error_missing_location_auth_android, appName),
|
||||||
|
onSubmitClick = onOpenAppSettings,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
submitText = stringResource(CommonStrings.action_continue),
|
||||||
|
)
|
||||||
|
LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog(
|
||||||
|
content = stringResource(CommonStrings.error_location_service_disabled_android),
|
||||||
|
onSubmitClick = onOpenLocationSettings,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
submitText = stringResource(CommonStrings.action_continue),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface LocationConstraintsDialogState {
|
||||||
|
data object None : LocationConstraintsDialogState
|
||||||
|
data object PermissionRationale : LocationConstraintsDialogState
|
||||||
|
data object PermissionDenied : LocationConstraintsDialogState
|
||||||
|
data object LocationServiceDisabled : LocationConstraintsDialogState
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
package io.element.android.features.location.impl.common.ui
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -30,13 +30,11 @@ internal fun LocationFloatingActionButton(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
shape = FloatingActionButtonDefaults.smallShape,
|
shape = CircleShape,
|
||||||
containerColor = ElementTheme.colors.bgCanvasDefault,
|
containerColor = ElementTheme.colors.bgCanvasDefault,
|
||||||
contentColor = ElementTheme.colors.iconPrimary,
|
contentColor = ElementTheme.colors.iconPrimary,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier
|
modifier = modifier.size(48.dp),
|
||||||
// Note: design is 40dp, but min is 48 for accessibility.
|
|
||||||
.size(48.dp),
|
|
||||||
) {
|
) {
|
||||||
val iconImage = if (isMapCenteredOnUser) {
|
val iconImage = if (isMapCenteredOnUser) {
|
||||||
CompoundIcons.LocationNavigatorCentred()
|
CompoundIcons.LocationNavigatorCentred()
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
|
import io.element.android.libraries.designsystem.components.rememberLocationPinBitmap
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.maplibre.compose.expressions.dsl.and
|
||||||
|
import org.maplibre.compose.expressions.dsl.asString
|
||||||
|
import org.maplibre.compose.expressions.dsl.const
|
||||||
|
import org.maplibre.compose.expressions.dsl.eq
|
||||||
|
import org.maplibre.compose.expressions.dsl.feature
|
||||||
|
import org.maplibre.compose.expressions.dsl.image
|
||||||
|
import org.maplibre.compose.expressions.dsl.not
|
||||||
|
import org.maplibre.compose.expressions.value.SymbolAnchor
|
||||||
|
import org.maplibre.compose.layers.CircleLayer
|
||||||
|
import org.maplibre.compose.layers.SymbolLayer
|
||||||
|
import org.maplibre.compose.sources.GeoJsonData
|
||||||
|
import org.maplibre.compose.sources.GeoJsonOptions
|
||||||
|
import org.maplibre.compose.sources.GeoJsonSource
|
||||||
|
import org.maplibre.compose.sources.rememberGeoJsonSource
|
||||||
|
import org.maplibre.compose.util.ClickResult
|
||||||
|
import org.maplibre.spatialk.geojson.Feature
|
||||||
|
import org.maplibre.spatialk.geojson.FeatureCollection
|
||||||
|
import org.maplibre.spatialk.geojson.Point
|
||||||
|
import org.maplibre.spatialk.geojson.Position
|
||||||
|
import org.maplibre.spatialk.geojson.toJson
|
||||||
|
|
||||||
|
private const val LOCATION_MARKER_ID = "LOCATION_MARKER_ID"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class representing a marker on the map.
|
||||||
|
*
|
||||||
|
* @param id Unique identifier for the marker
|
||||||
|
* @param location The geographic location of the marker
|
||||||
|
* @param variant The visual variant of the pin (user location, pinned, stale)
|
||||||
|
*/
|
||||||
|
data class LocationMarkerData(
|
||||||
|
val id: String,
|
||||||
|
val location: Location,
|
||||||
|
val variant: PinVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable that renders location markers on a MapLibre map with clustering support.
|
||||||
|
*
|
||||||
|
* Uses GeoJSON source with clustering enabled to group nearby markers.
|
||||||
|
* Individual markers are rendered using Canvas-based pin rendering with Coil for avatar loading.
|
||||||
|
* Clusters are rendered as circles with point counts.
|
||||||
|
*
|
||||||
|
* Must be used within a MaplibreMap content block.
|
||||||
|
*
|
||||||
|
* @param markers List of markers to display on the map
|
||||||
|
* @param onMarkerClick Callback when a marker is clicked
|
||||||
|
* @param onClusterClick Callback when a cluster is clicked, provides cluster center position
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LocationPinMarkers(
|
||||||
|
markers: List<LocationMarkerData>,
|
||||||
|
onMarkerClick: ((LocationMarkerData) -> Unit)? = null,
|
||||||
|
onClusterClick: ((Position) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
if (markers.isEmpty()) return
|
||||||
|
val clusterColor = ElementTheme.colors.bgAccentRest
|
||||||
|
val clusterStrokeColor = ElementTheme.colors.iconOnSolidPrimary
|
||||||
|
val clusterTextColor = ElementTheme.colors.textOnSolidPrimary
|
||||||
|
val clusterTextStyle = ElementTheme.typography.fontBodyMdMedium
|
||||||
|
|
||||||
|
// Convert markers to GeoJSON
|
||||||
|
val geoJsonString = remember(markers) {
|
||||||
|
val features = markers.map { marker ->
|
||||||
|
Feature(
|
||||||
|
id = JsonPrimitive(marker.id),
|
||||||
|
geometry = Point(Position(marker.location.lon, marker.location.lat)),
|
||||||
|
properties = mapOf(
|
||||||
|
LOCATION_MARKER_ID to JsonPrimitive(marker.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FeatureCollection(features).toJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create GeoJSON source with clustering
|
||||||
|
val markersSource = rememberGeoJsonSource(
|
||||||
|
data = GeoJsonData.JsonString(geoJsonString),
|
||||||
|
options = GeoJsonOptions(
|
||||||
|
cluster = true,
|
||||||
|
clusterMinPoints = 3,
|
||||||
|
clusterRadius = 30
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cluster circle layer
|
||||||
|
CircleLayer(
|
||||||
|
id = "cluster-circles",
|
||||||
|
source = markersSource,
|
||||||
|
filter = feature.has("point_count"),
|
||||||
|
color = const(clusterColor),
|
||||||
|
radius = const(24.dp),
|
||||||
|
strokeWidth = const(1.dp),
|
||||||
|
strokeColor = const(clusterStrokeColor),
|
||||||
|
onClick = { features ->
|
||||||
|
features.firstOrNull()?.let { feat ->
|
||||||
|
val point = feat.geometry as? Point
|
||||||
|
if (point != null && onClusterClick != null) {
|
||||||
|
onClusterClick(point.coordinates)
|
||||||
|
ClickResult.Consume
|
||||||
|
} else {
|
||||||
|
ClickResult.Pass
|
||||||
|
}
|
||||||
|
} ?: ClickResult.Pass
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cluster count text layer
|
||||||
|
SymbolLayer(
|
||||||
|
id = "cluster-count",
|
||||||
|
source = markersSource,
|
||||||
|
filter = feature.has("point_count"),
|
||||||
|
textField = feature["point_count_abbreviated"].asString(),
|
||||||
|
textColor = const(clusterTextColor),
|
||||||
|
textSize = const(clusterTextStyle.fontSize),
|
||||||
|
textFont = const(listOfNotNull(clusterTextStyle.fontFamily?.toString())),
|
||||||
|
textLetterSpacing = const(clusterTextStyle.letterSpacing),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Individual marker layers - one per marker for unique avatars
|
||||||
|
markers.forEach { marker ->
|
||||||
|
LocationPinMarkerLayer(
|
||||||
|
marker = marker,
|
||||||
|
source = markersSource,
|
||||||
|
onMarkerClick = onMarkerClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocationPinMarkerLayer(
|
||||||
|
marker: LocationMarkerData,
|
||||||
|
source: GeoJsonSource,
|
||||||
|
onMarkerClick: ((LocationMarkerData) -> Unit)?,
|
||||||
|
) {
|
||||||
|
val imageBitmap = rememberLocationPinBitmap(marker.variant)
|
||||||
|
if (imageBitmap != null) {
|
||||||
|
SymbolLayer(
|
||||||
|
id = "pin-marker-${marker.id}",
|
||||||
|
source = source,
|
||||||
|
filter = !feature.has("point_count") and (feature[LOCATION_MARKER_ID].asString() eq const(marker.id)),
|
||||||
|
iconImage = image(imageBitmap),
|
||||||
|
iconAnchor = const(SymbolAnchor.Bottom),
|
||||||
|
iconAllowOverlap = const(true),
|
||||||
|
onClick = { features ->
|
||||||
|
if (features.isNotEmpty() && onMarkerClick != null) {
|
||||||
|
onMarkerClick(marker)
|
||||||
|
ClickResult.Consume
|
||||||
|
} else {
|
||||||
|
ClickResult.Pass
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.impl.show.LocationShareItem
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
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.Text
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LocationShareRow(
|
||||||
|
item: LocationShareItem,
|
||||||
|
onShareClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
avatarData = item.avatarData,
|
||||||
|
avatarType = AvatarType.User,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = item.displayName,
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (item.isLive) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.LocationPinSolid(),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.iconAccentPrimary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val icon = if (item.assetType == AssetType.PIN) {
|
||||||
|
CompoundIcons.LocationNavigator()
|
||||||
|
} else {
|
||||||
|
CompoundIcons.LocationNavigatorCentred()
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.iconSecondary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = item.formattedTimestamp,
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onShareClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.ShareAndroid(),
|
||||||
|
contentDescription = stringResource(CommonStrings.action_share),
|
||||||
|
tint = ElementTheme.colors.iconPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun LocationShareRowPreview() = ElementPreview {
|
||||||
|
Column {
|
||||||
|
LocationShareRow(
|
||||||
|
item = LocationShareItem(
|
||||||
|
userId = UserId("@alice:matrix.org"),
|
||||||
|
displayName = "Alice",
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = "@alice:matrix.org",
|
||||||
|
name = "Alice",
|
||||||
|
url = null,
|
||||||
|
size = AvatarSize.UserListItem,
|
||||||
|
),
|
||||||
|
formattedTimestamp = "Shared 1 min ago",
|
||||||
|
isLive = true,
|
||||||
|
assetType = AssetType.SENDER,
|
||||||
|
location = Location(0.0, 0.0)
|
||||||
|
),
|
||||||
|
onShareClick = {},
|
||||||
|
)
|
||||||
|
LocationShareRow(
|
||||||
|
item = LocationShareItem(
|
||||||
|
userId = UserId("@bob:matrix.org"),
|
||||||
|
displayName = "Bob",
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = "@bob:matrix.org",
|
||||||
|
name = "Bob",
|
||||||
|
url = null,
|
||||||
|
size = AvatarSize.UserListItem,
|
||||||
|
),
|
||||||
|
isLive = false,
|
||||||
|
assetType = AssetType.PIN,
|
||||||
|
formattedTimestamp = "Shared 5 hours ago",
|
||||||
|
location = Location(0.0, 0.0)
|
||||||
|
),
|
||||||
|
onShareClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.material3.BottomSheetDefaults
|
||||||
|
import androidx.compose.material3.BottomSheetScaffoldState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.max
|
||||||
|
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||||
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
||||||
|
import org.maplibre.compose.camera.CameraState
|
||||||
|
import org.maplibre.compose.camera.rememberCameraState
|
||||||
|
import org.maplibre.compose.map.MapOptions
|
||||||
|
import org.maplibre.compose.map.MaplibreMap
|
||||||
|
import org.maplibre.compose.style.BaseStyle
|
||||||
|
import org.maplibre.compose.util.MaplibreComposable
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable scaffold component for map views with a bottom sheet.
|
||||||
|
*
|
||||||
|
* Handles the layout complexity of:
|
||||||
|
* - Calculating the visible sheet height dynamically
|
||||||
|
* - Updating camera position padding based on sheet height
|
||||||
|
* - Rendering the MaplibreMap with proper ornament positioning
|
||||||
|
*
|
||||||
|
* @param modifier Modifier for the root layout
|
||||||
|
* @param scaffoldState State for the bottom sheet scaffold
|
||||||
|
* @param cameraState The camera state for the map
|
||||||
|
* @param mapOptions The options to configure the map
|
||||||
|
* @param sheetPeekHeight The height of the sheet when collapsed
|
||||||
|
* @param sheetDragHandle Optional drag handle for the sheet
|
||||||
|
* @param sheetSwipeEnabled Whether the sheet can be swiped
|
||||||
|
* @param topBar The top app bar content
|
||||||
|
* @param snackbarHost The snackbar host content
|
||||||
|
* @param sheetContent The content to display in the bottom sheet
|
||||||
|
* @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.)
|
||||||
|
* @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.)
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MapBottomSheetScaffold(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
|
||||||
|
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
|
||||||
|
),
|
||||||
|
cameraState: CameraState = rememberCameraState(),
|
||||||
|
mapOptions: MapOptions = MapDefaults.options,
|
||||||
|
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
|
||||||
|
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||||
|
sheetSwipeEnabled: Boolean = true,
|
||||||
|
topBar: (@Composable () -> Unit)? = null,
|
||||||
|
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
|
||||||
|
sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {},
|
||||||
|
mapContent: @Composable @MaplibreComposable () -> Unit = {},
|
||||||
|
overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
||||||
|
BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) {
|
||||||
|
val layoutHeightPx by rememberUpdatedState(constraints.maxHeight)
|
||||||
|
val sheetPadding by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val sheetOffset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f
|
||||||
|
val sheetVisibleHeightPx = layoutHeightPx - sheetOffset
|
||||||
|
val bottomPadding = with(density) { max(sheetVisibleHeightPx.roundToInt().toDp(), 0.dp) }
|
||||||
|
PaddingValues(bottom = bottomPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update camera position when sheet padding changes
|
||||||
|
LaunchedEffect(sheetPadding) {
|
||||||
|
cameraState.position = cameraState.position.copy(padding = sheetPadding)
|
||||||
|
}
|
||||||
|
BottomSheetScaffold(
|
||||||
|
modifier = Modifier,
|
||||||
|
sheetPeekHeight = sheetPeekHeight,
|
||||||
|
sheetContent = {
|
||||||
|
sheetContent(sheetPadding)
|
||||||
|
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||||
|
},
|
||||||
|
scaffoldState = scaffoldState,
|
||||||
|
sheetDragHandle = sheetDragHandle,
|
||||||
|
sheetSwipeEnabled = sheetSwipeEnabled,
|
||||||
|
snackbarHost = snackbarHost,
|
||||||
|
topBar = topBar,
|
||||||
|
) {
|
||||||
|
val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding)
|
||||||
|
val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions)
|
||||||
|
Box {
|
||||||
|
MaplibreMap(
|
||||||
|
options = mapOptions,
|
||||||
|
baseStyle = BaseStyle.Uri(rememberTileStyleUrl()),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
cameraState = cameraState,
|
||||||
|
content = mapContent,
|
||||||
|
)
|
||||||
|
overlayContent(sheetPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
|
import org.maplibre.compose.camera.CameraState
|
||||||
|
import org.maplibre.compose.location.DesiredAccuracy
|
||||||
|
import org.maplibre.compose.location.LocationPuck
|
||||||
|
import org.maplibre.compose.location.LocationPuckColors
|
||||||
|
import org.maplibre.compose.location.LocationPuckSizes
|
||||||
|
import org.maplibre.compose.location.LocationTrackingEffect
|
||||||
|
import org.maplibre.compose.location.UserLocationState
|
||||||
|
import org.maplibre.compose.location.rememberAndroidLocationProvider
|
||||||
|
import org.maplibre.compose.location.rememberNullLocationProvider
|
||||||
|
import org.maplibre.compose.location.rememberUserLocationState
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserLocationPuck(
|
||||||
|
cameraState: CameraState,
|
||||||
|
locationState: UserLocationState,
|
||||||
|
trackUserLocation: Boolean,
|
||||||
|
) {
|
||||||
|
LocationTrackingEffect(
|
||||||
|
locationState = locationState,
|
||||||
|
enabled = trackUserLocation,
|
||||||
|
) {
|
||||||
|
val finalPosition = cameraState.position.copy(
|
||||||
|
target = currentLocation.position,
|
||||||
|
bearing = currentLocation.bearing ?: cameraState.position.bearing,
|
||||||
|
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
|
||||||
|
)
|
||||||
|
cameraState.animateTo(finalPosition)
|
||||||
|
}
|
||||||
|
val location = locationState.location
|
||||||
|
if (location != null) {
|
||||||
|
LocationPuck(
|
||||||
|
idPrefix = "user-location",
|
||||||
|
locationState = locationState,
|
||||||
|
cameraState = cameraState,
|
||||||
|
accuracyThreshold = Float.POSITIVE_INFINITY,
|
||||||
|
showBearingAccuracy = false,
|
||||||
|
showBearing = false,
|
||||||
|
sizes = LocationPuckSizes(
|
||||||
|
dotRadius = 8.dp,
|
||||||
|
dotStrokeWidth = 2.dp,
|
||||||
|
),
|
||||||
|
colors = LocationPuckColors(
|
||||||
|
dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary,
|
||||||
|
dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary,
|
||||||
|
dotStrokeColor = ElementTheme.colors.bgCanvasDefault,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
@Composable
|
||||||
|
fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState {
|
||||||
|
val isPreview = LocalInspectionMode.current
|
||||||
|
val locationProvider = if (isPreview || !hasLocationPermission) {
|
||||||
|
rememberNullLocationProvider()
|
||||||
|
} else {
|
||||||
|
rememberAndroidLocationProvider(
|
||||||
|
updateInterval = 1.minutes,
|
||||||
|
desiredAccuracy = DesiredAccuracy.Balanced,
|
||||||
|
minDistanceMeters = 50f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return rememberUserLocationState(locationProvider)
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.send
|
|
||||||
|
|
||||||
import io.element.android.features.location.api.Location
|
|
||||||
|
|
||||||
sealed interface SendLocationEvents {
|
|
||||||
data class SendLocation(
|
|
||||||
val cameraPosition: CameraPosition,
|
|
||||||
val location: Location?,
|
|
||||||
) : SendLocationEvents {
|
|
||||||
data class CameraPosition(
|
|
||||||
val lat: Double,
|
|
||||||
val lon: Double,
|
|
||||||
val zoom: Double,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data object SwitchToMyLocationMode : SendLocationEvents
|
|
||||||
data object SwitchToPinLocationMode : SendLocationEvents
|
|
||||||
data object DismissDialog : SendLocationEvents
|
|
||||||
data object RequestPermissions : SendLocationEvents
|
|
||||||
data object OpenAppSettings : SendLocationEvents
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.send
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
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 dev.zacsweers.metro.Assisted
|
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
|
||||||
import dev.zacsweers.metro.AssistedInject
|
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
|
||||||
import io.element.android.features.location.impl.common.actions.LocationActions
|
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
|
||||||
import io.element.android.features.messages.api.MessageComposerContext
|
|
||||||
import io.element.android.libraries.architecture.Presenter
|
|
||||||
import io.element.android.libraries.core.extensions.flatMap
|
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
|
||||||
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@AssistedInject
|
|
||||||
class SendLocationPresenter(
|
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
|
||||||
private val room: JoinedRoom,
|
|
||||||
@Assisted private val timelineMode: Timeline.Mode,
|
|
||||||
private val analyticsService: AnalyticsService,
|
|
||||||
private val messageComposerContext: MessageComposerContext,
|
|
||||||
private val locationActions: LocationActions,
|
|
||||||
private val buildMeta: BuildMeta,
|
|
||||||
) : Presenter<SendLocationState> {
|
|
||||||
@AssistedFactory
|
|
||||||
fun interface Factory {
|
|
||||||
fun create(timelineMode: Timeline.Mode): SendLocationPresenter
|
|
||||||
}
|
|
||||||
|
|
||||||
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun present(): SendLocationState {
|
|
||||||
val permissionsState: PermissionsState = permissionsPresenter.present()
|
|
||||||
var mode: SendLocationState.Mode by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
if (permissionsState.isAnyGranted) {
|
|
||||||
SendLocationState.Mode.SenderLocation
|
|
||||||
} else {
|
|
||||||
SendLocationState.Mode.PinLocation
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
|
||||||
var permissionDialog: SendLocationState.Dialog by remember {
|
|
||||||
mutableStateOf(SendLocationState.Dialog.None)
|
|
||||||
}
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
LaunchedEffect(permissionsState.permissions) {
|
|
||||||
if (permissionsState.isAnyGranted) {
|
|
||||||
mode = SendLocationState.Mode.SenderLocation
|
|
||||||
permissionDialog = SendLocationState.Dialog.None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleEvent(event: SendLocationEvents) {
|
|
||||||
when (event) {
|
|
||||||
is SendLocationEvents.SendLocation -> scope.launch {
|
|
||||||
sendLocation(event, mode)
|
|
||||||
}
|
|
||||||
SendLocationEvents.SwitchToMyLocationMode -> when {
|
|
||||||
permissionsState.isAnyGranted -> mode = SendLocationState.Mode.SenderLocation
|
|
||||||
permissionsState.shouldShowRationale -> permissionDialog = SendLocationState.Dialog.PermissionRationale
|
|
||||||
else -> permissionDialog = SendLocationState.Dialog.PermissionDenied
|
|
||||||
}
|
|
||||||
SendLocationEvents.SwitchToPinLocationMode -> mode = SendLocationState.Mode.PinLocation
|
|
||||||
SendLocationEvents.DismissDialog -> permissionDialog = SendLocationState.Dialog.None
|
|
||||||
SendLocationEvents.OpenAppSettings -> {
|
|
||||||
locationActions.openSettings()
|
|
||||||
permissionDialog = SendLocationState.Dialog.None
|
|
||||||
}
|
|
||||||
SendLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SendLocationState(
|
|
||||||
permissionDialog = permissionDialog,
|
|
||||||
mode = mode,
|
|
||||||
hasLocationPermission = permissionsState.isAnyGranted,
|
|
||||||
appName = appName,
|
|
||||||
eventSink = ::handleEvent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun sendLocation(
|
|
||||||
event: SendLocationEvents.SendLocation,
|
|
||||||
mode: SendLocationState.Mode,
|
|
||||||
) {
|
|
||||||
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
|
|
||||||
val inReplyToEventId = replyMode?.eventId
|
|
||||||
when (mode) {
|
|
||||||
SendLocationState.Mode.PinLocation -> {
|
|
||||||
val geoUri = event.cameraPosition.toGeoUri()
|
|
||||||
getTimeline().flatMap {
|
|
||||||
it.sendLocation(
|
|
||||||
body = generateBody(geoUri),
|
|
||||||
geoUri = geoUri,
|
|
||||||
description = null,
|
|
||||||
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
|
|
||||||
assetType = AssetType.PIN,
|
|
||||||
inReplyToEventId = inReplyToEventId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
analyticsService.capture(
|
|
||||||
Composer(
|
|
||||||
inThread = messageComposerContext.composerMode.inThread,
|
|
||||||
isEditing = messageComposerContext.composerMode.isEditing,
|
|
||||||
isReply = messageComposerContext.composerMode.isReply,
|
|
||||||
messageType = Composer.MessageType.LocationPin,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SendLocationState.Mode.SenderLocation -> {
|
|
||||||
val geoUri = event.toGeoUri()
|
|
||||||
getTimeline().flatMap {
|
|
||||||
it.sendLocation(
|
|
||||||
body = generateBody(geoUri),
|
|
||||||
geoUri = geoUri,
|
|
||||||
description = null,
|
|
||||||
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
|
|
||||||
assetType = AssetType.SENDER,
|
|
||||||
inReplyToEventId = inReplyToEventId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
analyticsService.capture(
|
|
||||||
Composer(
|
|
||||||
inThread = messageComposerContext.composerMode.inThread,
|
|
||||||
isEditing = messageComposerContext.composerMode.isEditing,
|
|
||||||
isReply = messageComposerContext.composerMode.isReply,
|
|
||||||
messageType = Composer.MessageType.LocationUser,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getTimeline(): Result<Timeline> {
|
|
||||||
return when (timelineMode) {
|
|
||||||
is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
|
|
||||||
else -> Result.success(room.liveTimeline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri()
|
|
||||||
|
|
||||||
private fun SendLocationEvents.SendLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon"
|
|
||||||
|
|
||||||
private fun generateBody(uri: String): String = "Location was shared at $uri"
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.send
|
|
||||||
|
|
||||||
data class SendLocationState(
|
|
||||||
val permissionDialog: Dialog,
|
|
||||||
val mode: Mode,
|
|
||||||
val hasLocationPermission: Boolean,
|
|
||||||
val appName: String,
|
|
||||||
val eventSink: (SendLocationEvents) -> Unit,
|
|
||||||
) {
|
|
||||||
sealed interface Mode {
|
|
||||||
data object SenderLocation : Mode
|
|
||||||
data object PinLocation : Mode
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface Dialog {
|
|
||||||
data object None : Dialog
|
|
||||||
data object PermissionRationale : Dialog
|
|
||||||
data object PermissionDenied : Dialog
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.send
|
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
|
||||||
|
|
||||||
private const val APP_NAME = "ApplicationName"
|
|
||||||
|
|
||||||
class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> {
|
|
||||||
override val values: Sequence<SendLocationState>
|
|
||||||
get() = sequenceOf(
|
|
||||||
aSendLocationState(
|
|
||||||
permissionDialog = SendLocationState.Dialog.None,
|
|
||||||
mode = SendLocationState.Mode.PinLocation,
|
|
||||||
hasLocationPermission = false,
|
|
||||||
),
|
|
||||||
aSendLocationState(
|
|
||||||
permissionDialog = SendLocationState.Dialog.PermissionDenied,
|
|
||||||
mode = SendLocationState.Mode.PinLocation,
|
|
||||||
hasLocationPermission = false,
|
|
||||||
),
|
|
||||||
aSendLocationState(
|
|
||||||
permissionDialog = SendLocationState.Dialog.PermissionRationale,
|
|
||||||
mode = SendLocationState.Mode.PinLocation,
|
|
||||||
hasLocationPermission = false,
|
|
||||||
),
|
|
||||||
aSendLocationState(
|
|
||||||
permissionDialog = SendLocationState.Dialog.None,
|
|
||||||
mode = SendLocationState.Mode.PinLocation,
|
|
||||||
hasLocationPermission = true,
|
|
||||||
),
|
|
||||||
aSendLocationState(
|
|
||||||
permissionDialog = SendLocationState.Dialog.None,
|
|
||||||
mode = SendLocationState.Mode.SenderLocation,
|
|
||||||
hasLocationPermission = true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun aSendLocationState(
|
|
||||||
permissionDialog: SendLocationState.Dialog,
|
|
||||||
mode: SendLocationState.Mode,
|
|
||||||
hasLocationPermission: Boolean,
|
|
||||||
): SendLocationState {
|
|
||||||
return SendLocationState(
|
|
||||||
permissionDialog = permissionDialog,
|
|
||||||
mode = mode,
|
|
||||||
hasLocationPermission = hasLocationPermission,
|
|
||||||
appName = APP_NAME,
|
|
||||||
eventSink = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.send
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
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.runtime.LaunchedEffect
|
|
||||||
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.tooling.preview.PreviewParameter
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import io.element.android.features.location.api.Location
|
|
||||||
import io.element.android.features.location.api.internal.centerBottomEdge
|
|
||||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
|
||||||
import io.element.android.features.location.impl.R
|
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
|
||||||
import io.element.android.features.location.impl.common.PermissionDeniedDialog
|
|
||||||
import io.element.android.features.location.impl.common.PermissionRationaleDialog
|
|
||||||
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMode
|
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
|
|
||||||
import io.element.android.libraries.maplibre.compose.MapLibreMap
|
|
||||||
import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
|
||||||
import org.maplibre.android.camera.CameraPosition
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SendLocationView(
|
|
||||||
state: SendLocationState,
|
|
||||||
navigateUp: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
state.eventSink(SendLocationEvents.RequestPermissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (state.permissionDialog) {
|
|
||||||
SendLocationState.Dialog.None -> Unit
|
|
||||||
SendLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog(
|
|
||||||
onContinue = { state.eventSink(SendLocationEvents.OpenAppSettings) },
|
|
||||||
onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) },
|
|
||||||
appName = state.appName,
|
|
||||||
)
|
|
||||||
SendLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog(
|
|
||||||
onContinue = { state.eventSink(SendLocationEvents.RequestPermissions) },
|
|
||||||
onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) },
|
|
||||||
appName = state.appName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val cameraPositionState = rememberCameraPositionState {
|
|
||||||
position = MapDefaults.centerCameraPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(state.mode) {
|
|
||||||
when (state.mode) {
|
|
||||||
SendLocationState.Mode.PinLocation -> {
|
|
||||||
cameraPositionState.cameraMode = CameraMode.NONE
|
|
||||||
}
|
|
||||||
SendLocationState.Mode.SenderLocation -> {
|
|
||||||
cameraPositionState.position = CameraPosition.Builder()
|
|
||||||
.zoom(MapDefaults.DEFAULT_ZOOM)
|
|
||||||
.build()
|
|
||||||
cameraPositionState.cameraMode = CameraMode.TRACKING
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(cameraPositionState.isMoving) {
|
|
||||||
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
|
|
||||||
state.eventSink(SendLocationEvents.SwitchToPinLocationMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BottomSheetScaffold doesn't manage the system insets for sheetContent and the FAB, so we need to do it manually.
|
|
||||||
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
|
||||||
|
|
||||||
BottomSheetScaffold(
|
|
||||||
sheetContent = {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
when (state.mode) {
|
|
||||||
SendLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action
|
|
||||||
SendLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.clickable(
|
|
||||||
// target is null when the map hasn't loaded (or api key is wrong) so we disable the button
|
|
||||||
enabled = cameraPositionState.position.target != null
|
|
||||||
) {
|
|
||||||
state.eventSink(
|
|
||||||
SendLocationEvents.SendLocation(
|
|
||||||
cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
|
|
||||||
lat = cameraPositionState.position.target!!.latitude,
|
|
||||||
lon = cameraPositionState.position.target!!.longitude,
|
|
||||||
zoom = cameraPositionState.position.zoom,
|
|
||||||
),
|
|
||||||
location = cameraPositionState.location?.let {
|
|
||||||
Location(
|
|
||||||
lat = it.latitude,
|
|
||||||
lon = it.longitude,
|
|
||||||
accuracy = it.accuracy,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
navigateUp()
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
resourceId = R.drawable.pin_small,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.Unspecified,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp + navBarPadding))
|
|
||||||
},
|
|
||||||
modifier = modifier,
|
|
||||||
scaffoldState = rememberBottomSheetScaffoldState(
|
|
||||||
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded),
|
|
||||||
),
|
|
||||||
sheetDragHandle = {},
|
|
||||||
sheetSwipeEnabled = false,
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
titleStr = stringResource(CommonStrings.screen_share_location_title),
|
|
||||||
navigationIcon = {
|
|
||||||
BackButton(onClick = navigateUp)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(it)
|
|
||||||
.consumeWindowInsets(it),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
MapLibreMap(
|
|
||||||
styleUri = rememberTileStyleUrl(),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
cameraPositionState = cameraPositionState,
|
|
||||||
uiSettings = MapDefaults.uiSettings,
|
|
||||||
symbolManagerSettings = MapDefaults.symbolManagerSettings,
|
|
||||||
locationSettings = MapDefaults.locationSettings.copy(
|
|
||||||
locationEnabled = state.hasLocationPermission,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
resourceId = CommonDrawables.pin,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.Unspecified,
|
|
||||||
modifier = Modifier.centerBottomEdge(this),
|
|
||||||
)
|
|
||||||
LocationFloatingActionButton(
|
|
||||||
isMapCenteredOnUser = state.mode == SendLocationState.Mode.SenderLocation,
|
|
||||||
onClick = { state.eventSink(SendLocationEvents.SwitchToMyLocationMode) },
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(end = 18.dp, bottom = 72.dp + navBarPadding),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun SendLocationViewPreview(
|
|
||||||
@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState
|
|
||||||
) = ElementPreview {
|
|
||||||
SendLocationView(
|
|
||||||
state = state,
|
|
||||||
navigateUp = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,26 +6,26 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.location.impl.send
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
import io.element.android.features.location.api.ShareLocationEntryPoint
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultSendLocationEntryPoint : SendLocationEntryPoint {
|
class DefaultShareLocationEntryPoint : ShareLocationEntryPoint {
|
||||||
override fun createNode(
|
override fun createNode(
|
||||||
parentNode: Node,
|
parentNode: Node,
|
||||||
buildContext: BuildContext,
|
buildContext: BuildContext,
|
||||||
timelineMode: Timeline.Mode,
|
timelineMode: Timeline.Mode,
|
||||||
): Node {
|
): Node {
|
||||||
return parentNode.createNode<SendLocationNode>(
|
return parentNode.createNode<ShareLocationNode>(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = listOf(SendLocationNode.Inputs(timelineMode))
|
plugins = listOf(ShareLocationNode.Inputs(timelineMode))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
data class LiveLocationDuration(
|
||||||
|
val duration: Duration,
|
||||||
|
val formatted: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
* Copyright 2023-2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
sealed interface ShareLocationEvent {
|
||||||
|
data class ShareStaticLocation(
|
||||||
|
val location: Location,
|
||||||
|
val isPinned: Boolean,
|
||||||
|
) : ShareLocationEvent
|
||||||
|
|
||||||
|
data object ShowLiveLocationDurationPicker : ShareLocationEvent
|
||||||
|
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
|
||||||
|
|
||||||
|
data object StartTrackingUserLocation : ShareLocationEvent
|
||||||
|
data object StopTrackingUserLocation : ShareLocationEvent
|
||||||
|
data object DismissDialog : ShareLocationEvent
|
||||||
|
|
||||||
|
data object RequestPermissions : ShareLocationEvent
|
||||||
|
data object OpenAppSettings : ShareLocationEvent
|
||||||
|
data object OpenLocationSettings : ShareLocationEvent
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.location.impl.send
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -26,10 +26,10 @@ import io.element.android.services.analytics.api.AnalyticsService
|
|||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class SendLocationNode(
|
class ShareLocationNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
presenterFactory: SendLocationPresenter.Factory,
|
presenterFactory: ShareLocationPresenter.Factory,
|
||||||
analyticsService: AnalyticsService,
|
analyticsService: AnalyticsService,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
@@ -48,7 +48,7 @@ class SendLocationNode(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
SendLocationView(
|
ShareLocationView(
|
||||||
state = presenter.present(),
|
state = presenter.present(),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
navigateUp = ::navigateUp,
|
navigateUp = ::navigateUp,
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
* Copyright 2023-2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
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 dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
|
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
||||||
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
|
import io.element.android.features.location.impl.common.actions.LocationActions
|
||||||
|
import io.element.android.features.location.impl.common.checkLocationConstraints
|
||||||
|
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||||
|
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
||||||
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
|
import io.element.android.features.location.impl.common.toDialogState
|
||||||
|
import io.element.android.features.location.impl.share.ShareLocationState.Dialog.Constraints
|
||||||
|
import io.element.android.features.messages.api.MessageComposerContext
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.extensions.flatMap
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.dateformatter.api.DurationFormatter
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
private val LIVE_LOCATION_DURATIONS = listOf(15.minutes, 1.hours, 8.hours)
|
||||||
|
|
||||||
|
@AssistedInject
|
||||||
|
class ShareLocationPresenter(
|
||||||
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
|
private val room: JoinedRoom,
|
||||||
|
@Assisted private val timelineMode: Timeline.Mode,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val messageComposerContext: MessageComposerContext,
|
||||||
|
private val locationActions: LocationActions,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val client: MatrixClient,
|
||||||
|
private val durationFormatter: DurationFormatter,
|
||||||
|
) : Presenter<ShareLocationState> {
|
||||||
|
@AssistedFactory
|
||||||
|
fun interface Factory {
|
||||||
|
fun create(timelineMode: Timeline.Mode): ShareLocationPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): ShareLocationState {
|
||||||
|
val permissionsState: PermissionsState = permissionsPresenter.present()
|
||||||
|
var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted && locationActions.isLocationEnabled()) }
|
||||||
|
val isLiveLocationSharingEnabled by remember {
|
||||||
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
|
||||||
|
}.collectAsState(false)
|
||||||
|
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
||||||
|
var dialogState: ShareLocationState.Dialog by remember {
|
||||||
|
mutableStateOf(ShareLocationState.Dialog.None)
|
||||||
|
}
|
||||||
|
val currentUser by client.userProfile.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun checkLocationConstraints() {
|
||||||
|
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
||||||
|
dialogState = Constraints(locationConstraints.toDialogState())
|
||||||
|
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
|
||||||
|
|
||||||
|
fun handleEvent(event: ShareLocationEvent) {
|
||||||
|
when (event) {
|
||||||
|
is ShareLocationEvent.ShareStaticLocation -> scope.launch {
|
||||||
|
shareStaticLocation(event)
|
||||||
|
}
|
||||||
|
ShareLocationEvent.StartTrackingUserLocation -> checkLocationConstraints()
|
||||||
|
ShareLocationEvent.StopTrackingUserLocation -> trackUserPosition = false
|
||||||
|
ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None
|
||||||
|
ShareLocationEvent.OpenAppSettings -> {
|
||||||
|
locationActions.openAppSettings()
|
||||||
|
dialogState = ShareLocationState.Dialog.None
|
||||||
|
}
|
||||||
|
ShareLocationEvent.OpenLocationSettings -> {
|
||||||
|
locationActions.openLocationSettings()
|
||||||
|
dialogState = ShareLocationState.Dialog.None
|
||||||
|
}
|
||||||
|
ShareLocationEvent.ShowLiveLocationDurationPicker -> {
|
||||||
|
val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
|
||||||
|
dialogState = if (constraintsResult is LocationConstraintsCheck.Success) {
|
||||||
|
val durations = LIVE_LOCATION_DURATIONS.map {
|
||||||
|
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
|
||||||
|
}
|
||||||
|
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
|
||||||
|
} else {
|
||||||
|
Constraints(constraintsResult.toDialogState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
|
||||||
|
dialogState = ShareLocationState.Dialog.None
|
||||||
|
// room.startLiveLocationShare(event.duration.inWholeMilliseconds)
|
||||||
|
}
|
||||||
|
ShareLocationEvent.RequestPermissions -> {
|
||||||
|
dialogState = ShareLocationState.Dialog.None
|
||||||
|
permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ShareLocationState(
|
||||||
|
currentUser = currentUser,
|
||||||
|
dialogState = dialogState,
|
||||||
|
trackUserLocation = trackUserPosition,
|
||||||
|
hasLocationPermission = permissionsState.isAnyGranted,
|
||||||
|
canShareLiveLocation = isLiveLocationSharingEnabled,
|
||||||
|
appName = appName,
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun shareStaticLocation(event: ShareLocationEvent.ShareStaticLocation) {
|
||||||
|
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
|
||||||
|
val inReplyToEventId = replyMode?.eventId
|
||||||
|
val geoUri = event.location.toGeoUri()
|
||||||
|
getTimeline().flatMap {
|
||||||
|
it.sendLocation(
|
||||||
|
body = generateBody(geoUri),
|
||||||
|
geoUri = geoUri,
|
||||||
|
description = null,
|
||||||
|
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
|
||||||
|
assetType = if (event.isPinned) AssetType.PIN else AssetType.SENDER,
|
||||||
|
inReplyToEventId = inReplyToEventId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
analyticsService.capture(
|
||||||
|
Composer(
|
||||||
|
inThread = messageComposerContext.composerMode.inThread,
|
||||||
|
isEditing = messageComposerContext.composerMode.isEditing,
|
||||||
|
isReply = messageComposerContext.composerMode.isReply,
|
||||||
|
messageType = if (event.isPinned) Composer.MessageType.LocationPin else Composer.MessageType.LocationUser
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getTimeline(): Result<Timeline> {
|
||||||
|
return when (timelineMode) {
|
||||||
|
is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
|
||||||
|
else -> Result.success(room.liveTimeline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateBody(uri: String): String = "Location was shared at $uri"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
* Copyright 2023-2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
data class ShareLocationState(
|
||||||
|
val currentUser: MatrixUser,
|
||||||
|
val dialogState: Dialog,
|
||||||
|
val trackUserLocation: Boolean,
|
||||||
|
val hasLocationPermission: Boolean,
|
||||||
|
val appName: String,
|
||||||
|
val canShareLiveLocation: Boolean,
|
||||||
|
val eventSink: (ShareLocationEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
sealed interface Dialog {
|
||||||
|
data object None : Dialog
|
||||||
|
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
|
||||||
|
data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : Dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
* Copyright 2023-2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
private const val APP_NAME = "ApplicationName"
|
||||||
|
|
||||||
|
class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState> {
|
||||||
|
override val values: Sequence<ShareLocationState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.None,
|
||||||
|
trackUserPosition = false,
|
||||||
|
hasLocationPermission = false,
|
||||||
|
),
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
|
||||||
|
trackUserPosition = false,
|
||||||
|
hasLocationPermission = false,
|
||||||
|
),
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
|
||||||
|
trackUserPosition = false,
|
||||||
|
hasLocationPermission = false,
|
||||||
|
),
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
|
||||||
|
trackUserPosition = false,
|
||||||
|
hasLocationPermission = true,
|
||||||
|
),
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.None,
|
||||||
|
trackUserPosition = false,
|
||||||
|
hasLocationPermission = true,
|
||||||
|
),
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.None,
|
||||||
|
trackUserPosition = true,
|
||||||
|
hasLocationPermission = true,
|
||||||
|
),
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
|
||||||
|
persistentListOf(
|
||||||
|
LiveLocationDuration(15.minutes, "15 minutes"),
|
||||||
|
LiveLocationDuration(1.hours, "1 hour"),
|
||||||
|
LiveLocationDuration(8.hours, "8 hours"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
trackUserPosition = true,
|
||||||
|
hasLocationPermission = true,
|
||||||
|
canShareLiveLocation = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aShareLocationState(
|
||||||
|
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
|
||||||
|
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
|
||||||
|
trackUserPosition: Boolean = false,
|
||||||
|
hasLocationPermission: Boolean = false,
|
||||||
|
canShareLiveLocation: Boolean = false,
|
||||||
|
appName: String = APP_NAME,
|
||||||
|
eventSink: (ShareLocationEvent) -> Unit = {},
|
||||||
|
): ShareLocationState {
|
||||||
|
return ShareLocationState(
|
||||||
|
currentUser = currentUser,
|
||||||
|
dialogState = dialogState,
|
||||||
|
trackUserLocation = trackUserPosition,
|
||||||
|
hasLocationPermission = hasLocationPermission,
|
||||||
|
canShareLiveLocation = canShareLiveLocation,
|
||||||
|
appName = appName,
|
||||||
|
eventSink = eventSink
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
* Copyright 2023-2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.api.internal.centerBottomEdge
|
||||||
|
import io.element.android.features.location.impl.R
|
||||||
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
||||||
|
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
||||||
|
import io.element.android.features.location.impl.common.ui.UserLocationPuck
|
||||||
|
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
|
||||||
|
import io.element.android.libraries.androidutils.system.toast
|
||||||
|
import io.element.android.libraries.designsystem.components.LocationPin
|
||||||
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
|
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import org.maplibre.compose.camera.CameraMoveReason
|
||||||
|
import org.maplibre.compose.camera.CameraState
|
||||||
|
import org.maplibre.compose.camera.rememberCameraState
|
||||||
|
import org.maplibre.compose.location.UserLocationState
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ShareLocationView(
|
||||||
|
state: ShareLocationState,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
when (val dialogState = state.dialogState) {
|
||||||
|
ShareLocationState.Dialog.None -> Unit
|
||||||
|
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
|
||||||
|
state = dialogState.state,
|
||||||
|
appName = state.appName,
|
||||||
|
onRequestPermissions = { state.eventSink(ShareLocationEvent.RequestPermissions) },
|
||||||
|
onOpenAppSettings = { state.eventSink(ShareLocationEvent.OpenAppSettings) },
|
||||||
|
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
|
||||||
|
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||||
|
)
|
||||||
|
is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
|
||||||
|
durations = dialogState.durations,
|
||||||
|
onSelectDuration = { duration ->
|
||||||
|
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))
|
||||||
|
context.toast("Not implemented yet!")
|
||||||
|
navigateUp()
|
||||||
|
},
|
||||||
|
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||||
|
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded)
|
||||||
|
)
|
||||||
|
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
|
||||||
|
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
|
||||||
|
|
||||||
|
LaunchedEffect(cameraState.isCameraMoving) {
|
||||||
|
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
|
||||||
|
state.eventSink(ShareLocationEvent.StopTrackingUserLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MapBottomSheetScaffold(
|
||||||
|
cameraState = cameraState,
|
||||||
|
modifier = modifier,
|
||||||
|
scaffoldState = scaffoldState,
|
||||||
|
sheetDragHandle = null,
|
||||||
|
sheetSwipeEnabled = false,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
titleStr = stringResource(CommonStrings.screen_share_location_title),
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(onClick = navigateUp)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
sheetContent = {
|
||||||
|
BottomSheetContent(
|
||||||
|
cameraState = cameraState,
|
||||||
|
state = state,
|
||||||
|
userLocationState = userLocationState,
|
||||||
|
navigateUp = navigateUp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
mapContent = {
|
||||||
|
UserLocationPuck(
|
||||||
|
cameraState = cameraState,
|
||||||
|
locationState = userLocationState,
|
||||||
|
trackUserLocation = state.trackUserLocation
|
||||||
|
)
|
||||||
|
},
|
||||||
|
overlayContent = { sheetPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(sheetPadding)
|
||||||
|
) {
|
||||||
|
val variant = if (state.trackUserLocation) {
|
||||||
|
PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin))
|
||||||
|
} else {
|
||||||
|
PinVariant.PinnedLocation
|
||||||
|
}
|
||||||
|
LocationPin(
|
||||||
|
variant = variant,
|
||||||
|
modifier = Modifier.centerBottomEdge(this),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LocationFloatingActionButton(
|
||||||
|
isMapCenteredOnUser = state.trackUserLocation,
|
||||||
|
onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(all = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BottomSheetContent(
|
||||||
|
cameraState: CameraState,
|
||||||
|
state: ShareLocationState,
|
||||||
|
userLocationState: UserLocationState,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
val userLocation = userLocationState.location
|
||||||
|
if (state.trackUserLocation && userLocation != null) {
|
||||||
|
ShareCurrentLocationItem {
|
||||||
|
state.eventSink(
|
||||||
|
ShareLocationEvent.ShareStaticLocation(
|
||||||
|
location = Location(
|
||||||
|
lat = userLocation.position.latitude,
|
||||||
|
lon = userLocation.position.longitude
|
||||||
|
),
|
||||||
|
isPinned = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SharePinLocationItem(
|
||||||
|
onClick = {
|
||||||
|
val positionTarget = cameraState.position.target
|
||||||
|
state.eventSink(
|
||||||
|
ShareLocationEvent.ShareStaticLocation(
|
||||||
|
location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude),
|
||||||
|
isPinned = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.canShareLiveLocation) {
|
||||||
|
ShareLiveLocationItem {
|
||||||
|
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareCurrentLocationItem(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(CommonStrings.screen_share_my_location_action))
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
leadingContent = ListItemContent.Icon(
|
||||||
|
iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SharePinLocationItem(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(CommonStrings.screen_share_this_location_action))
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
leadingContent = ListItemContent.Icon(
|
||||||
|
iconSource = IconSource.Vector(CompoundIcons.LocationNavigator())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareLiveLocationItem(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(CommonStrings.action_share_live_location))
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
leadingContent = ListItemContent.Icon(
|
||||||
|
iconSource = IconSource.Vector(CompoundIcons.LocationPinSolid()),
|
||||||
|
tintColor = ElementTheme.colors.iconAccentPrimary,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LiveLocationDurationDialog(
|
||||||
|
durations: ImmutableList<LiveLocationDuration>,
|
||||||
|
onSelectDuration: (Duration) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var selectedIndex by remember { mutableIntStateOf(0) }
|
||||||
|
ListDialog(
|
||||||
|
title = stringResource(R.string.screen_share_location_live_location_duration_picker_title),
|
||||||
|
submitText = stringResource(CommonStrings.action_continue),
|
||||||
|
onSubmit = { onSelectDuration(durations[selectedIndex].duration) },
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
applyPaddingToContents = false,
|
||||||
|
verticalArrangement = Arrangement.Top
|
||||||
|
) {
|
||||||
|
itemsIndexed(durations) { index, duration ->
|
||||||
|
RadioButtonListItem(
|
||||||
|
headline = duration.formatted,
|
||||||
|
selected = index == selectedIndex,
|
||||||
|
onSelect = { selectedIndex = index },
|
||||||
|
compactLayout = true,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun ShareLocationViewPreview(
|
||||||
|
@PreviewParameter(ShareLocationStateProvider::class) state: ShareLocationState
|
||||||
|
) = ElementPreview {
|
||||||
|
ShareLocationView(
|
||||||
|
state = state,
|
||||||
|
navigateUp = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
* Copyright 2023-2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
|
||||||
|
sealed interface ShowLocationEvent {
|
||||||
|
data class Share(val location: Location) : ShowLocationEvent
|
||||||
|
data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvent
|
||||||
|
data object DismissDialog : ShowLocationEvent
|
||||||
|
data object RequestPermissions : ShowLocationEvent
|
||||||
|
data object OpenAppSettings : ShowLocationEvent
|
||||||
|
data object OpenLocationSettings : ShowLocationEvent
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.show
|
|
||||||
|
|
||||||
sealed interface ShowLocationEvents {
|
|
||||||
data object Share : ShowLocationEvents
|
|
||||||
data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents
|
|
||||||
data object DismissDialog : ShowLocationEvents
|
|
||||||
data object RequestPermissions : ShowLocationEvents
|
|
||||||
data object OpenAppSettings : ShowLocationEvents
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,7 @@ class ShowLocationNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val inputs: ShowLocationEntryPoint.Inputs = inputs()
|
private val inputs: ShowLocationEntryPoint.Inputs = inputs()
|
||||||
private val presenter = presenterFactory.create(inputs.location, inputs.description)
|
private val presenter = presenterFactory.create(inputs.mode)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
|||||||
@@ -18,26 +18,38 @@ import androidx.compose.runtime.setValue
|
|||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.ShowLocationMode
|
||||||
|
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
import io.element.android.features.location.impl.common.actions.LocationActions
|
import io.element.android.features.location.impl.common.actions.LocationActions
|
||||||
|
import io.element.android.features.location.impl.common.checkLocationConstraints
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
|
import io.element.android.features.location.impl.common.toDialogState
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||||
|
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class ShowLocationPresenter(
|
class ShowLocationPresenter(
|
||||||
@Assisted private val location: Location,
|
@Assisted private val mode: ShowLocationMode,
|
||||||
@Assisted private val description: String?,
|
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
private val locationActions: LocationActions,
|
private val locationActions: LocationActions,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val dateFormatter: DateFormatter,
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
) : Presenter<ShowLocationState> {
|
) : Presenter<ShowLocationState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
fun interface Factory {
|
fun interface Factory {
|
||||||
fun create(location: Location, description: String?): ShowLocationPresenter
|
fun create(mode: ShowLocationMode): ShowLocationPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
|
private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
|
||||||
@@ -47,43 +59,75 @@ class ShowLocationPresenter(
|
|||||||
val permissionsState: PermissionsState = permissionsPresenter.present()
|
val permissionsState: PermissionsState = permissionsPresenter.present()
|
||||||
var isTrackMyLocation by remember { mutableStateOf(false) }
|
var isTrackMyLocation by remember { mutableStateOf(false) }
|
||||||
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
||||||
var permissionDialog: ShowLocationState.Dialog by remember {
|
var dialogState: LocationConstraintsDialogState by remember {
|
||||||
mutableStateOf(ShowLocationState.Dialog.None)
|
mutableStateOf(LocationConstraintsDialogState.None)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(permissionsState.permissions) {
|
LaunchedEffect(permissionsState.permissions) {
|
||||||
if (permissionsState.isAnyGranted) {
|
if (permissionsState.isAnyGranted) {
|
||||||
permissionDialog = ShowLocationState.Dialog.None
|
dialogState = LocationConstraintsDialogState.None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvent(event: ShowLocationEvents) {
|
fun handleEvent(event: ShowLocationEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
ShowLocationEvents.Share -> locationActions.share(location, description)
|
is ShowLocationEvent.Share -> {
|
||||||
is ShowLocationEvents.TrackMyLocation -> {
|
locationActions.share(event.location, null)
|
||||||
|
}
|
||||||
|
is ShowLocationEvent.TrackMyLocation -> {
|
||||||
if (event.enabled) {
|
if (event.enabled) {
|
||||||
when {
|
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
||||||
permissionsState.isAnyGranted -> isTrackMyLocation = true
|
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
|
||||||
permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale
|
dialogState = locationConstraints.toDialogState()
|
||||||
else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
isTrackMyLocation = false
|
isTrackMyLocation = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ShowLocationEvents.DismissDialog -> permissionDialog = ShowLocationState.Dialog.None
|
ShowLocationEvent.DismissDialog -> dialogState = LocationConstraintsDialogState.None
|
||||||
ShowLocationEvents.OpenAppSettings -> {
|
ShowLocationEvent.OpenAppSettings -> {
|
||||||
locationActions.openSettings()
|
locationActions.openAppSettings()
|
||||||
permissionDialog = ShowLocationState.Dialog.None
|
dialogState = LocationConstraintsDialogState.None
|
||||||
}
|
}
|
||||||
ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
ShowLocationEvent.OpenLocationSettings -> {
|
||||||
|
locationActions.openLocationSettings()
|
||||||
|
dialogState = LocationConstraintsDialogState.None
|
||||||
|
}
|
||||||
|
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val locationShares = remember {
|
||||||
|
when (mode) {
|
||||||
|
is ShowLocationMode.Static -> {
|
||||||
|
val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true)
|
||||||
|
val formattedTimestamp = stringProvider.getString(
|
||||||
|
CommonStrings.screen_static_location_sheet_timestamp_description,
|
||||||
|
relativeTime
|
||||||
|
)
|
||||||
|
persistentListOf(
|
||||||
|
LocationShareItem(
|
||||||
|
userId = mode.senderId,
|
||||||
|
displayName = mode.senderName,
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = mode.senderId.value,
|
||||||
|
name = mode.senderName,
|
||||||
|
url = mode.senderAvatarUrl,
|
||||||
|
size = AvatarSize.UserListItem,
|
||||||
|
),
|
||||||
|
formattedTimestamp = formattedTimestamp,
|
||||||
|
location = mode.location,
|
||||||
|
isLive = false,
|
||||||
|
assetType = mode.assetType,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShowLocationMode.Live -> persistentListOf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ShowLocationState(
|
return ShowLocationState(
|
||||||
permissionDialog = permissionDialog,
|
dialogState = dialogState,
|
||||||
location = location,
|
locationShares = locationShares,
|
||||||
description = description,
|
|
||||||
hasLocationPermission = permissionsState.isAnyGranted,
|
hasLocationPermission = permissionsState.isAnyGranted,
|
||||||
isTrackMyLocation = isTrackMyLocation,
|
isTrackMyLocation = isTrackMyLocation,
|
||||||
appName = appName,
|
appName = appName,
|
||||||
|
|||||||
@@ -9,19 +9,47 @@
|
|||||||
package io.element.android.features.location.impl.show
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationMarkerData
|
||||||
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
data class ShowLocationState(
|
data class ShowLocationState(
|
||||||
val permissionDialog: Dialog,
|
val dialogState: LocationConstraintsDialogState,
|
||||||
val location: Location,
|
val locationShares: ImmutableList<LocationShareItem>,
|
||||||
val description: String?,
|
|
||||||
val hasLocationPermission: Boolean,
|
val hasLocationPermission: Boolean,
|
||||||
val isTrackMyLocation: Boolean,
|
val isTrackMyLocation: Boolean,
|
||||||
val appName: String,
|
val appName: String,
|
||||||
val eventSink: (ShowLocationEvents) -> Unit,
|
val eventSink: (ShowLocationEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
sealed interface Dialog {
|
val isSheetDraggable = locationShares.any { item -> item.isLive }
|
||||||
data object None : Dialog
|
}
|
||||||
data object PermissionRationale : Dialog
|
|
||||||
data object PermissionDenied : Dialog
|
data class LocationShareItem(
|
||||||
}
|
val userId: UserId,
|
||||||
|
val displayName: String,
|
||||||
|
val avatarData: AvatarData,
|
||||||
|
val formattedTimestamp: String,
|
||||||
|
val location: Location,
|
||||||
|
val isLive: Boolean,
|
||||||
|
val assetType: AssetType?,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun LocationShareItem.toMarkerData(): LocationMarkerData {
|
||||||
|
val pinVariant = if (assetType == AssetType.PIN) {
|
||||||
|
PinVariant.PinnedLocation
|
||||||
|
} else {
|
||||||
|
PinVariant.UserLocation(
|
||||||
|
avatarData = avatarData,
|
||||||
|
isLive = isLive,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return LocationMarkerData(
|
||||||
|
id = userId.value,
|
||||||
|
location = location,
|
||||||
|
variant = pinVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,26 @@ package io.element.android.features.location.impl.show
|
|||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
private const val APP_NAME = "ApplicationName"
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
||||||
override val values: Sequence<ShowLocationState>
|
override val values: Sequence<ShowLocationState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aShowLocationState(),
|
aShowLocationState(),
|
||||||
aShowLocationState(
|
aShowLocationState(
|
||||||
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
|
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
|
||||||
),
|
),
|
||||||
aShowLocationState(
|
aShowLocationState(
|
||||||
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
|
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
|
||||||
|
),
|
||||||
|
aShowLocationState(
|
||||||
|
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
|
||||||
|
hasLocationPermission = true,
|
||||||
),
|
),
|
||||||
aShowLocationState(
|
aShowLocationState(
|
||||||
hasLocationPermission = true,
|
hasLocationPermission = true,
|
||||||
@@ -30,33 +38,48 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
|||||||
hasLocationPermission = true,
|
hasLocationPermission = true,
|
||||||
isTrackMyLocation = true,
|
isTrackMyLocation = true,
|
||||||
),
|
),
|
||||||
aShowLocationState(
|
|
||||||
description = "My favourite place!",
|
|
||||||
),
|
|
||||||
aShowLocationState(
|
|
||||||
description = "For some reason I decided to to write a small essay that wraps at just two lines!",
|
|
||||||
),
|
|
||||||
aShowLocationState(
|
|
||||||
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!",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val APP_NAME = "ApplicationName"
|
||||||
|
|
||||||
fun aShowLocationState(
|
fun aShowLocationState(
|
||||||
permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None,
|
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
|
||||||
location: Location = Location(1.23, 2.34, 4f),
|
locationShares: List<LocationShareItem> = listOf(aLocationShareItem()),
|
||||||
description: String? = null,
|
|
||||||
hasLocationPermission: Boolean = false,
|
hasLocationPermission: Boolean = false,
|
||||||
isTrackMyLocation: Boolean = false,
|
isTrackMyLocation: Boolean = false,
|
||||||
appName: String = APP_NAME,
|
appName: String = APP_NAME,
|
||||||
eventSink: (ShowLocationEvents) -> Unit = {},
|
eventSink: (ShowLocationEvent) -> Unit = {},
|
||||||
) = ShowLocationState(
|
): ShowLocationState {
|
||||||
permissionDialog = permissionDialog,
|
return ShowLocationState(
|
||||||
|
dialogState = constraintsDialogState,
|
||||||
|
locationShares = locationShares.toImmutableList(),
|
||||||
|
hasLocationPermission = hasLocationPermission,
|
||||||
|
isTrackMyLocation = isTrackMyLocation,
|
||||||
|
appName = appName,
|
||||||
|
eventSink = eventSink,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aLocationShareItem(
|
||||||
|
userId: UserId = UserId("@alice:matrix.org"),
|
||||||
|
displayName: String = "Alice",
|
||||||
|
avatarData: AvatarData = AvatarData(
|
||||||
|
id = userId.value,
|
||||||
|
name = displayName,
|
||||||
|
url = null,
|
||||||
|
size = AvatarSize.UserListItem,
|
||||||
|
),
|
||||||
|
formattedTimestamp: String = "Shared 1 min ago",
|
||||||
|
location: Location = Location(1.23, 2.34, 4f),
|
||||||
|
isLive: Boolean = false,
|
||||||
|
assetType: AssetType? = null,
|
||||||
|
) = LocationShareItem(
|
||||||
|
userId = userId,
|
||||||
|
displayName = displayName,
|
||||||
|
avatarData = avatarData,
|
||||||
|
formattedTimestamp = formattedTimestamp,
|
||||||
location = location,
|
location = location,
|
||||||
description = description,
|
isLive = isLive,
|
||||||
hasLocationPermission = hasLocationPermission,
|
assetType = assetType,
|
||||||
isTrackMyLocation = isTrackMyLocation,
|
|
||||||
appName = appName,
|
|
||||||
eventSink = eventSink,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,49 +6,48 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
|
||||||
|
|
||||||
package io.element.android.features.location.impl.show
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.BottomSheetDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.TypographyTokens
|
|
||||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
import io.element.android.features.location.impl.common.PermissionDeniedDialog
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
|
||||||
import io.element.android.features.location.impl.common.PermissionRationaleDialog
|
|
||||||
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationPinMarkers
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationShareRow
|
||||||
|
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
||||||
|
import io.element.android.features.location.impl.common.ui.UserLocationPuck
|
||||||
|
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMode
|
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
|
|
||||||
import io.element.android.libraries.maplibre.compose.IconAnchor
|
|
||||||
import io.element.android.libraries.maplibre.compose.MapLibreMap
|
|
||||||
import io.element.android.libraries.maplibre.compose.Symbol
|
|
||||||
import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
|
|
||||||
import io.element.android.libraries.maplibre.compose.rememberSymbolState
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import kotlinx.coroutines.launch
|
||||||
import org.maplibre.android.camera.CameraPosition
|
import org.maplibre.compose.camera.CameraMoveReason
|
||||||
import org.maplibre.android.geometry.LatLng
|
import org.maplibre.compose.camera.CameraPosition
|
||||||
|
import org.maplibre.compose.camera.rememberCameraState
|
||||||
|
import org.maplibre.spatialk.geojson.Position
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -57,46 +56,53 @@ fun ShowLocationView(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (state.permissionDialog) {
|
LocationConstraintsDialog(
|
||||||
ShowLocationState.Dialog.None -> Unit
|
state = state.dialogState,
|
||||||
ShowLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog(
|
appName = state.appName,
|
||||||
onContinue = { state.eventSink(ShowLocationEvents.OpenAppSettings) },
|
onRequestPermissions = { state.eventSink(ShowLocationEvent.RequestPermissions) },
|
||||||
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) },
|
onOpenAppSettings = { state.eventSink(ShowLocationEvent.OpenAppSettings) },
|
||||||
appName = state.appName,
|
onOpenLocationSettings = { state.eventSink(ShowLocationEvent.OpenLocationSettings) },
|
||||||
)
|
onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) },
|
||||||
ShowLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog(
|
)
|
||||||
onContinue = { state.eventSink(ShowLocationEvents.RequestPermissions) },
|
|
||||||
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) },
|
|
||||||
appName = state.appName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val cameraPositionState = rememberCameraPositionState {
|
val initialPosition = remember {
|
||||||
position = CameraPosition.Builder()
|
if (state.locationShares.isEmpty()) {
|
||||||
.target(LatLng(state.location.lat, state.location.lon))
|
MapDefaults.defaultCameraPosition
|
||||||
.zoom(MapDefaults.DEFAULT_ZOOM)
|
} else {
|
||||||
.build()
|
val firstLocation = state.locationShares.first().location
|
||||||
|
CameraPosition(
|
||||||
|
target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon),
|
||||||
|
zoom = MapDefaults.DEFAULT_ZOOM
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
val cameraState = rememberCameraState(firstPosition = initialPosition)
|
||||||
LaunchedEffect(state.isTrackMyLocation) {
|
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
|
||||||
when (state.isTrackMyLocation) {
|
LaunchedEffect(cameraState.isCameraMoving) {
|
||||||
false -> cameraPositionState.cameraMode = CameraMode.NONE
|
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
|
||||||
true -> {
|
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||||
cameraPositionState.position = CameraPosition.Builder()
|
|
||||||
.zoom(MapDefaults.DEFAULT_ZOOM)
|
|
||||||
.build()
|
|
||||||
cameraPositionState.cameraMode = CameraMode.TRACKING
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(cameraPositionState.isMoving) {
|
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||||
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
|
bottomSheetState = rememberStandardBottomSheetState(
|
||||||
state.eventSink(ShowLocationEvents.TrackMyLocation(false))
|
initialValue =
|
||||||
}
|
if (state.isSheetDraggable) {
|
||||||
}
|
SheetValue.PartiallyExpanded
|
||||||
|
} else {
|
||||||
Scaffold(
|
SheetValue.Expanded
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
MapBottomSheetScaffold(
|
||||||
|
sheetDragHandle = if (state.isSheetDraggable) {
|
||||||
|
{ BottomSheetDefaults.DragHandle() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
sheetSwipeEnabled = state.isSheetDraggable,
|
||||||
|
scaffoldState = scaffoldState,
|
||||||
|
cameraState = cameraState,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -106,65 +112,56 @@ fun ShowLocationView(
|
|||||||
onClick = onBackClick,
|
onClick = onBackClick,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
actions = {
|
|
||||||
IconButton(
|
|
||||||
onClick = { state.eventSink(ShowLocationEvents.Share) }
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = CompoundIcons.ShareAndroid(),
|
|
||||||
contentDescription = stringResource(CommonStrings.action_share),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
sheetContent = { sheetPaddings ->
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(CommonStrings.screen_static_location_sheet_title),
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
state.locationShares.forEach { locationShare ->
|
||||||
|
LocationShareRow(
|
||||||
|
item = locationShare,
|
||||||
|
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||||
|
val position = CameraPosition(
|
||||||
|
padding = sheetPaddings,
|
||||||
|
target = Position(locationShare.location.lon, locationShare.location.lat),
|
||||||
|
zoom = MapDefaults.DEFAULT_ZOOM
|
||||||
|
)
|
||||||
|
coroutineScope.launch {
|
||||||
|
cameraState.animateTo(finalPosition = position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mapContent = {
|
||||||
|
UserLocationPuck(
|
||||||
|
cameraState = cameraState,
|
||||||
|
locationState = userLocationState,
|
||||||
|
trackUserLocation = state.isTrackMyLocation
|
||||||
|
)
|
||||||
|
val markers = remember(state.locationShares) {
|
||||||
|
state.locationShares.map { it.toMarkerData() }
|
||||||
|
}
|
||||||
|
LocationPinMarkers(markers)
|
||||||
|
},
|
||||||
|
overlayContent = {
|
||||||
LocationFloatingActionButton(
|
LocationFloatingActionButton(
|
||||||
isMapCenteredOnUser = state.isTrackMyLocation,
|
isMapCenteredOnUser = state.isTrackMyLocation,
|
||||||
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
|
onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(all = 16.dp),
|
||||||
)
|
)
|
||||||
},
|
|
||||||
) { 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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MapLibreMap(
|
|
||||||
styleUri = rememberTileStyleUrl(),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
images = mapOf(PIN_ID to CommonDrawables.pin).toImmutableMap(),
|
|
||||||
cameraPositionState = cameraPositionState,
|
|
||||||
uiSettings = MapDefaults.uiSettings,
|
|
||||||
symbolManagerSettings = MapDefaults.symbolManagerSettings,
|
|
||||||
locationSettings = MapDefaults.locationSettings.copy(
|
|
||||||
locationEnabled = state.hasLocationPermission,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Symbol(
|
|
||||||
iconId = PIN_ID,
|
|
||||||
state = rememberSymbolState(
|
|
||||||
position = LatLng(state.location.lat, state.location.lon)
|
|
||||||
),
|
|
||||||
iconAnchor = IconAnchor.BOTTOM,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@@ -175,5 +172,3 @@ internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider
|
|||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val PIN_ID = "pin"
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="26dp"
|
|
||||||
android:height="28dp"
|
|
||||||
android:viewportWidth="26"
|
|
||||||
android:viewportHeight="28">
|
|
||||||
<path
|
|
||||||
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
|
|
||||||
android:fillColor="#EBEEF2"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
|
|
||||||
android:fillColor="#EBEEF2"/>
|
|
||||||
<group>
|
|
||||||
<clip-path
|
|
||||||
android:pathData="M6.74,6.74h12.444v12.444h-12.444z"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M12.962,6.74C10.554,6.74 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.74 12.962,6.74ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
|
|
||||||
android:fillColor="#101317"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="26dp"
|
|
||||||
android:height="28dp"
|
|
||||||
android:viewportWidth="26"
|
|
||||||
android:viewportHeight="28">
|
|
||||||
<path
|
|
||||||
android:pathData="M12.962,28L9.819,24.889L16.105,24.889L12.962,28Z"
|
|
||||||
android:fillColor="#1B1D22"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M12.963,12.963m-12.963,0a12.963,12.963 0,1 1,25.926 0a12.963,12.963 0,1 1,-25.926 0"
|
|
||||||
android:fillColor="#1B1D22"/>
|
|
||||||
<group>
|
|
||||||
<clip-path
|
|
||||||
android:pathData="M6.74,6.741h12.444v12.444h-12.444z"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M12.962,6.741C10.554,6.741 8.606,8.741 8.606,11.215C8.606,13.88 11.357,17.555 12.489,18.955C12.738,19.262 13.192,19.262 13.441,18.955C14.567,17.555 17.318,13.88 17.318,11.215C17.318,8.741 15.37,6.741 12.962,6.741ZM12.962,12.813C12.103,12.813 11.406,12.097 11.406,11.215C11.406,10.333 12.103,9.617 12.962,9.617C13.821,9.617 14.518,10.333 14.518,11.215C14.518,12.097 13.821,12.813 12.962,12.813Z"
|
|
||||||
android:fillColor="#ffffff"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_share_location_live_location_duration_picker_title">"Valitse, kuinka kauan haluat jakaa reaaliaikaisen sijaintisi."</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_share_location_live_location_duration_picker_title">"Choisissez la durée pendant laquelle vous partagerez votre position en direct."</string>
|
||||||
|
</resources>
|
||||||
4
features/location/impl/src/main/res/values/localazy.xml
Normal file
4
features/location/impl/src/main/res/values/localazy.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_share_location_live_location_duration_picker_title">"Choose how long to share your live location."</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.impl.aPermissionsState
|
||||||
|
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||||
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LocationConstraintsCheckTest {
|
||||||
|
@Test
|
||||||
|
fun `checkLocationConstraints returns Success when permissions granted and location enabled`() {
|
||||||
|
val permissionsState = aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
)
|
||||||
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
|
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checkLocationConstraints returns Success when some permissions granted and location enabled`() {
|
||||||
|
val permissionsState = aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.SomeGranted,
|
||||||
|
)
|
||||||
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
|
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checkLocationConstraints returns LocationServiceDisabled when permissions granted but location disabled`() {
|
||||||
|
val permissionsState = aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
)
|
||||||
|
val locationActions = FakeLocationActions(isLocationEnabled = false)
|
||||||
|
|
||||||
|
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checkLocationConstraints returns PermissionRationale when permissions denied with rationale`() {
|
||||||
|
val permissionsState = aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = true,
|
||||||
|
)
|
||||||
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
|
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checkLocationConstraints returns PermissionDenied when permissions denied without rationale`() {
|
||||||
|
val permissionsState = aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||||
|
|
||||||
|
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ package io.element.android.features.location.impl.common.actions
|
|||||||
|
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
|
||||||
class FakeLocationActions : LocationActions {
|
class FakeLocationActions(
|
||||||
|
private var isLocationEnabled: Boolean = true,
|
||||||
|
) : LocationActions {
|
||||||
var sharedLocation: Location? = null
|
var sharedLocation: Location? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -20,12 +22,27 @@ class FakeLocationActions : LocationActions {
|
|||||||
var openSettingsInvocationsCount = 0
|
var openSettingsInvocationsCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var openLocationSettingsInvocationsCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
override fun share(location: Location, label: String?) {
|
override fun share(location: Location, label: String?) {
|
||||||
sharedLocation = location
|
sharedLocation = location
|
||||||
sharedLabel = label
|
sharedLabel = label
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun openSettings() {
|
override fun openAppSettings() {
|
||||||
openSettingsInvocationsCount++
|
openSettingsInvocationsCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun isLocationEnabled(): Boolean {
|
||||||
|
return isLocationEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openLocationSettings() {
|
||||||
|
openLocationSettingsInvocationsCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenLocationEnabled(enabled: Boolean) {
|
||||||
|
isLocationEnabled = enabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,496 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.send
|
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
|
||||||
import app.cash.molecule.moleculeFlow
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
|
||||||
import io.element.android.features.location.api.Location
|
|
||||||
import io.element.android.features.location.impl.aPermissionsState
|
|
||||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
|
||||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
|
||||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
|
||||||
import io.element.android.tests.testutils.lambda.value
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class SendLocationPresenterTest {
|
|
||||||
@get:Rule
|
|
||||||
val warmUpRule = WarmUpRule()
|
|
||||||
|
|
||||||
private val fakePermissionsPresenter = FakePermissionsPresenter()
|
|
||||||
private val fakeAnalyticsService = FakeAnalyticsService()
|
|
||||||
private val fakeMessageComposerContext = FakeMessageComposerContext()
|
|
||||||
private val fakeLocationActions = FakeLocationActions()
|
|
||||||
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
|
|
||||||
|
|
||||||
private fun createSendLocationPresenter(
|
|
||||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
|
||||||
): SendLocationPresenter = SendLocationPresenter(
|
|
||||||
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
|
|
||||||
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
|
|
||||||
},
|
|
||||||
room = joinedRoom,
|
|
||||||
timelineMode = Timeline.Mode.Live,
|
|
||||||
analyticsService = fakeAnalyticsService,
|
|
||||||
messageComposerContext = fakeMessageComposerContext,
|
|
||||||
locationActions = fakeLocationActions,
|
|
||||||
buildMeta = fakeBuildMeta,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `initial state with permissions granted`() = runTest {
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter()
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.AllGranted,
|
|
||||||
shouldShowRationale = false,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
|
|
||||||
assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation)
|
|
||||||
assertThat(initialState.hasLocationPermission).isTrue()
|
|
||||||
|
|
||||||
// Swipe the map to switch mode
|
|
||||||
initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode)
|
|
||||||
val myLocationState = awaitItem()
|
|
||||||
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
|
|
||||||
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(myLocationState.hasLocationPermission).isTrue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `initial state with permissions partially granted`() = runTest {
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter()
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.SomeGranted,
|
|
||||||
shouldShowRationale = false,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
|
|
||||||
assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation)
|
|
||||||
assertThat(initialState.hasLocationPermission).isTrue()
|
|
||||||
|
|
||||||
// Swipe the map to switch mode
|
|
||||||
initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode)
|
|
||||||
val myLocationState = awaitItem()
|
|
||||||
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
|
|
||||||
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(myLocationState.hasLocationPermission).isTrue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `initial state with permissions denied`() = runTest {
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter()
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
|
||||||
shouldShowRationale = false,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
|
|
||||||
assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(initialState.hasLocationPermission).isFalse()
|
|
||||||
|
|
||||||
// Click on the button to switch mode
|
|
||||||
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
|
|
||||||
val myLocationState = awaitItem()
|
|
||||||
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied)
|
|
||||||
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(myLocationState.hasLocationPermission).isFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `initial state with permissions denied once`() = runTest {
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter()
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
|
||||||
shouldShowRationale = true,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
|
|
||||||
assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(initialState.hasLocationPermission).isFalse()
|
|
||||||
|
|
||||||
// Click on the button to switch mode
|
|
||||||
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
|
|
||||||
val myLocationState = awaitItem()
|
|
||||||
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
|
|
||||||
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(myLocationState.hasLocationPermission).isFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rationale dialog dismiss`() = runTest {
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter()
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
|
||||||
shouldShowRationale = true,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
|
||||||
val initialState = awaitItem()
|
|
||||||
|
|
||||||
// Click on the button to switch mode
|
|
||||||
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
|
|
||||||
val myLocationState = awaitItem()
|
|
||||||
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
|
|
||||||
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(myLocationState.hasLocationPermission).isFalse()
|
|
||||||
|
|
||||||
// Dismiss the dialog
|
|
||||||
myLocationState.eventSink(SendLocationEvents.DismissDialog)
|
|
||||||
val dialogDismissedState = awaitItem()
|
|
||||||
assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
|
|
||||||
assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rationale dialog continue`() = runTest {
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter()
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
|
||||||
shouldShowRationale = true,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
|
||||||
val initialState = awaitItem()
|
|
||||||
|
|
||||||
// Click on the button to switch mode
|
|
||||||
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
|
|
||||||
val myLocationState = awaitItem()
|
|
||||||
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
|
|
||||||
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(myLocationState.hasLocationPermission).isFalse()
|
|
||||||
|
|
||||||
// Continue the dialog sends permission request to the permissions presenter
|
|
||||||
myLocationState.eventSink(SendLocationEvents.RequestPermissions)
|
|
||||||
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `permission denied dialog dismiss`() = runTest {
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter()
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
|
||||||
shouldShowRationale = false,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
|
||||||
val initialState = awaitItem()
|
|
||||||
|
|
||||||
// Click on the button to switch mode
|
|
||||||
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
|
|
||||||
val myLocationState = awaitItem()
|
|
||||||
assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied)
|
|
||||||
assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(myLocationState.hasLocationPermission).isFalse()
|
|
||||||
|
|
||||||
// Dismiss the dialog
|
|
||||||
myLocationState.eventSink(SendLocationEvents.DismissDialog)
|
|
||||||
val dialogDismissedState = awaitItem()
|
|
||||||
assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
|
|
||||||
assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
|
|
||||||
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `share sender location`() = runTest {
|
|
||||||
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
|
|
||||||
Result.success(Unit)
|
|
||||||
}
|
|
||||||
val joinedRoom = FakeJoinedRoom(
|
|
||||||
liveTimeline = FakeTimeline().apply {
|
|
||||||
sendLocationLambda = sendLocationResult
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter(joinedRoom)
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.AllGranted,
|
|
||||||
shouldShowRationale = false,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
|
||||||
val initialState = awaitItem()
|
|
||||||
|
|
||||||
// Send location
|
|
||||||
initialState.eventSink(
|
|
||||||
SendLocationEvents.SendLocation(
|
|
||||||
cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
|
|
||||||
lat = 0.0,
|
|
||||||
lon = 1.0,
|
|
||||||
zoom = 2.0,
|
|
||||||
),
|
|
||||||
location = Location(
|
|
||||||
lat = 3.0,
|
|
||||||
lon = 4.0,
|
|
||||||
accuracy = 5.0f,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
delay(1) // Wait for the coroutine to finish
|
|
||||||
|
|
||||||
sendLocationResult.assertions().isCalledOnce()
|
|
||||||
.with(
|
|
||||||
value("Location was shared at geo:3.0,4.0;u=5.0"),
|
|
||||||
value("geo:3.0,4.0;u=5.0"),
|
|
||||||
value(null),
|
|
||||||
value(15),
|
|
||||||
value(AssetType.SENDER),
|
|
||||||
value(null),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
|
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
|
|
||||||
Composer(
|
|
||||||
inThread = false,
|
|
||||||
isEditing = false,
|
|
||||||
isReply = false,
|
|
||||||
messageType = Composer.MessageType.LocationUser,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `share pin location`() = runTest {
|
|
||||||
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
|
|
||||||
Result.success(Unit)
|
|
||||||
}
|
|
||||||
val joinedRoom = FakeJoinedRoom(
|
|
||||||
liveTimeline = FakeTimeline().apply {
|
|
||||||
sendLocationLambda = sendLocationResult
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter(joinedRoom)
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
|
||||||
shouldShowRationale = false,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
|
||||||
val initialState = awaitItem()
|
|
||||||
|
|
||||||
// Send location
|
|
||||||
initialState.eventSink(
|
|
||||||
SendLocationEvents.SendLocation(
|
|
||||||
cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
|
|
||||||
lat = 0.0,
|
|
||||||
lon = 1.0,
|
|
||||||
zoom = 2.0,
|
|
||||||
),
|
|
||||||
location = Location(
|
|
||||||
lat = 3.0,
|
|
||||||
lon = 4.0,
|
|
||||||
accuracy = 5.0f,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
delay(1) // Wait for the coroutine to finish
|
|
||||||
|
|
||||||
sendLocationResult.assertions().isCalledOnce()
|
|
||||||
.with(
|
|
||||||
value("Location was shared at geo:0.0,1.0"),
|
|
||||||
value("geo:0.0,1.0"),
|
|
||||||
value(null),
|
|
||||||
value(15),
|
|
||||||
value(AssetType.PIN),
|
|
||||||
value(null),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
|
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
|
|
||||||
Composer(
|
|
||||||
inThread = false,
|
|
||||||
isEditing = false,
|
|
||||||
isReply = false,
|
|
||||||
messageType = Composer.MessageType.LocationPin,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `composer context passes through analytics`() = runTest {
|
|
||||||
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
|
|
||||||
Result.success(Unit)
|
|
||||||
}
|
|
||||||
val joinedRoom = FakeJoinedRoom(
|
|
||||||
liveTimeline = FakeTimeline().apply {
|
|
||||||
sendLocationLambda = sendLocationResult
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter(joinedRoom)
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
|
||||||
shouldShowRationale = false,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
fakeMessageComposerContext.apply {
|
|
||||||
composerMode = MessageComposerMode.Edit(
|
|
||||||
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
|
||||||
content = ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
|
||||||
val initialState = awaitItem()
|
|
||||||
|
|
||||||
// Send location
|
|
||||||
initialState.eventSink(
|
|
||||||
SendLocationEvents.SendLocation(
|
|
||||||
cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
|
|
||||||
lat = 0.0,
|
|
||||||
lon = 1.0,
|
|
||||||
zoom = 2.0,
|
|
||||||
),
|
|
||||||
location = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
delay(1) // Wait for the coroutine to finish
|
|
||||||
|
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
|
|
||||||
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
|
|
||||||
Composer(
|
|
||||||
inThread = false,
|
|
||||||
isEditing = true,
|
|
||||||
isReply = false,
|
|
||||||
messageType = Composer.MessageType.LocationPin,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `open settings activity`() = runTest {
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter()
|
|
||||||
fakePermissionsPresenter.givenState(
|
|
||||||
aPermissionsState(
|
|
||||||
permissions = PermissionsState.Permissions.NoneGranted,
|
|
||||||
shouldShowRationale = false,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
fakeMessageComposerContext.apply {
|
|
||||||
composerMode = MessageComposerMode.Edit(
|
|
||||||
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
|
||||||
content = ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
|
||||||
val initialState = awaitItem()
|
|
||||||
|
|
||||||
initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
|
|
||||||
val dialogShownState = awaitItem()
|
|
||||||
|
|
||||||
// Open settings
|
|
||||||
dialogShownState.eventSink(SendLocationEvents.OpenAppSettings)
|
|
||||||
val settingsOpenedState = awaitItem()
|
|
||||||
|
|
||||||
assertThat(settingsOpenedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
|
|
||||||
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `application name is in state`() = runTest {
|
|
||||||
val sendLocationPresenter = createSendLocationPresenter()
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
sendLocationPresenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.appName).isEqualTo("app name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.location.impl.send
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
@@ -14,7 +14,10 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
|
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
@@ -22,19 +25,19 @@ import io.element.android.tests.testutils.node.TestParentNode
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DefaultSendLocationEntryPointTest {
|
class DefaultShareLocationEntryPointTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test node builder`() {
|
fun `test node builder`() {
|
||||||
val entryPoint = DefaultSendLocationEntryPoint()
|
val entryPoint = DefaultShareLocationEntryPoint()
|
||||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||||
SendLocationNode(
|
ShareLocationNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
presenterFactory = { timelineMode: Timeline.Mode ->
|
presenterFactory = { timelineMode: Timeline.Mode ->
|
||||||
SendLocationPresenter(
|
ShareLocationPresenter(
|
||||||
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
||||||
room = FakeJoinedRoom(),
|
room = FakeJoinedRoom(),
|
||||||
timelineMode = timelineMode,
|
timelineMode = timelineMode,
|
||||||
@@ -42,6 +45,9 @@ class DefaultSendLocationEntryPointTest {
|
|||||||
messageComposerContext = FakeMessageComposerContext(),
|
messageComposerContext = FakeMessageComposerContext(),
|
||||||
locationActions = FakeLocationActions(),
|
locationActions = FakeLocationActions(),
|
||||||
buildMeta = aBuildMeta(),
|
buildMeta = aBuildMeta(),
|
||||||
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
|
client = FakeMatrixClient(),
|
||||||
|
durationFormatter = FakeDurationFormatter(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
@@ -53,7 +59,7 @@ class DefaultSendLocationEntryPointTest {
|
|||||||
buildContext = BuildContext.root(null),
|
buildContext = BuildContext.root(null),
|
||||||
timelineMode = timelineMode,
|
timelineMode = timelineMode,
|
||||||
)
|
)
|
||||||
assertThat(result).isInstanceOf(SendLocationNode::class.java)
|
assertThat(result).isInstanceOf(ShareLocationNode::class.java)
|
||||||
assertThat(result.plugins).contains(SendLocationNode.Inputs(timelineMode))
|
assertThat(result.plugins).contains(ShareLocationNode.Inputs(timelineMode))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
* Copyright 2023-2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionMode
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.impl.aPermissionsState
|
||||||
|
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||||
|
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||||
|
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||||
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
|
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ShareLocationPresenterTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
private val fakePermissionsPresenter = FakePermissionsPresenter()
|
||||||
|
private val fakeAnalyticsService = FakeAnalyticsService()
|
||||||
|
private val fakeMessageComposerContext = FakeMessageComposerContext()
|
||||||
|
private val fakeLocationActions = FakeLocationActions()
|
||||||
|
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
|
||||||
|
private val fakeFeatureFlagService = FakeFeatureFlagService()
|
||||||
|
private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
|
||||||
|
|
||||||
|
private val durationFormatter = FakeDurationFormatter()
|
||||||
|
|
||||||
|
private fun createShareLocationPresenter(
|
||||||
|
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||||
|
locationActions: FakeLocationActions = fakeLocationActions,
|
||||||
|
): ShareLocationPresenter = ShareLocationPresenter(
|
||||||
|
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||||
|
room = joinedRoom,
|
||||||
|
timelineMode = Timeline.Mode.Live,
|
||||||
|
analyticsService = fakeAnalyticsService,
|
||||||
|
messageComposerContext = fakeMessageComposerContext,
|
||||||
|
locationActions = locationActions,
|
||||||
|
buildMeta = fakeBuildMeta,
|
||||||
|
featureFlagService = fakeFeatureFlagService,
|
||||||
|
client = fakeMatrixClient,
|
||||||
|
durationFormatter = durationFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state with permissions granted and location enabled`() = runTest {
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
assertThat(state.trackUserLocation).isTrue()
|
||||||
|
assertThat(state.hasLocationPermission).isTrue()
|
||||||
|
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state with permissions partially granted and location enabled`() = runTest {
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.SomeGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
shareLocationPresenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.trackUserLocation).isTrue()
|
||||||
|
assertThat(initialState.hasLocationPermission).isTrue()
|
||||||
|
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state with permissions denied`() = runTest {
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
shareLocationPresenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.trackUserLocation).isFalse()
|
||||||
|
assertThat(initialState.hasLocationPermission).isFalse()
|
||||||
|
assertThat(initialState.dialogState).isEqualTo(
|
||||||
|
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state with permissions denied with rationale`() = runTest {
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.trackUserLocation).isFalse()
|
||||||
|
assertThat(initialState.hasLocationPermission).isFalse()
|
||||||
|
assertThat(initialState.dialogState).isEqualTo(
|
||||||
|
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state with location services disabled`() = runTest {
|
||||||
|
val locationActions = FakeLocationActions(isLocationEnabled = false)
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.trackUserLocation).isFalse()
|
||||||
|
assertThat(initialState.hasLocationPermission).isTrue()
|
||||||
|
assertThat(initialState.dialogState).isEqualTo(
|
||||||
|
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `StopTrackingUserLocation event sets trackUserLocation to false`() = runTest {
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.trackUserLocation).isTrue()
|
||||||
|
|
||||||
|
initialState.eventSink(ShareLocationEvent.StopTrackingUserLocation)
|
||||||
|
val stoppedState = awaitItem()
|
||||||
|
assertThat(stoppedState.trackUserLocation).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DismissDialog event clears dialog state`() = runTest {
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.dialogState).isEqualTo(
|
||||||
|
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
|
||||||
|
)
|
||||||
|
|
||||||
|
initialState.eventSink(ShareLocationEvent.DismissDialog)
|
||||||
|
val dismissedState = awaitItem()
|
||||||
|
assertThat(dismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `RequestPermissions event triggers permission request`() = runTest {
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ShareLocationEvent.RequestPermissions)
|
||||||
|
|
||||||
|
// Wait for dialog to be dismissed
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `OpenAppSettings event opens settings and clears dialog`() = runTest {
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ShareLocationEvent.OpenAppSettings)
|
||||||
|
val settingsOpenedState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
|
||||||
|
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `OpenLocationSettings event opens location settings and clears dialog`() = runTest {
|
||||||
|
val locationActions = FakeLocationActions(isLocationEnabled = false)
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.dialogState).isEqualTo(
|
||||||
|
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
|
||||||
|
)
|
||||||
|
|
||||||
|
initialState.eventSink(ShareLocationEvent.OpenLocationSettings)
|
||||||
|
val settingsOpenedState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None)
|
||||||
|
assertThat(locationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest {
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||||
|
val durationDialogState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter()
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.NoneGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
// Dismiss initial dialog
|
||||||
|
initialState.eventSink(ShareLocationEvent.DismissDialog)
|
||||||
|
val dismissedState = awaitItem()
|
||||||
|
|
||||||
|
dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||||
|
val constraintDialogState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(constraintDialogState.dialogState).isEqualTo(
|
||||||
|
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied)
|
||||||
|
)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ShareStaticLocation sends user location`() = runTest {
|
||||||
|
val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val joinedRoom = FakeJoinedRoom(
|
||||||
|
liveTimeline = FakeTimeline().apply {
|
||||||
|
sendLocationLambda = sendLocationResult
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(joinedRoom)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
|
||||||
|
initialState.eventSink(
|
||||||
|
ShareLocationEvent.ShareStaticLocation(
|
||||||
|
location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f),
|
||||||
|
isPinned = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
sendLocationResult.assertions().isCalledOnce()
|
||||||
|
.with(
|
||||||
|
value("Location was shared at geo:3.0,4.0;u=5.0"),
|
||||||
|
value("geo:3.0,4.0;u=5.0"),
|
||||||
|
value(null),
|
||||||
|
value(15),
|
||||||
|
value(AssetType.SENDER),
|
||||||
|
value(null),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
|
||||||
|
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
|
||||||
|
Composer(
|
||||||
|
inThread = false,
|
||||||
|
isEditing = false,
|
||||||
|
isReply = false,
|
||||||
|
messageType = Composer.MessageType.LocationUser,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ShareStaticLocation sends pinned location`() = runTest {
|
||||||
|
val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val joinedRoom = FakeJoinedRoom(
|
||||||
|
liveTimeline = FakeTimeline().apply {
|
||||||
|
sendLocationLambda = sendLocationResult
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val shareLocationPresenter = createShareLocationPresenter(joinedRoom)
|
||||||
|
fakePermissionsPresenter.givenState(
|
||||||
|
aPermissionsState(
|
||||||
|
permissions = PermissionsState.Permissions.AllGranted,
|
||||||
|
shouldShowRationale = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
shareLocationPresenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
|
||||||
|
initialState.eventSink(
|
||||||
|
ShareLocationEvent.ShareStaticLocation(
|
||||||
|
location = Location(lat = 1.0, lon = 2.0, accuracy = 3.0f),
|
||||||
|
isPinned = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
sendLocationResult.assertions().isCalledOnce()
|
||||||
|
.with(
|
||||||
|
value("Location was shared at geo:1.0,2.0;u=3.0"),
|
||||||
|
value("geo:1.0,2.0;u=3.0"),
|
||||||
|
value(null),
|
||||||
|
value(15),
|
||||||
|
value(AssetType.PIN),
|
||||||
|
value(null),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
|
||||||
|
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
|
||||||
|
Composer(
|
||||||
|
inThread = false,
|
||||||
|
isEditing = false,
|
||||||
|
isReply = false,
|
||||||
|
messageType = Composer.MessageType.LocationPin,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.libraries.testtags.TestTags
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
import io.element.android.tests.testutils.clickOn
|
||||||
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
|
import io.element.android.tests.testutils.pressBack
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ShareLocationViewTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test back action`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>(expectEvents = false)
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setShareLocationView(
|
||||||
|
state = aShareLocationState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
navigateUp = callback,
|
||||||
|
)
|
||||||
|
rule.pressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test fab click`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||||
|
rule.setShareLocationView(
|
||||||
|
aShareLocationState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
navigateUp = EnsureNeverCalled(),
|
||||||
|
)
|
||||||
|
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
|
||||||
|
eventsRecorder.assertSingle(ShareLocationEvent.StartTrackingUserLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when permission denied is displayed user can open the settings`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||||
|
rule.setShareLocationView(
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
navigateUp = EnsureNeverCalled(),
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_continue)
|
||||||
|
eventsRecorder.assertSingle(ShareLocationEvent.OpenAppSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when permission denied is displayed user can close the dialog`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||||
|
rule.setShareLocationView(
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
navigateUp = EnsureNeverCalled(),
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
|
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when permission rationale is displayed user can request permissions`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||||
|
rule.setShareLocationView(
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
navigateUp = EnsureNeverCalled(),
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_continue)
|
||||||
|
eventsRecorder.assertSingle(ShareLocationEvent.RequestPermissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when permission rationale is displayed user can close the dialog`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||||
|
rule.setShareLocationView(
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
navigateUp = EnsureNeverCalled(),
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
|
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when location service disabled is displayed user can open location settings`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||||
|
rule.setShareLocationView(
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
|
||||||
|
hasLocationPermission = true,
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
navigateUp = EnsureNeverCalled(),
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_continue)
|
||||||
|
eventsRecorder.assertSingle(ShareLocationEvent.OpenLocationSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when location service disabled is displayed user can close the dialog`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||||
|
rule.setShareLocationView(
|
||||||
|
aShareLocationState(
|
||||||
|
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
|
||||||
|
hasLocationPermission = true,
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
navigateUp = EnsureNeverCalled(),
|
||||||
|
)
|
||||||
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
|
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setShareLocationView(
|
||||||
|
state: ShareLocationState,
|
||||||
|
navigateUp: () -> Unit = EnsureNeverCalled(),
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
// Simulate a LocalInspectionMode for MapLibreMap
|
||||||
|
CompositionLocalProvider(LocalInspectionMode provides true) {
|
||||||
|
ShareLocationView(
|
||||||
|
state = state,
|
||||||
|
navigateUp = navigateUp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,14 @@ import com.bumble.appyx.core.modality.BuildContext
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.location.api.ShowLocationEntryPoint
|
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||||
|
import io.element.android.features.location.api.ShowLocationMode
|
||||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||||
|
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||||
import io.element.android.tests.testutils.node.TestParentNode
|
import io.element.android.tests.testutils.node.TestParentNode
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -32,21 +36,28 @@ class DefaultShowLocationEntryPointTest {
|
|||||||
ShowLocationNode(
|
ShowLocationNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
presenterFactory = { location: Location, description: String? ->
|
presenterFactory = object : ShowLocationPresenter.Factory {
|
||||||
ShowLocationPresenter(
|
override fun create(mode: ShowLocationMode) = ShowLocationPresenter(
|
||||||
|
mode = mode,
|
||||||
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
||||||
locationActions = FakeLocationActions(),
|
locationActions = FakeLocationActions(),
|
||||||
buildMeta = aBuildMeta(),
|
buildMeta = aBuildMeta(),
|
||||||
location = location,
|
dateFormatter = FakeDateFormatter(),
|
||||||
description = description,
|
stringProvider = FakeStringProvider()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val inputs = ShowLocationEntryPoint.Inputs(
|
val inputs = ShowLocationEntryPoint.Inputs(
|
||||||
location = Location(37.4219983, -122.084, 10f),
|
mode = ShowLocationMode.Static(
|
||||||
description = "My location",
|
location = Location(37.4219983, -122.084, 10f),
|
||||||
|
senderName = "Alice",
|
||||||
|
senderId = UserId("@alice:matrix.org"),
|
||||||
|
senderAvatarUrl = null,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
assetType = null,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
val result = entryPoint.createNode(
|
val result = entryPoint.createNode(
|
||||||
parentNode = parentNode,
|
parentNode = parentNode,
|
||||||
|
|||||||
@@ -13,14 +13,19 @@ import app.cash.molecule.moleculeFlow
|
|||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.api.ShowLocationMode
|
||||||
import io.element.android.features.location.impl.aPermissionsState
|
import io.element.android.features.location.impl.aPermissionsState
|
||||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
|
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
@@ -33,15 +38,26 @@ class ShowLocationPresenterTest {
|
|||||||
private val fakePermissionsPresenter = FakePermissionsPresenter()
|
private val fakePermissionsPresenter = FakePermissionsPresenter()
|
||||||
private val fakeLocationActions = FakeLocationActions()
|
private val fakeLocationActions = FakeLocationActions()
|
||||||
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
|
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
|
||||||
|
private val fakeDateFormatter = FakeDateFormatter()
|
||||||
private val location = Location(1.23, 4.56, 7.8f)
|
private val location = Location(1.23, 4.56, 7.8f)
|
||||||
private val presenter = ShowLocationPresenter(
|
|
||||||
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
|
private fun createShowLocationPresenter(
|
||||||
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
|
mode: ShowLocationMode = ShowLocationMode.Static(
|
||||||
},
|
location = location,
|
||||||
locationActions = fakeLocationActions,
|
senderName = "Alice",
|
||||||
|
senderId = UserId("@alice:matrix.org"),
|
||||||
|
senderAvatarUrl = null,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
assetType = null,
|
||||||
|
),
|
||||||
|
locationActions: FakeLocationActions = fakeLocationActions,
|
||||||
|
) = ShowLocationPresenter(
|
||||||
|
mode = mode,
|
||||||
|
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||||
|
locationActions = locationActions,
|
||||||
buildMeta = fakeBuildMeta,
|
buildMeta = fakeBuildMeta,
|
||||||
location = location,
|
dateFormatter = fakeDateFormatter,
|
||||||
description = A_DESCRIPTION,
|
stringProvider = FakeStringProvider()
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -53,12 +69,9 @@ class ShowLocationPresenterTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.present()
|
presenter.test {
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.location).isEqualTo(location)
|
|
||||||
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
|
|
||||||
assertThat(initialState.hasLocationPermission).isFalse()
|
assertThat(initialState.hasLocationPermission).isFalse()
|
||||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||||
}
|
}
|
||||||
@@ -73,12 +86,9 @@ class ShowLocationPresenterTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.present()
|
presenter.test {
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.location).isEqualTo(location)
|
|
||||||
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
|
|
||||||
assertThat(initialState.hasLocationPermission).isFalse()
|
assertThat(initialState.hasLocationPermission).isFalse()
|
||||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||||
}
|
}
|
||||||
@@ -88,12 +98,9 @@ class ShowLocationPresenterTest {
|
|||||||
fun `emits initial state with location permission`() = runTest {
|
fun `emits initial state with location permission`() = runTest {
|
||||||
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.present()
|
presenter.test {
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.location).isEqualTo(location)
|
|
||||||
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
|
|
||||||
assertThat(initialState.hasLocationPermission).isTrue()
|
assertThat(initialState.hasLocationPermission).isTrue()
|
||||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||||
}
|
}
|
||||||
@@ -103,12 +110,9 @@ class ShowLocationPresenterTest {
|
|||||||
fun `emits initial state with partial location permission`() = runTest {
|
fun `emits initial state with partial location permission`() = runTest {
|
||||||
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
|
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.present()
|
presenter.test {
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.location).isEqualTo(location)
|
|
||||||
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
|
|
||||||
assertThat(initialState.hasLocationPermission).isTrue()
|
assertThat(initialState.hasLocationPermission).isTrue()
|
||||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||||
}
|
}
|
||||||
@@ -116,14 +120,12 @@ class ShowLocationPresenterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `uses action to share location`() = runTest {
|
fun `uses action to share location`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.present()
|
presenter.test {
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(ShowLocationEvents.Share)
|
initialState.eventSink(ShowLocationEvent.Share(location))
|
||||||
|
|
||||||
assertThat(fakeLocationActions.sharedLocation).isEqualTo(location)
|
assertThat(fakeLocationActions.sharedLocation).isEqualTo(location)
|
||||||
assertThat(fakeLocationActions.sharedLabel).isEqualTo(A_DESCRIPTION)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +133,13 @@ class ShowLocationPresenterTest {
|
|||||||
fun `centers on user location`() = runTest {
|
fun `centers on user location`() = runTest {
|
||||||
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.present()
|
presenter.test {
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.hasLocationPermission).isTrue()
|
assertThat(initialState.hasLocationPermission).isTrue()
|
||||||
assertThat(initialState.isTrackMyLocation).isFalse()
|
assertThat(initialState.isTrackMyLocation).isFalse()
|
||||||
|
|
||||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||||
val trackMyLocationState = awaitItem()
|
val trackMyLocationState = awaitItem()
|
||||||
|
|
||||||
delay(1)
|
delay(1)
|
||||||
@@ -147,9 +148,9 @@ class ShowLocationPresenterTest {
|
|||||||
assertThat(trackMyLocationState.isTrackMyLocation).isTrue()
|
assertThat(trackMyLocationState.isTrackMyLocation).isTrue()
|
||||||
|
|
||||||
// Swipe the map to switch mode
|
// Swipe the map to switch mode
|
||||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(false))
|
initialState.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||||
val trackLocationDisabledState = awaitItem()
|
val trackLocationDisabledState = awaitItem()
|
||||||
assertThat(trackLocationDisabledState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
|
assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
|
||||||
assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse()
|
assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse()
|
||||||
assertThat(trackLocationDisabledState.hasLocationPermission).isTrue()
|
assertThat(trackLocationDisabledState.hasLocationPermission).isTrue()
|
||||||
}
|
}
|
||||||
@@ -164,23 +165,22 @@ class ShowLocationPresenterTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.present()
|
presenter.test {
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
// Skip initial state
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
// Click on the button to switch mode
|
// Click on the button to switch mode
|
||||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||||
val trackLocationState = awaitItem()
|
val trackLocationState = awaitItem()
|
||||||
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale)
|
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
|
||||||
assertThat(trackLocationState.isTrackMyLocation).isFalse()
|
assertThat(trackLocationState.isTrackMyLocation).isFalse()
|
||||||
assertThat(trackLocationState.hasLocationPermission).isFalse()
|
assertThat(trackLocationState.hasLocationPermission).isFalse()
|
||||||
|
|
||||||
// Dismiss the dialog
|
// Dismiss the dialog
|
||||||
initialState.eventSink(ShowLocationEvents.DismissDialog)
|
initialState.eventSink(ShowLocationEvent.DismissDialog)
|
||||||
val dialogDismissedState = awaitItem()
|
val dialogDismissedState = awaitItem()
|
||||||
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
|
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
|
||||||
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
|
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
|
||||||
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
|
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
|
||||||
}
|
}
|
||||||
@@ -194,22 +194,20 @@ class ShowLocationPresenterTest {
|
|||||||
shouldShowRationale = true,
|
shouldShowRationale = true,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
val presenter = createShowLocationPresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
// Skip initial state
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
// Click on the button to switch mode
|
// Click on the button to switch mode
|
||||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||||
val trackLocationState = awaitItem()
|
val trackLocationState = awaitItem()
|
||||||
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale)
|
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
|
||||||
assertThat(trackLocationState.isTrackMyLocation).isFalse()
|
assertThat(trackLocationState.isTrackMyLocation).isFalse()
|
||||||
assertThat(trackLocationState.hasLocationPermission).isFalse()
|
assertThat(trackLocationState.hasLocationPermission).isFalse()
|
||||||
|
|
||||||
// Continue the dialog sends permission request to the permissions presenter
|
// Continue the dialog sends permission request to the permissions presenter
|
||||||
trackLocationState.eventSink(ShowLocationEvents.RequestPermissions)
|
trackLocationState.eventSink(ShowLocationEvent.RequestPermissions)
|
||||||
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
|
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,23 +221,22 @@ class ShowLocationPresenterTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.present()
|
presenter.test {
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
// Skip initial state
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
// Click on the button to switch mode
|
// Click on the button to switch mode
|
||||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||||
val trackLocationState = awaitItem()
|
val trackLocationState = awaitItem()
|
||||||
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionDenied)
|
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied)
|
||||||
assertThat(trackLocationState.isTrackMyLocation).isFalse()
|
assertThat(trackLocationState.isTrackMyLocation).isFalse()
|
||||||
assertThat(trackLocationState.hasLocationPermission).isFalse()
|
assertThat(trackLocationState.hasLocationPermission).isFalse()
|
||||||
|
|
||||||
// Dismiss the dialog
|
// Dismiss the dialog
|
||||||
initialState.eventSink(ShowLocationEvents.DismissDialog)
|
initialState.eventSink(ShowLocationEvent.DismissDialog)
|
||||||
val dialogDismissedState = awaitItem()
|
val dialogDismissedState = awaitItem()
|
||||||
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
|
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
|
||||||
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
|
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
|
||||||
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
|
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
|
||||||
}
|
}
|
||||||
@@ -254,20 +251,19 @@ class ShowLocationPresenterTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createShowLocationPresenter()
|
||||||
presenter.present()
|
presenter.test {
|
||||||
}.test {
|
|
||||||
// Skip initial state
|
// Skip initial state
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
|
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||||
val dialogShownState = awaitItem()
|
val dialogShownState = awaitItem()
|
||||||
|
|
||||||
// Open settings
|
// Open settings
|
||||||
dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings)
|
dialogShownState.eventSink(ShowLocationEvent.OpenAppSettings)
|
||||||
val settingsOpenedState = awaitItem()
|
val settingsOpenedState = awaitItem()
|
||||||
|
|
||||||
assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None)
|
assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
|
||||||
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
|
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,14 +271,51 @@ class ShowLocationPresenterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `application name is in state`() = runTest {
|
fun `application name is in state`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
createShowLocationPresenter().present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.appName).isEqualTo("app name")
|
assertThat(initialState.appName).isEqualTo("app name")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
@Test
|
||||||
private const val A_DESCRIPTION = "My happy place"
|
fun `location service disabled shows dialog`() = runTest {
|
||||||
|
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
||||||
|
fakeLocationActions.givenLocationEnabled(false)
|
||||||
|
|
||||||
|
val presenter = createShowLocationPresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.hasLocationPermission).isTrue()
|
||||||
|
|
||||||
|
// Try to track location when location services are disabled
|
||||||
|
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||||
|
val dialogShownState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled)
|
||||||
|
assertThat(dialogShownState.isTrackMyLocation).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `open location settings from dialog`() = runTest {
|
||||||
|
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
|
||||||
|
fakeLocationActions.givenLocationEnabled(false)
|
||||||
|
|
||||||
|
val presenter = createShowLocationPresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
|
||||||
|
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
|
||||||
|
val dialogShownState = awaitItem()
|
||||||
|
assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled)
|
||||||
|
|
||||||
|
// Open location settings
|
||||||
|
dialogShownState.eventSink(ShowLocationEvent.OpenLocationSettings)
|
||||||
|
val settingsOpenedState = awaitItem()
|
||||||
|
|
||||||
|
assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
|
||||||
|
assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription
|
|||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
@@ -35,7 +37,7 @@ class ShowLocationViewTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test back action`() {
|
fun `test back action`() {
|
||||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>(expectEvents = false)
|
val eventsRecorder = EventsRecorder<ShowLocationEvent>(expectEvents = false)
|
||||||
ensureCalledOnce { callback ->
|
ensureCalledOnce { callback ->
|
||||||
rule.setShowLocationView(
|
rule.setShowLocationView(
|
||||||
state = aShowLocationState(
|
state = aShowLocationState(
|
||||||
@@ -49,7 +51,7 @@ class ShowLocationViewTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test share action`() {
|
fun `test share action`() {
|
||||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||||
rule.setShowLocationView(
|
rule.setShowLocationView(
|
||||||
aShowLocationState(
|
aShowLocationState(
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
@@ -58,12 +60,13 @@ class ShowLocationViewTest {
|
|||||||
)
|
)
|
||||||
val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
|
val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
|
||||||
rule.onNodeWithContentDescription(shareContentDescription).performClick()
|
rule.onNodeWithContentDescription(shareContentDescription).performClick()
|
||||||
eventsRecorder.assertSingle(ShowLocationEvents.Share)
|
// The default aStaticLocationMode uses Location(1.23, 2.34, 4f)
|
||||||
|
eventsRecorder.assertSingle(ShowLocationEvent.Share(Location(1.23, 2.34, 4f)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test fab click`() {
|
fun `test fab click`() {
|
||||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||||
rule.setShowLocationView(
|
rule.setShowLocationView(
|
||||||
aShowLocationState(
|
aShowLocationState(
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
@@ -71,63 +74,63 @@ class ShowLocationViewTest {
|
|||||||
onBackClick = EnsureNeverCalled(),
|
onBackClick = EnsureNeverCalled(),
|
||||||
)
|
)
|
||||||
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
|
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
|
||||||
eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true))
|
eventsRecorder.assertSingle(ShowLocationEvent.TrackMyLocation(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when permission denied is displayed user can open the settings`() {
|
fun `when permission denied is displayed user can open the settings`() {
|
||||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||||
rule.setShowLocationView(
|
rule.setShowLocationView(
|
||||||
aShowLocationState(
|
aShowLocationState(
|
||||||
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
|
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
),
|
),
|
||||||
onBackClick = EnsureNeverCalled(),
|
onBackClick = EnsureNeverCalled(),
|
||||||
)
|
)
|
||||||
rule.clickOn(CommonStrings.action_continue)
|
rule.clickOn(CommonStrings.action_continue)
|
||||||
eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings)
|
eventsRecorder.assertSingle(ShowLocationEvent.OpenAppSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when permission denied is displayed user can close the dialog`() {
|
fun `when permission denied is displayed user can close the dialog`() {
|
||||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||||
rule.setShowLocationView(
|
rule.setShowLocationView(
|
||||||
aShowLocationState(
|
aShowLocationState(
|
||||||
permissionDialog = ShowLocationState.Dialog.PermissionDenied,
|
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
),
|
),
|
||||||
onBackClick = EnsureNeverCalled(),
|
onBackClick = EnsureNeverCalled(),
|
||||||
)
|
)
|
||||||
rule.clickOn(CommonStrings.action_cancel)
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog)
|
eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when permission rationale is displayed user can request permissions`() {
|
fun `when permission rationale is displayed user can request permissions`() {
|
||||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||||
rule.setShowLocationView(
|
rule.setShowLocationView(
|
||||||
aShowLocationState(
|
aShowLocationState(
|
||||||
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
|
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
),
|
),
|
||||||
onBackClick = EnsureNeverCalled(),
|
onBackClick = EnsureNeverCalled(),
|
||||||
)
|
)
|
||||||
rule.clickOn(CommonStrings.action_continue)
|
rule.clickOn(CommonStrings.action_continue)
|
||||||
eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions)
|
eventsRecorder.assertSingle(ShowLocationEvent.RequestPermissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when permission rationale is displayed user can close the dialog`() {
|
fun `when permission rationale is displayed user can close the dialog`() {
|
||||||
val eventsRecorder = EventsRecorder<ShowLocationEvents>()
|
val eventsRecorder = EventsRecorder<ShowLocationEvent>()
|
||||||
rule.setShowLocationView(
|
rule.setShowLocationView(
|
||||||
aShowLocationState(
|
aShowLocationState(
|
||||||
permissionDialog = ShowLocationState.Dialog.PermissionRationale,
|
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
),
|
),
|
||||||
onBackClick = EnsureNeverCalled(),
|
onBackClick = EnsureNeverCalled(),
|
||||||
)
|
)
|
||||||
rule.clickOn(CommonStrings.action_cancel)
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog)
|
eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ package io.element.android.features.location.test
|
|||||||
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
import io.element.android.features.location.api.ShareLocationEntryPoint
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
class FakeSendLocationEntryPoint : SendLocationEntryPoint {
|
class FakeShareLocationEntryPoint : ShareLocationEntryPoint {
|
||||||
override fun createNode(
|
override fun createNode(
|
||||||
parentNode: Node,
|
parentNode: Node,
|
||||||
buildContext: BuildContext,
|
buildContext: BuildContext,
|
||||||
@@ -28,10 +28,10 @@ import io.element.android.features.call.api.CallType
|
|||||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||||
import io.element.android.features.forward.api.ForwardEntryPoint
|
import io.element.android.features.forward.api.ForwardEntryPoint
|
||||||
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
|
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
|
||||||
import io.element.android.features.location.api.Location
|
|
||||||
import io.element.android.features.location.api.LocationService
|
import io.element.android.features.location.api.LocationService
|
||||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
import io.element.android.features.location.api.ShareLocationEntryPoint
|
||||||
import io.element.android.features.location.api.ShowLocationEntryPoint
|
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||||
|
import io.element.android.features.location.api.ShowLocationMode
|
||||||
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
|
||||||
@@ -102,7 +102,7 @@ class MessagesFlowNode(
|
|||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val roomListService: RoomListService,
|
private val roomListService: RoomListService,
|
||||||
private val sessionId: SessionId,
|
private val sessionId: SessionId,
|
||||||
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
private val shareLocationEntryPoint: ShareLocationEntryPoint,
|
||||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||||
@@ -148,7 +148,7 @@ class MessagesFlowNode(
|
|||||||
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget
|
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class LocationViewer(val location: Location, val description: String?) : NavTarget
|
data class LocationViewer(val mode: ShowLocationMode) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
||||||
@@ -336,7 +336,7 @@ class MessagesFlowNode(
|
|||||||
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
|
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
|
||||||
}
|
}
|
||||||
is NavTarget.LocationViewer -> {
|
is NavTarget.LocationViewer -> {
|
||||||
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
|
val inputs = ShowLocationEntryPoint.Inputs(navTarget.mode)
|
||||||
showLocationEntryPoint.createNode(
|
showLocationEntryPoint.createNode(
|
||||||
parentNode = this,
|
parentNode = this,
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
@@ -374,7 +374,7 @@ class MessagesFlowNode(
|
|||||||
createNode<ReportMessageNode>(buildContext, listOf(inputs))
|
createNode<ReportMessageNode>(buildContext, listOf(inputs))
|
||||||
}
|
}
|
||||||
is NavTarget.SendLocation -> {
|
is NavTarget.SendLocation -> {
|
||||||
sendLocationEntryPoint.createNode(
|
shareLocationEntryPoint.createNode(
|
||||||
parentNode = this,
|
parentNode = this,
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
timelineMode = navTarget.timelineMode,
|
timelineMode = navTarget.timelineMode,
|
||||||
@@ -558,9 +558,16 @@ class MessagesFlowNode(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is TimelineItemLocationContent -> {
|
is TimelineItemLocationContent -> {
|
||||||
NavTarget.LocationViewer(
|
val mode = ShowLocationMode.Static(
|
||||||
location = event.content.location,
|
location = event.content.location,
|
||||||
description = event.content.description,
|
senderName = event.safeSenderName,
|
||||||
|
senderId = event.senderId,
|
||||||
|
senderAvatarUrl = event.senderAvatar.url,
|
||||||
|
timestamp = event.sentTimeMillis,
|
||||||
|
assetType = event.content.assetType,
|
||||||
|
)
|
||||||
|
NavTarget.LocationViewer(
|
||||||
|
mode = mode
|
||||||
).takeIf { locationService.isServiceAvailable() }
|
).takeIf { locationService.isServiceAvailable() }
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady
|
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
@@ -166,7 +166,7 @@ internal fun aTimelineItemEvent(
|
|||||||
isMine = isMine,
|
isMine = isMine,
|
||||||
isEditable = isEditable,
|
isEditable = isEditable,
|
||||||
canBeRepliedTo = canBeRepliedTo,
|
canBeRepliedTo = canBeRepliedTo,
|
||||||
senderProfile = aProfileTimelineDetailsReady(
|
senderProfile = aProfileDetailsReady(
|
||||||
displayName = senderDisplayName,
|
displayName = senderDisplayName,
|
||||||
displayNameAmbiguous = displayNameAmbiguous,
|
displayNameAmbiguous = displayNameAmbiguous,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,10 +8,8 @@
|
|||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
@@ -21,31 +19,22 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemLocationView(
|
fun TimelineItemLocationView(
|
||||||
content: TimelineItemLocationContent,
|
content: TimelineItemLocationContent,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
StaticMapView(
|
||||||
content.description?.let {
|
modifier = modifier
|
||||||
Text(
|
.fillMaxWidth()
|
||||||
text = it,
|
.heightIn(max = 188.dp),
|
||||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
pinVariant = content.pinVariant,
|
||||||
)
|
lat = content.location.lat,
|
||||||
}
|
lon = content.location.lon,
|
||||||
|
zoom = 15.0,
|
||||||
StaticMapView(
|
contentDescription = content.body
|
||||||
modifier = Modifier
|
)
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(max = 188.dp),
|
|
||||||
lat = content.location.lat,
|
|
||||||
lon = content.location.lon,
|
|
||||||
zoom = 15.0,
|
|
||||||
contentDescription = content.body
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
package io.element.android.features.messages.impl.timeline.factories.event
|
package io.element.android.features.messages.impl.timeline.factories.event
|
||||||
|
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
@@ -22,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||||
@@ -70,10 +73,10 @@ class TimelineItemContentFactory(
|
|||||||
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
|
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
|
||||||
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
|
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
|
||||||
is MessageContent -> {
|
is MessageContent -> {
|
||||||
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender)
|
|
||||||
messageFactory.create(
|
messageFactory.create(
|
||||||
|
senderId = sender,
|
||||||
|
senderProfile = senderProfile,
|
||||||
content = itemContent,
|
content = itemContent,
|
||||||
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -96,6 +99,24 @@ class TimelineItemContentFactory(
|
|||||||
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
||||||
is CallNotifyContent -> TimelineItemRtcNotificationContent()
|
is CallNotifyContent -> TimelineItemRtcNotificationContent()
|
||||||
is UnknownContent -> TimelineItemUnknownContent
|
is UnknownContent -> TimelineItemUnknownContent
|
||||||
|
is LiveLocationContent -> {
|
||||||
|
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
|
||||||
|
Location.fromGeoUri(beacon.geoUri)
|
||||||
|
}.lastOrNull()
|
||||||
|
if (lastKnownLocation != null) {
|
||||||
|
TimelineItemLocationContent(
|
||||||
|
body = itemContent.body.trimEnd(),
|
||||||
|
description = itemContent.description?.trimEnd(),
|
||||||
|
assetType = itemContent.assetType,
|
||||||
|
senderId = sender,
|
||||||
|
senderProfile = senderProfile,
|
||||||
|
location = lastKnownLocation,
|
||||||
|
mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TimelineItemUnknownContent
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
|||||||
import io.element.android.libraries.androidutils.text.safeLinkify
|
import io.element.android.libraries.androidutils.text.safeLinkify
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||||
@@ -39,10 +40,12 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||||
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
|
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
|
||||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
@@ -65,11 +68,13 @@ class TimelineItemContentMessageFactory(
|
|||||||
) {
|
) {
|
||||||
fun create(
|
fun create(
|
||||||
content: MessageContent,
|
content: MessageContent,
|
||||||
senderDisambiguatedDisplayName: String,
|
senderId: UserId,
|
||||||
|
senderProfile: ProfileDetails,
|
||||||
eventId: EventId?,
|
eventId: EventId?,
|
||||||
): TimelineItemEventContent {
|
): TimelineItemEventContent {
|
||||||
return when (val messageType = content.type) {
|
return when (val messageType = content.type) {
|
||||||
is EmoteMessageType -> {
|
is EmoteMessageType -> {
|
||||||
|
val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(senderId)
|
||||||
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
|
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
|
||||||
val dom = messageType.formatted?.toHtmlDocument(
|
val dom = messageType.formatted?.toHtmlDocument(
|
||||||
permalinkParser = permalinkParser,
|
permalinkParser = permalinkParser,
|
||||||
@@ -135,8 +140,8 @@ class TimelineItemContentMessageFactory(
|
|||||||
}
|
}
|
||||||
is LocationMessageType -> {
|
is LocationMessageType -> {
|
||||||
val location = Location.fromGeoUri(messageType.geoUri)
|
val location = Location.fromGeoUri(messageType.geoUri)
|
||||||
|
val body = messageType.body.trimEnd()
|
||||||
if (location == null) {
|
if (location == null) {
|
||||||
val body = messageType.body.trimEnd()
|
|
||||||
TimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
body = body,
|
body = body,
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
@@ -145,9 +150,13 @@ class TimelineItemContentMessageFactory(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
TimelineItemLocationContent(
|
TimelineItemLocationContent(
|
||||||
body = messageType.body.trimEnd(),
|
body = body,
|
||||||
location = location,
|
location = location,
|
||||||
description = messageType.description
|
description = messageType.description,
|
||||||
|
senderId = senderId,
|
||||||
|
senderProfile = senderProfile,
|
||||||
|
assetType = messageType.assetType,
|
||||||
|
mode = TimelineItemLocationContent.Mode.Static
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyCon
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||||
@@ -81,7 +82,8 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
|
|||||||
RedactedContent,
|
RedactedContent,
|
||||||
is StickerContent,
|
is StickerContent,
|
||||||
is PollContent,
|
is PollContent,
|
||||||
is UnableToDecryptContent -> true
|
is UnableToDecryptContent,
|
||||||
|
is LiveLocationContent -> true
|
||||||
// Can't be grouped
|
// Can't be grouped
|
||||||
is FailedToParseStateContent,
|
is FailedToParseStateContent,
|
||||||
is ProfileChangeContent,
|
is ProfileChangeContent,
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||||||
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
|
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
|
||||||
aTimelineItemVoiceContent(),
|
aTimelineItemVoiceContent(),
|
||||||
aTimelineItemLocationContent(),
|
aTimelineItemLocationContent(),
|
||||||
aTimelineItemLocationContent("Location description"),
|
|
||||||
aTimelineItemPollContent(),
|
aTimelineItemPollContent(),
|
||||||
aTimelineItemNoticeContent(),
|
aTimelineItemNoticeContent(),
|
||||||
aTimelineItemRedactedContent(),
|
aTimelineItemRedactedContent(),
|
||||||
aTimelineItemTextContent(),
|
aTimelineItemTextContent(),
|
||||||
aTimelineItemUnknownContent(),
|
aTimelineItemUnknownContent(),
|
||||||
aTimelineItemTextContent().copy(isEdited = true),
|
aTimelineItemTextContent().copy(isEdited = true),
|
||||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT)
|
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
||||||
|
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,53 @@
|
|||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
||||||
|
|
||||||
data class TimelineItemLocationContent(
|
data class TimelineItemLocationContent(
|
||||||
val body: String,
|
val body: String,
|
||||||
|
val senderId: UserId,
|
||||||
|
val senderProfile: ProfileDetails,
|
||||||
val location: Location,
|
val location: Location,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
val assetType: AssetType? = null,
|
||||||
|
val mode: Mode,
|
||||||
) : TimelineItemEventContent {
|
) : TimelineItemEventContent {
|
||||||
|
val pinVariant = when (mode) {
|
||||||
|
is Mode.Live -> {
|
||||||
|
if (mode.isActive) {
|
||||||
|
PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true)
|
||||||
|
} else {
|
||||||
|
PinVariant.StaleLocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode.Static -> {
|
||||||
|
when (assetType) {
|
||||||
|
AssetType.PIN -> PinVariant.PinnedLocation
|
||||||
|
AssetType.SENDER,
|
||||||
|
AssetType.UNKNOWN,
|
||||||
|
null -> PinVariant.UserLocation(avatarData = senderAvatar(), isLive = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun senderAvatar() = AvatarData(
|
||||||
|
senderId.value,
|
||||||
|
name = senderProfile.getDisplayName(),
|
||||||
|
url = senderProfile.getAvatarUrl(),
|
||||||
|
size = AvatarSize.LocationPin
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Mode {
|
||||||
|
data object Static : Mode
|
||||||
|
data class Live(val isActive: Boolean) : Mode
|
||||||
|
}
|
||||||
|
|
||||||
override val type: String = "TimelineItemLocationContent"
|
override val type: String = "TimelineItemLocationContent"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,21 +10,32 @@ package io.element.android.features.messages.impl.timeline.model.event
|
|||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||||
|
|
||||||
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
|
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
|
||||||
override val values: Sequence<TimelineItemLocationContent>
|
override val values: Sequence<TimelineItemLocationContent>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aTimelineItemLocationContent(),
|
aTimelineItemLocationContent(),
|
||||||
aTimelineItemLocationContent("This is a description!"),
|
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
|
||||||
|
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLocationContent(
|
fun aTimelineItemLocationContent(
|
||||||
body = "User location geo:52.2445,0.7186;u=5000",
|
body: String = "",
|
||||||
|
senderId: UserId = UserId("@sender:matrix.org"),
|
||||||
|
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||||
|
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static,
|
||||||
|
) = TimelineItemLocationContent(
|
||||||
|
body = body,
|
||||||
location = Location(
|
location = Location(
|
||||||
lat = 52.2445,
|
lat = 52.2445,
|
||||||
lon = 0.7186,
|
lon = 0.7186,
|
||||||
accuracy = 5000f,
|
accuracy = 5000f,
|
||||||
),
|
),
|
||||||
description = description,
|
senderId = senderId,
|
||||||
|
senderProfile = senderProfile,
|
||||||
|
mode = mode
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import io.element.android.features.call.test.FakeElementCallEntryPoint
|
|||||||
import io.element.android.features.forward.test.FakeForwardEntryPoint
|
import io.element.android.features.forward.test.FakeForwardEntryPoint
|
||||||
import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint
|
import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint
|
||||||
import io.element.android.features.location.test.FakeLocationService
|
import io.element.android.features.location.test.FakeLocationService
|
||||||
import io.element.android.features.location.test.FakeSendLocationEntryPoint
|
import io.element.android.features.location.test.FakeShareLocationEntryPoint
|
||||||
import io.element.android.features.location.test.FakeShowLocationEntryPoint
|
import io.element.android.features.location.test.FakeShowLocationEntryPoint
|
||||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||||
import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider
|
import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider
|
||||||
@@ -62,7 +62,7 @@ class DefaultMessagesEntryPointTest {
|
|||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
roomListService = FakeRoomListService(),
|
roomListService = FakeRoomListService(),
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
sendLocationEntryPoint = FakeSendLocationEntryPoint(),
|
shareLocationEntryPoint = FakeShareLocationEntryPoint(),
|
||||||
showLocationEntryPoint = FakeShowLocationEntryPoint(),
|
showLocationEntryPoint = FakeShowLocationEntryPoint(),
|
||||||
createPollEntryPoint = FakeCreatePollEntryPoint(),
|
createPollEntryPoint = FakeCreatePollEntryPoint(),
|
||||||
elementCallEntryPoint = FakeElementCallEntryPoint(),
|
elementCallEntryPoint = FakeElementCallEntryPoint(),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.core.FakeSendHandle
|
import io.element.android.libraries.matrix.test.core.FakeSendHandle
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady
|
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
internal fun aMessageEvent(
|
internal fun aMessageEvent(
|
||||||
@@ -52,7 +52,7 @@ internal fun aMessageEvent(
|
|||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
transactionId = transactionId,
|
transactionId = transactionId,
|
||||||
senderId = A_USER_ID,
|
senderId = A_USER_ID,
|
||||||
senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME),
|
senderProfile = aProfileDetailsReady(displayName = A_USER_NAME),
|
||||||
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender),
|
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender),
|
||||||
content = content,
|
content = content,
|
||||||
sentTime = "",
|
sentTime = "",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
|||||||
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
|
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
|
||||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||||
@@ -59,8 +60,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.aProfileDetails
|
||||||
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
||||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||||
@@ -83,7 +86,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")),
|
content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemTextContent(
|
val expected = TimelineItemTextContent(
|
||||||
@@ -98,15 +102,21 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `test create LocationMessageType not null`() = runTest {
|
fun `test create LocationMessageType not null`() = runTest {
|
||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
|
val assetType = AssetType.SENDER
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")),
|
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description", assetType)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemLocationContent(
|
val expected = TimelineItemLocationContent(
|
||||||
body = "body",
|
body = "body",
|
||||||
location = Location(lat = 1.0, lon = 2.0, accuracy = 0.0F),
|
location = Location(lat = 1.0, lon = 2.0, accuracy = null),
|
||||||
description = "description",
|
description = "description",
|
||||||
|
assetType = assetType,
|
||||||
|
mode = TimelineItemLocationContent.Mode.Static,
|
||||||
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
@@ -115,8 +125,9 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
fun `test create LocationMessageType null`() = runTest {
|
fun `test create LocationMessageType null`() = runTest {
|
||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = LocationMessageType("body", "", null)),
|
content = createMessageContent(type = LocationMessageType("body", "", null, null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemTextContent(
|
val expected = TimelineItemTextContent(
|
||||||
@@ -133,7 +144,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = TextMessageType("body", null)),
|
content = createMessageContent(type = TextMessageType("body", null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemTextContent(
|
val expected = TimelineItemTextContent(
|
||||||
@@ -150,7 +162,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = TextMessageType("https://www.example.org", null)),
|
content = createMessageContent(type = TextMessageType("https://www.example.org", null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
) as TimelineItemTextContent
|
) as TimelineItemTextContent
|
||||||
val expected = TimelineItemTextContent(
|
val expected = TimelineItemTextContent(
|
||||||
@@ -197,7 +210,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
|
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
|
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
|
||||||
@@ -215,7 +229,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
|
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
|
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
|
||||||
@@ -226,7 +241,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = VideoMessageType("filename", null, null, MediaSource("url"), null)),
|
content = createMessageContent(type = VideoMessageType("filename", null, null, MediaSource("url"), null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVideoContent(
|
val expected = TimelineItemVideoContent(
|
||||||
@@ -279,7 +295,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
),
|
),
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVideoContent(
|
val expected = TimelineItemVideoContent(
|
||||||
@@ -309,7 +326,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = AudioMessageType("filename", null, null, MediaSource("url"), null)),
|
content = createMessageContent(type = AudioMessageType("filename", null, null, MediaSource("url"), null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemAudioContent(
|
val expected = TimelineItemAudioContent(
|
||||||
@@ -345,7 +363,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
),
|
),
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemAudioContent(
|
val expected = TimelineItemAudioContent(
|
||||||
@@ -368,7 +387,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = VoiceMessageType("filename", null, null, MediaSource("url"), null, null)),
|
content = createMessageContent(type = VoiceMessageType("filename", null, null, MediaSource("url"), null, null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVoiceContent(
|
val expected = TimelineItemVoiceContent(
|
||||||
@@ -410,7 +430,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
),
|
),
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemVoiceContent(
|
val expected = TimelineItemVoiceContent(
|
||||||
@@ -435,7 +456,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = ImageMessageType("filename", "body", null, MediaSource("url"), null)),
|
content = createMessageContent(type = ImageMessageType("filename", "body", null, MediaSource("url"), null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemImageContent(
|
val expected = TimelineItemImageContent(
|
||||||
@@ -515,7 +537,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
),
|
),
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemImageContent(
|
val expected = TimelineItemImageContent(
|
||||||
@@ -544,7 +567,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = FileMessageType("filename", null, null, MediaSource("url"), null)),
|
content = createMessageContent(type = FileMessageType("filename", null, null, MediaSource("url"), null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemFileContent(
|
val expected = TimelineItemFileContent(
|
||||||
@@ -586,7 +610,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
),
|
),
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemFileContent(
|
val expected = TimelineItemFileContent(
|
||||||
@@ -609,7 +634,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = NoticeMessageType("body", null)),
|
content = createMessageContent(type = NoticeMessageType("body", null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemNoticeContent(
|
val expected = TimelineItemNoticeContent(
|
||||||
@@ -631,7 +657,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
(result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted"))
|
(result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted"))
|
||||||
@@ -642,7 +669,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
val sut = createTimelineItemContentMessageFactory()
|
val sut = createTimelineItemContentMessageFactory()
|
||||||
val result = sut.create(
|
val result = sut.create(
|
||||||
content = createMessageContent(type = EmoteMessageType("body", null)),
|
content = createMessageContent(type = EmoteMessageType("body", null)),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails("Bob"),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemEmoteContent(
|
val expected = TimelineItemEmoteContent(
|
||||||
@@ -664,7 +692,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails("Bob"),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -690,7 +719,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
formatted = FormattedBody(MessageFormat.HTML, "Test <a href=\"https://www.example.org\">me@matrix.org</a>")
|
formatted = FormattedBody(MessageFormat.HTML, "Test <a href=\"https://www.example.org\">me@matrix.org</a>")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
(result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
|
(result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
|
||||||
@@ -715,7 +745,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
|
formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
(result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
|
(result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
|
||||||
@@ -741,7 +772,8 @@ class TimelineItemContentMessageFactoryTest {
|
|||||||
formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
|
formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
senderDisambiguatedDisplayName = "Bob",
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetails(),
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen
|
|||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.core.FakeSendHandle
|
import io.element.android.libraries.matrix.test.core.FakeSendHandle
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady
|
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ class TimelineItemGrouperTest {
|
|||||||
id = UniqueId("0"),
|
id = UniqueId("0"),
|
||||||
senderId = A_USER_ID,
|
senderId = A_USER_ID,
|
||||||
senderAvatar = anAvatarData(),
|
senderAvatar = anAvatarData(),
|
||||||
senderProfile = aProfileTimelineDetailsReady(displayName = ""),
|
senderProfile = aProfileDetailsReady(displayName = ""),
|
||||||
content = TimelineItemStateEventContent(body = "a state event"),
|
content = TimelineItemStateEventContent(body = "a state event"),
|
||||||
reactionsState = aTimelineItemReactions(count = 0),
|
reactionsState = aTimelineItemReactions(count = 0),
|
||||||
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
|
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "
|
|||||||
statemachine = "com.freeletics.flowredux:compose:1.2.2"
|
statemachine = "com.freeletics.flowredux:compose:1.2.2"
|
||||||
maplibre = "org.maplibre.gl:android-sdk:13.0.1"
|
maplibre = "org.maplibre.gl:android-sdk:13.0.1"
|
||||||
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
|
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
|
||||||
|
maplibre_compose = "org.maplibre.compose:maplibre-compose:0.12.1"
|
||||||
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
|
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
|
||||||
opusencoder = "io.element.android:opusencoder:1.2.0"
|
opusencoder = "io.element.android:opusencoder:1.2.0"
|
||||||
zxing_cpp = "io.github.zxing-cpp:android:3.0.2"
|
zxing_cpp = "io.github.zxing-cpp:android:3.0.2"
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ package io.element.android.libraries.dateformatter.api
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a duration in a localized, human-readable way.
|
||||||
|
* Uses the largest appropriate unit (hours, minutes, or seconds).
|
||||||
|
*
|
||||||
|
* Examples (in English):
|
||||||
|
* - 2 hours 30 minutes → "3 hours" (rounded)
|
||||||
|
* - 45 minutes → "45 minutes"
|
||||||
|
* - 30 seconds → "30 seconds"
|
||||||
|
*/
|
||||||
|
interface DurationFormatter {
|
||||||
|
fun format(duration: Duration): String
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert milliseconds to human readable duration.
|
* Convert milliseconds to human readable duration.
|
||||||
* Hours in 1 digit or more.
|
* Hours in 1 digit or more.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.dateformatter.impl
|
||||||
|
|
||||||
|
import android.icu.text.MeasureFormat
|
||||||
|
import android.icu.text.MeasureFormat.FormatWidth
|
||||||
|
import android.icu.util.Measure
|
||||||
|
import android.icu.util.MeasureUnit
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import dev.zacsweers.metro.binding
|
||||||
|
import io.element.android.libraries.dateformatter.api.DurationFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats durations in a localized, human-readable way using Android's MeasureFormat.
|
||||||
|
*
|
||||||
|
* Uses WIDE format for readability (e.g., "5 hours", "3 minutes", "10 seconds").
|
||||||
|
* Rounds to the nearest unit for cleaner display.
|
||||||
|
*/
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
@ContributesBinding(AppScope::class, binding = binding<DurationFormatter>())
|
||||||
|
class DefaultDurationFormatter(
|
||||||
|
localeChangeObserver: LocaleChangeObserver,
|
||||||
|
locale: Locale,
|
||||||
|
) : DurationFormatter, LocaleChangeListener {
|
||||||
|
init {
|
||||||
|
localeChangeObserver.addListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache formatter, recreate only on locale change
|
||||||
|
private var formatter: MeasureFormat = MeasureFormat.getInstance(locale, FormatWidth.WIDE)
|
||||||
|
|
||||||
|
override fun onLocaleChange() {
|
||||||
|
formatter = MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun format(duration: Duration): String {
|
||||||
|
val millis = duration.inWholeMilliseconds
|
||||||
|
|
||||||
|
return when {
|
||||||
|
duration >= 1.hours -> {
|
||||||
|
// Round to nearest hour (add 30 minutes before dividing)
|
||||||
|
val hours = ((millis + 30 * DateUtils.MINUTE_IN_MILLIS) / DateUtils.HOUR_IN_MILLIS).toInt()
|
||||||
|
formatter.format(Measure(hours, MeasureUnit.HOUR))
|
||||||
|
}
|
||||||
|
duration >= 1.minutes -> {
|
||||||
|
// Round to nearest minute (add 30 seconds before dividing)
|
||||||
|
val minutes = ((millis + 30 * DateUtils.SECOND_IN_MILLIS) / DateUtils.MINUTE_IN_MILLIS).toInt()
|
||||||
|
formatter.format(Measure(minutes, MeasureUnit.MINUTE))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Round to nearest second (add 500ms before dividing)
|
||||||
|
val seconds = ((millis + 500) / DateUtils.SECOND_IN_MILLIS).toInt()
|
||||||
|
formatter.format(Measure(seconds, MeasureUnit.SECOND))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.dateformatter.impl
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@Config(qualifiers = "en", sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||||
|
class DefaultDurationFormatterTest {
|
||||||
|
private fun createDurationFormatter(): DefaultDurationFormatter {
|
||||||
|
return DefaultDurationFormatter(
|
||||||
|
localeChangeObserver = {},
|
||||||
|
locale = Locale.US,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test zero duration`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(0.seconds)).isEqualTo("0 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 1 second`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(1.seconds)).isEqualTo("1 second")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 30 seconds`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(30.seconds)).isEqualTo("30 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 59 seconds`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(59.seconds)).isEqualTo("59 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 1 minute`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(1.minutes)).isEqualTo("1 minute")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 1 minute 29 seconds rounds to 1 minute`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(1.minutes + 29.seconds)).isEqualTo("1 minute")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 1 minute 30 seconds rounds to 2 minutes`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(1.minutes + 30.seconds)).isEqualTo("2 minutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 45 minutes`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(45.minutes)).isEqualTo("45 minutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 59 minutes`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(59.minutes)).isEqualTo("59 minutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 1 hour`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(1.hours)).isEqualTo("1 hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 1 hour 29 minutes rounds to 1 hour`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(1.hours + 29.minutes)).isEqualTo("1 hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 1 hour 30 minutes rounds to 2 hours`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(1.hours + 30.minutes)).isEqualTo("2 hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 2 hours 30 minutes rounds to 3 hours`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(2.hours + 30.minutes)).isEqualTo("3 hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 5 hours`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(5.hours)).isEqualTo("5 hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test 24 hours`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(24.hours)).isEqualTo("24 hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test rounding at seconds threshold - 499ms rounds to 0 seconds`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(499.milliseconds)).isEqualTo("0 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test rounding at seconds threshold - 500ms rounds to 1 second`() {
|
||||||
|
val formatter = createDurationFormatter()
|
||||||
|
assertThat(formatter.format(500.milliseconds)).isEqualTo("1 second")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.dateformatter.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.dateformatter.api.DurationFormatter
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class FakeDurationFormatter(
|
||||||
|
private val formatLambda: (Duration) -> String = { it.toString() },
|
||||||
|
) : DurationFormatter {
|
||||||
|
override fun format(duration: Duration): String {
|
||||||
|
return formatLambda(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.RectF
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.graphics.withSave
|
||||||
|
import coil3.Image
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.asImage
|
||||||
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.allowHardware
|
||||||
|
import coil3.toBitmap
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
||||||
|
private val PIN_WIDTH = 42.dp
|
||||||
|
private val PIN_HEIGHT = PIN_WIDTH * 1.2f
|
||||||
|
private val AVATAR_SIZE = PIN_WIDTH - 10.dp
|
||||||
|
private val CONTENT_OFFSET = 5.dp
|
||||||
|
private val DOT_RADIUS = 6.dp
|
||||||
|
private val STROKE_WIDTH = 1.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variants of location pin markers.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
sealed interface PinVariant {
|
||||||
|
data class UserLocation(
|
||||||
|
val avatarData: AvatarData,
|
||||||
|
val isLive: Boolean,
|
||||||
|
) : PinVariant
|
||||||
|
|
||||||
|
data object PinnedLocation : PinVariant
|
||||||
|
data object StaleLocation : PinVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A location pin composable that supports multiple variants.
|
||||||
|
*
|
||||||
|
* Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LocationPin(
|
||||||
|
variant: PinVariant,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val image = rememberLocationPinBitmap(variant)
|
||||||
|
Canvas(modifier = modifier.size(PIN_WIDTH, PIN_HEIGHT)) {
|
||||||
|
if (image != null) {
|
||||||
|
drawImage(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a location pin to an [ImageBitmap] using Canvas operations.
|
||||||
|
* @param variant The pin variant to render
|
||||||
|
* @return The rendered [ImageBitmap], or null if still loading
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val imageLoader = SingletonImageLoader.get(context)
|
||||||
|
val colors = pinColors(variant)
|
||||||
|
val cacheKey = rememberCacheKey(variant)
|
||||||
|
return produceState<ImageBitmap?>(initialValue = null, cacheKey) {
|
||||||
|
val memoryCacheKey = MemoryCache.Key(cacheKey)
|
||||||
|
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
|
||||||
|
if (cached != null) {
|
||||||
|
value = cached.image.toBitmap().asImageBitmap()
|
||||||
|
} else {
|
||||||
|
val dimensions = PinDimensions(density)
|
||||||
|
val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader)
|
||||||
|
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
|
||||||
|
value = bitmap.asImageBitmap()
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun pinColors(variant: PinVariant): PinColors {
|
||||||
|
return when (variant) {
|
||||||
|
is PinVariant.UserLocation -> {
|
||||||
|
val avatarColors = AvatarColorsProvider.provide(variant.avatarData.id)
|
||||||
|
if (variant.isLive) {
|
||||||
|
PinColors(
|
||||||
|
fill = ElementTheme.colors.iconAccentPrimary,
|
||||||
|
stroke = Color.Transparent,
|
||||||
|
dot = Color.Transparent,
|
||||||
|
avatarStroke = ElementTheme.colors.bgCanvasDefault,
|
||||||
|
avatarBackground = avatarColors.background,
|
||||||
|
avatarForeground = avatarColors.foreground,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PinColors(
|
||||||
|
fill = ElementTheme.colors.bgCanvasDefault,
|
||||||
|
stroke = ElementTheme.colors.iconQuaternaryAlpha,
|
||||||
|
dot = Color.Transparent,
|
||||||
|
avatarStroke = ElementTheme.colors.iconQuaternaryAlpha,
|
||||||
|
avatarBackground = avatarColors.background,
|
||||||
|
avatarForeground = avatarColors.foreground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PinVariant.PinnedLocation -> PinColors(
|
||||||
|
fill = ElementTheme.colors.bgCanvasDefault,
|
||||||
|
stroke = ElementTheme.colors.iconSecondaryAlpha,
|
||||||
|
avatarStroke = Color.Transparent,
|
||||||
|
avatarBackground = Color.Transparent,
|
||||||
|
avatarForeground = Color.Transparent,
|
||||||
|
dot = ElementTheme.colors.iconPrimary,
|
||||||
|
)
|
||||||
|
PinVariant.StaleLocation -> PinColors(
|
||||||
|
fill = ElementTheme.colors.bgSubtleSecondary,
|
||||||
|
stroke = ElementTheme.colors.iconDisabled,
|
||||||
|
avatarStroke = Color.Transparent,
|
||||||
|
avatarBackground = Color.Transparent,
|
||||||
|
avatarForeground = Color.Transparent,
|
||||||
|
dot = ElementTheme.colors.iconDisabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color configuration for rendering a location pin.
|
||||||
|
*/
|
||||||
|
private data class PinColors(
|
||||||
|
val fill: Color,
|
||||||
|
val stroke: Color,
|
||||||
|
val dot: Color,
|
||||||
|
val avatarStroke: Color,
|
||||||
|
val avatarBackground: Color,
|
||||||
|
val avatarForeground: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-calculated pixel dimensions for rendering a location pin.
|
||||||
|
*/
|
||||||
|
private class PinDimensions(density: Density) {
|
||||||
|
val pinWidth = with(density) { PIN_WIDTH.toPx() }
|
||||||
|
val pinHeight = with(density) { PIN_HEIGHT.toPx() }
|
||||||
|
val avatarSize: Float = with(density) { AVATAR_SIZE.toPx() }
|
||||||
|
val avatarOffset: Float = with(density) { CONTENT_OFFSET.toPx() }
|
||||||
|
val dotRadius: Float = with(density) { DOT_RADIUS.toPx() }
|
||||||
|
val strokeWidth: Float = with(density) { STROKE_WIDTH.toPx() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders location pins to bitmaps using Canvas operations.
|
||||||
|
* Uses Coil for avatar loading.
|
||||||
|
* Paint objects are shared across all renders.
|
||||||
|
*/
|
||||||
|
private object LocationPinRenderer {
|
||||||
|
// Shared Paint objects to avoid allocations
|
||||||
|
private val fillPaint = Paint().apply {
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
private val strokePaint = Paint().apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
private val textPaint = Paint().apply {
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
isAntiAlias = true
|
||||||
|
isFakeBoldText = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a pin variant to bitmap. Suspending for async avatar loading.
|
||||||
|
*/
|
||||||
|
suspend fun renderPin(
|
||||||
|
variant: PinVariant,
|
||||||
|
colors: PinColors,
|
||||||
|
dimensions: PinDimensions,
|
||||||
|
context: Context,
|
||||||
|
imageLoader: ImageLoader,
|
||||||
|
): Bitmap {
|
||||||
|
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
|
||||||
|
when (variant) {
|
||||||
|
is PinVariant.UserLocation -> {
|
||||||
|
val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
|
||||||
|
canvas.drawAvatar(
|
||||||
|
avatarImage = avatarImage,
|
||||||
|
avatarData = variant.avatarData,
|
||||||
|
borderColor = colors.avatarStroke,
|
||||||
|
backgroundColor = colors.avatarBackground,
|
||||||
|
foregroundColor = colors.avatarForeground,
|
||||||
|
dimensions = dimensions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PinVariant.PinnedLocation,
|
||||||
|
PinVariant.StaleLocation -> canvas.drawDot(colors.dot, dimensions)
|
||||||
|
}
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color, dimensions: PinDimensions) {
|
||||||
|
val path = createPinPath(dimensions)
|
||||||
|
fillPaint.color = fillColor.toArgb()
|
||||||
|
drawPath(path, fillPaint)
|
||||||
|
strokePaint.color = strokeColor.toArgb()
|
||||||
|
strokePaint.strokeWidth = dimensions.strokeWidth
|
||||||
|
drawPath(path, strokePaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the teardrop-shaped pin path to match dimensions.
|
||||||
|
* Based on SVG path with dimensions 40x48 (ratio 1:1.2).
|
||||||
|
*/
|
||||||
|
private fun createPinPath(dimensions: PinDimensions): Path {
|
||||||
|
val svgWidth = 40f
|
||||||
|
val svgHeight = 48f
|
||||||
|
val inset = dimensions.strokeWidth / 2
|
||||||
|
val scaleX = (dimensions.pinWidth - dimensions.strokeWidth) / svgWidth
|
||||||
|
val scaleY = (dimensions.pinHeight - dimensions.strokeWidth) / svgHeight
|
||||||
|
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(20f, 48f)
|
||||||
|
cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f)
|
||||||
|
cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f)
|
||||||
|
cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f)
|
||||||
|
cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f)
|
||||||
|
cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f)
|
||||||
|
cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f)
|
||||||
|
cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f)
|
||||||
|
cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f)
|
||||||
|
cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f)
|
||||||
|
cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f)
|
||||||
|
cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f)
|
||||||
|
cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f)
|
||||||
|
cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f)
|
||||||
|
cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f)
|
||||||
|
cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f)
|
||||||
|
cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
val matrix = Matrix().apply {
|
||||||
|
setScale(scaleX, scaleY)
|
||||||
|
postTranslate(inset, inset)
|
||||||
|
}
|
||||||
|
path.transform(matrix)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadAvatarImage(
|
||||||
|
avatarData: AvatarData,
|
||||||
|
context: Context,
|
||||||
|
imageLoader: ImageLoader,
|
||||||
|
): Image? {
|
||||||
|
val request = ImageRequest.Builder(context)
|
||||||
|
.data(avatarData)
|
||||||
|
// Disable hardware rendering for Canvas
|
||||||
|
.allowHardware(false)
|
||||||
|
.build()
|
||||||
|
return imageLoader.execute(request).image
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Canvas.drawAvatar(
|
||||||
|
avatarImage: Image?,
|
||||||
|
avatarData: AvatarData,
|
||||||
|
borderColor: Color,
|
||||||
|
backgroundColor: Color,
|
||||||
|
foregroundColor: Color,
|
||||||
|
dimensions: PinDimensions,
|
||||||
|
) {
|
||||||
|
val centerX = dimensions.pinWidth / 2
|
||||||
|
val avatarY = dimensions.avatarOffset
|
||||||
|
val avatarRadius = dimensions.avatarSize / 2
|
||||||
|
|
||||||
|
withSave {
|
||||||
|
if (avatarImage != null) {
|
||||||
|
val bitmap = avatarImage.toBitmap()
|
||||||
|
// Calculate centered square crop (ContentScale.Crop behavior)
|
||||||
|
val srcSize = minOf(bitmap.width, bitmap.height)
|
||||||
|
val srcX = (bitmap.width - srcSize) / 2
|
||||||
|
val srcY = (bitmap.height - srcSize) / 2
|
||||||
|
val srcRect = Rect(srcX, srcY, srcX + srcSize, srcY + srcSize)
|
||||||
|
val destRect = RectF(
|
||||||
|
centerX - avatarRadius,
|
||||||
|
avatarY,
|
||||||
|
centerX + avatarRadius,
|
||||||
|
avatarY + dimensions.avatarSize
|
||||||
|
)
|
||||||
|
val clipPath = Path().apply {
|
||||||
|
addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW)
|
||||||
|
}
|
||||||
|
clipPath(clipPath)
|
||||||
|
drawBitmap(bitmap, srcRect, destRect, null)
|
||||||
|
} else {
|
||||||
|
drawInitialLetterAvatar(
|
||||||
|
initialLetter = avatarData.initialLetter,
|
||||||
|
centerX = centerX,
|
||||||
|
centerY = avatarY + avatarRadius,
|
||||||
|
radius = avatarRadius,
|
||||||
|
foreground = foregroundColor.toArgb(),
|
||||||
|
background = backgroundColor.toArgb()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strokePaint.color = borderColor.toArgb()
|
||||||
|
strokePaint.strokeWidth = dimensions.strokeWidth
|
||||||
|
drawCircle(centerX, avatarY + avatarRadius, avatarRadius, strokePaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Canvas.drawInitialLetterAvatar(
|
||||||
|
initialLetter: String,
|
||||||
|
centerX: Float,
|
||||||
|
centerY: Float,
|
||||||
|
radius: Float,
|
||||||
|
foreground: Int,
|
||||||
|
background: Int,
|
||||||
|
) {
|
||||||
|
fillPaint.color = background
|
||||||
|
drawCircle(centerX, centerY, radius, fillPaint)
|
||||||
|
textPaint.color = foreground
|
||||||
|
textPaint.textSize = radius * 1.2f
|
||||||
|
val textBounds = Rect()
|
||||||
|
textPaint.getTextBounds(initialLetter, 0, 1, textBounds)
|
||||||
|
val textY = centerY + textBounds.height() / 2f
|
||||||
|
drawText(initialLetter, centerX, textY, textPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Canvas.drawDot(dotColor: Color, dimensions: PinDimensions) {
|
||||||
|
if (dotColor == Color.Transparent) return
|
||||||
|
val centerX = dimensions.pinWidth / 2
|
||||||
|
val centerY = dimensions.avatarOffset + dimensions.avatarSize / 2
|
||||||
|
fillPaint.color = dotColor.toArgb()
|
||||||
|
drawCircle(centerX, centerY, dimensions.dotRadius, fillPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun LocationPinPreview() = ElementPreview {
|
||||||
|
val sampleAvatarData = AvatarData(
|
||||||
|
id = "@alice:matrix.org",
|
||||||
|
name = "Alice",
|
||||||
|
url = null,
|
||||||
|
size = AvatarSize.SelectedUser
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
LocationPin(
|
||||||
|
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false),
|
||||||
|
)
|
||||||
|
LocationPin(
|
||||||
|
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
LocationPin(
|
||||||
|
variant = PinVariant.PinnedLocation,
|
||||||
|
)
|
||||||
|
LocationPin(
|
||||||
|
variant = PinVariant.StaleLocation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberCacheKey(variant: PinVariant): String {
|
||||||
|
val isLightTheme = ElementTheme.isLightTheme
|
||||||
|
val density = LocalDensity.current.density
|
||||||
|
return remember(isLightTheme, density, variant) {
|
||||||
|
val pinVariant = when (variant) {
|
||||||
|
PinVariant.PinnedLocation -> "pin_pinned"
|
||||||
|
PinVariant.StaleLocation -> "pin_stale"
|
||||||
|
is PinVariant.UserLocation -> "pin_user_${variant.avatarData.id}_${variant.isLive}"
|
||||||
|
}
|
||||||
|
"${pinVariant}_{$isLightTheme}_{$density}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
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.unit.dp
|
|
||||||
import io.element.android.compound.theme.ElementTheme
|
|
||||||
import io.element.android.libraries.designsystem.R
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PinIcon(
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.background(ElementTheme.colors.bgSubtlePrimary)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Center)
|
|
||||||
.width(22.dp),
|
|
||||||
resourceId = R.drawable.pin,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.Unspecified,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun PinIconPreview() = ElementPreview {
|
|
||||||
PinIcon()
|
|
||||||
}
|
|
||||||
@@ -75,6 +75,6 @@ enum class AvatarSize(val dp: Dp) {
|
|||||||
SpaceMember(24.dp),
|
SpaceMember(24.dp),
|
||||||
LeaveSpaceRoom(32.dp),
|
LeaveSpaceRoom(32.dp),
|
||||||
SelectParentSpace(32.dp),
|
SelectParentSpace(32.dp),
|
||||||
|
|
||||||
AccountItem(32.dp),
|
AccountItem(32.dp),
|
||||||
|
LocationPin(32.dp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ fun ListDialog(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
applyPaddingToContents: Boolean = true,
|
applyPaddingToContents: Boolean = true,
|
||||||
destructiveSubmit: Boolean = false,
|
destructiveSubmit: Boolean = false,
|
||||||
|
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp),
|
||||||
listItems: LazyListScope.() -> Unit,
|
listItems: LazyListScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
|
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
|
||||||
@@ -67,6 +68,7 @@ fun ListDialog(
|
|||||||
listItems = listItems,
|
listItems = listItems,
|
||||||
applyPaddingToContents = applyPaddingToContents,
|
applyPaddingToContents = applyPaddingToContents,
|
||||||
destructiveSubmit = destructiveSubmit,
|
destructiveSubmit = destructiveSubmit,
|
||||||
|
verticalArrangement = verticalArrangement,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +84,7 @@ private fun ListDialogContent(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
applyPaddingToContents: Boolean,
|
applyPaddingToContents: Boolean,
|
||||||
destructiveSubmit: Boolean,
|
destructiveSubmit: Boolean,
|
||||||
|
verticalArrangement: Arrangement.Vertical,
|
||||||
subtitle: @Composable (() -> Unit)? = null,
|
subtitle: @Composable (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
SimpleAlertDialogContent(
|
SimpleAlertDialogContent(
|
||||||
@@ -99,7 +102,7 @@ private fun ListDialogContent(
|
|||||||
val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp
|
val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.padding(horizontal = horizontalPadding),
|
modifier = Modifier.padding(horizontal = horizontalPadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = verticalArrangement,
|
||||||
) { listItems() }
|
) { listItems() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +129,7 @@ internal fun ListDialogContentPreview() {
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
destructiveSubmit = false,
|
destructiveSubmit = false,
|
||||||
applyPaddingToContents = true,
|
applyPaddingToContents = true,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
||||||
@@ -115,6 +116,10 @@ class DefaultRoomLatestEventFormatter(
|
|||||||
val message = sp.getString(CommonStrings.common_unsupported_event)
|
val message = sp.getString(CommonStrings.common_unsupported_event)
|
||||||
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||||
}
|
}
|
||||||
|
is LiveLocationContent -> {
|
||||||
|
val message = sp.getString(CommonStrings.common_shared_location)
|
||||||
|
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||||
|
}
|
||||||
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
|
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
|
||||||
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
|
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
|
||||||
}?.take(DEFAULT_SAFE_LENGTH)
|
}?.take(DEFAULT_SAFE_LENGTH)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||||
@@ -69,6 +70,7 @@ class DefaultTimelineEventFormatter(
|
|||||||
is MessageContent,
|
is MessageContent,
|
||||||
is FailedToParseMessageLikeContent,
|
is FailedToParseMessageLikeContent,
|
||||||
is FailedToParseStateContent,
|
is FailedToParseStateContent,
|
||||||
|
is LiveLocationContent,
|
||||||
is UnknownContent -> {
|
is UnknownContent -> {
|
||||||
if (buildMeta.isDebuggable) {
|
if (buildMeta.isDebuggable) {
|
||||||
error("You should not use this formatter for this event content: $content")
|
error("You should not use this formatter for this event content: $content")
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
|
|||||||
ImageMessageType(body, null, null, MediaSource("url"), null),
|
ImageMessageType(body, null, null, MediaSource("url"), null),
|
||||||
StickerMessageType(body, null, null, MediaSource("url"), null),
|
StickerMessageType(body, null, null, MediaSource("url"), null),
|
||||||
FileMessageType(body, null, null, MediaSource("url"), null),
|
FileMessageType(body, null, null, MediaSource("url"), null),
|
||||||
LocationMessageType(body, "geo:1,2", null),
|
LocationMessageType(body, "geo:1,2", null, null),
|
||||||
NoticeMessageType(body, null),
|
NoticeMessageType(body, null),
|
||||||
EmoteMessageType(body, null),
|
EmoteMessageType(body, null),
|
||||||
OtherMessageType(msgType = "a_type", body = body),
|
OtherMessageType(msgType = "a_type", body = body),
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class DefaultRoomLatestEventFormatterTest {
|
|||||||
ImageMessageType(body, null, null, MediaSource("url"), null),
|
ImageMessageType(body, null, null, MediaSource("url"), null),
|
||||||
StickerMessageType(body, null, null, MediaSource("url"), null),
|
StickerMessageType(body, null, null, MediaSource("url"), null),
|
||||||
FileMessageType(body, null, null, MediaSource("url"), null),
|
FileMessageType(body, null, null, MediaSource("url"), null),
|
||||||
LocationMessageType(body, "geo:1,2", null),
|
LocationMessageType(body, "geo:1,2", null, null),
|
||||||
NoticeMessageType(body, null),
|
NoticeMessageType(body, null),
|
||||||
EmoteMessageType(body, null),
|
EmoteMessageType(body, null),
|
||||||
OtherMessageType(msgType = "a_type", body = body),
|
OtherMessageType(msgType = "a_type", body = body),
|
||||||
|
|||||||
@@ -147,6 +147,13 @@ enum class FeatureFlags(
|
|||||||
defaultValue = { false },
|
defaultValue = { false },
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
),
|
),
|
||||||
|
LiveLocationSharing(
|
||||||
|
key = "feature.liveLocationSharing",
|
||||||
|
title = "Live location sharing",
|
||||||
|
description = "Allow sharing live location in rooms.",
|
||||||
|
defaultValue = { false },
|
||||||
|
isFinished = false,
|
||||||
|
),
|
||||||
ValidateNetworkWhenSchedulingNotificationFetching(
|
ValidateNetworkWhenSchedulingNotificationFetching(
|
||||||
key = "feature.validate_network_when_scheduling_notification_fetching",
|
key = "feature.validate_network_when_scheduling_notification_fetching",
|
||||||
title = "validate internet connectivity when scheduling notification fetching",
|
title = "validate internet connectivity when scheduling notification fetching",
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2022-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("io.element.android-compose-library")
|
|
||||||
id("kotlin-parcelize")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "io.element.android.libraries.maplibre.compose"
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
explicitApi()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
api(libs.maplibre)
|
|
||||||
api(libs.maplibre.ktx)
|
|
||||||
api(libs.maplibre.annotation)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import org.maplibre.android.location.modes.CameraMode as InternalCameraMode
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
public enum class CameraMode {
|
|
||||||
NONE,
|
|
||||||
NONE_COMPASS,
|
|
||||||
NONE_GPS,
|
|
||||||
TRACKING,
|
|
||||||
TRACKING_COMPASS,
|
|
||||||
TRACKING_GPS,
|
|
||||||
TRACKING_GPS_NORTH;
|
|
||||||
|
|
||||||
@InternalCameraMode.Mode
|
|
||||||
internal fun toInternal(): Int = when (this) {
|
|
||||||
NONE -> InternalCameraMode.NONE
|
|
||||||
NONE_COMPASS -> InternalCameraMode.NONE_COMPASS
|
|
||||||
NONE_GPS -> InternalCameraMode.NONE_GPS
|
|
||||||
TRACKING -> InternalCameraMode.TRACKING
|
|
||||||
TRACKING_COMPASS -> InternalCameraMode.TRACKING_COMPASS
|
|
||||||
TRACKING_GPS -> InternalCameraMode.TRACKING_GPS
|
|
||||||
TRACKING_GPS_NORTH -> InternalCameraMode.TRACKING_GPS_NORTH
|
|
||||||
}
|
|
||||||
|
|
||||||
internal companion object {
|
|
||||||
fun fromInternal(@InternalCameraMode.Mode mode: Int): CameraMode = when (mode) {
|
|
||||||
InternalCameraMode.NONE -> NONE
|
|
||||||
InternalCameraMode.NONE_COMPASS -> NONE_COMPASS
|
|
||||||
InternalCameraMode.NONE_GPS -> NONE_GPS
|
|
||||||
InternalCameraMode.TRACKING -> TRACKING
|
|
||||||
InternalCameraMode.TRACKING_COMPASS -> TRACKING_COMPASS
|
|
||||||
InternalCameraMode.TRACKING_GPS -> TRACKING_GPS
|
|
||||||
InternalCameraMode.TRACKING_GPS_NORTH -> TRACKING_GPS_NORTH
|
|
||||||
else -> error("Unknown camera mode: $mode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_ANIMATION
|
|
||||||
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE
|
|
||||||
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enumerates the different reasons why the map camera started to move.
|
|
||||||
*
|
|
||||||
* Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener.
|
|
||||||
*
|
|
||||||
* [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed.
|
|
||||||
*
|
|
||||||
* [UNKNOWN] is used to represent when an unsupported integer value is provided to [fromInt] - this
|
|
||||||
* may be a new constant value from the Maps SDK that isn't supported by maps-compose yet, in which
|
|
||||||
* case this library should be updated to include a new enum value for that constant.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
public enum class CameraMoveStartedReason(public val value: Int) {
|
|
||||||
UNKNOWN(-2),
|
|
||||||
NO_MOVEMENT_YET(-1),
|
|
||||||
GESTURE(REASON_API_GESTURE),
|
|
||||||
API_ANIMATION(REASON_API_ANIMATION),
|
|
||||||
DEVELOPER_ANIMATION(REASON_DEVELOPER_ANIMATION);
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
/**
|
|
||||||
* Converts from the Maps SDK [org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener]
|
|
||||||
* constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such
|
|
||||||
* [CameraMoveStartedReason] for the given [value].
|
|
||||||
*
|
|
||||||
* See https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener.
|
|
||||||
*/
|
|
||||||
public fun fromInt(value: Int): CameraMoveStartedReason {
|
|
||||||
return values().firstOrNull { it.value == value } ?: return UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import android.location.Location
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.Saver
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import org.maplibre.android.camera.CameraPosition
|
|
||||||
import org.maplibre.android.camera.CameraUpdateFactory
|
|
||||||
import org.maplibre.android.maps.MapLibreMap
|
|
||||||
import org.maplibre.android.maps.Projection
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver].
|
|
||||||
* [init] will be called when the [CameraPositionState] is first created to configure its
|
|
||||||
* initial state.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
public inline fun rememberCameraPositionState(
|
|
||||||
crossinline init: CameraPositionState.() -> Unit = {}
|
|
||||||
): CameraPositionState = rememberSaveable(saver = CameraPositionState.Saver) {
|
|
||||||
CameraPositionState().apply(init)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A state object that can be hoisted to control and observe the map's camera state.
|
|
||||||
* A [CameraPositionState] may only be used by a single [MapLibreMap] composable at a time
|
|
||||||
* as it reflects instance state for a single view of a map.
|
|
||||||
*
|
|
||||||
* @param position the initial camera position
|
|
||||||
* @param cameraMode the initial camera mode
|
|
||||||
*/
|
|
||||||
public class CameraPositionState(
|
|
||||||
position: CameraPosition = CameraPosition.Builder().build(),
|
|
||||||
cameraMode: CameraMode = CameraMode.NONE,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Whether the camera is currently moving or not. This includes any kind of movement:
|
|
||||||
* panning, zooming, or rotation.
|
|
||||||
*/
|
|
||||||
public var isMoving: Boolean by mutableStateOf(false)
|
|
||||||
internal set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The reason for the start of the most recent camera moment, or
|
|
||||||
* [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or
|
|
||||||
* [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK.
|
|
||||||
*/
|
|
||||||
public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf(
|
|
||||||
CameraMoveStartedReason.NO_MOVEMENT_YET
|
|
||||||
)
|
|
||||||
internal set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current [Projection] to be used for converting between screen
|
|
||||||
* coordinates and lat/lng.
|
|
||||||
*/
|
|
||||||
public val projection: Projection?
|
|
||||||
get() = map?.projection
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local source of truth for the current camera position.
|
|
||||||
* While [map] is non-null this reflects the current position of [map] as it changes.
|
|
||||||
* While [map] is null it reflects the last known map position, or the last value set by
|
|
||||||
* explicitly setting [position].
|
|
||||||
*/
|
|
||||||
internal var rawPosition by mutableStateOf(position)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current position of the camera on the map.
|
|
||||||
*/
|
|
||||||
public var position: CameraPosition
|
|
||||||
get() = rawPosition
|
|
||||||
set(value) {
|
|
||||||
synchronized(lock) {
|
|
||||||
val map = map
|
|
||||||
if (map == null) {
|
|
||||||
rawPosition = value
|
|
||||||
} else {
|
|
||||||
map.moveCamera(CameraUpdateFactory.newCameraPosition(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local source of truth for the current camera mode.
|
|
||||||
* While [map] is non-null this reflects the current camera mode as it changes.
|
|
||||||
* While [map] is null it reflects the last known camera mode, or the last value set by
|
|
||||||
* explicitly setting [cameraMode].
|
|
||||||
*/
|
|
||||||
internal var rawCameraMode by mutableStateOf(cameraMode)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current tracking mode of the camera.
|
|
||||||
*/
|
|
||||||
public var cameraMode: CameraMode
|
|
||||||
get() = rawCameraMode
|
|
||||||
set(value) {
|
|
||||||
synchronized(lock) {
|
|
||||||
val map = map
|
|
||||||
if (map == null) {
|
|
||||||
rawCameraMode = value
|
|
||||||
} else {
|
|
||||||
map.locationComponent.cameraMode = value.toInternal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The user's last available location.
|
|
||||||
*/
|
|
||||||
public var location: Location? by mutableStateOf(null)
|
|
||||||
internal set
|
|
||||||
|
|
||||||
// Used to perform side effects thread-safely.
|
|
||||||
// Guards all mutable properties that are not `by mutableStateOf`.
|
|
||||||
private val lock = Unit
|
|
||||||
|
|
||||||
// The map currently associated with this CameraPositionState.
|
|
||||||
// Guarded by `lock`.
|
|
||||||
private var map: MapLibreMap? by mutableStateOf(null)
|
|
||||||
|
|
||||||
// The current map is set and cleared by side effect.
|
|
||||||
// There can be only one associated at a time.
|
|
||||||
internal fun setMap(map: MapLibreMap?) {
|
|
||||||
synchronized(lock) {
|
|
||||||
if (this.map == null && map == null) return
|
|
||||||
if (this.map != null && map != null) {
|
|
||||||
error("CameraPositionState may only be associated with one MapLibreMap at a time")
|
|
||||||
}
|
|
||||||
this.map = map
|
|
||||||
if (map == null) {
|
|
||||||
isMoving = false
|
|
||||||
} else {
|
|
||||||
map.moveCamera(CameraUpdateFactory.newCameraPosition(position))
|
|
||||||
map.locationComponent.cameraMode = cameraMode.toInternal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
/**
|
|
||||||
* The default saver implementation for [CameraPositionState].
|
|
||||||
*/
|
|
||||||
public val Saver: Saver<CameraPositionState, SaveableCameraPositionData> = Saver(
|
|
||||||
save = { SaveableCameraPositionData(it.position, it.cameraMode.toInternal()) },
|
|
||||||
restore = { CameraPositionState(it.position, CameraMode.fromInternal(it.cameraMode)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Provides the [CameraPositionState] used by the map. */
|
|
||||||
internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositionState() }
|
|
||||||
|
|
||||||
/** The current [CameraPositionState] used by the map. */
|
|
||||||
public val currentCameraPositionState: CameraPositionState
|
|
||||||
@[MapLibreMapComposable ReadOnlyComposable Composable]
|
|
||||||
get() = LocalCameraPositionState.current
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
public data class SaveableCameraPositionData(
|
|
||||||
val position: CameraPosition,
|
|
||||||
val cameraMode: Int
|
|
||||||
) : Parcelable
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import org.maplibre.android.style.layers.Property
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
public enum class IconAnchor {
|
|
||||||
CENTER,
|
|
||||||
LEFT,
|
|
||||||
RIGHT,
|
|
||||||
TOP,
|
|
||||||
BOTTOM,
|
|
||||||
TOP_LEFT,
|
|
||||||
TOP_RIGHT,
|
|
||||||
BOTTOM_LEFT,
|
|
||||||
BOTTOM_RIGHT;
|
|
||||||
|
|
||||||
@Property.ICON_ANCHOR
|
|
||||||
internal fun toInternal(): String = when (this) {
|
|
||||||
CENTER -> Property.ICON_ANCHOR_CENTER
|
|
||||||
LEFT -> Property.ICON_ANCHOR_LEFT
|
|
||||||
RIGHT -> Property.ICON_ANCHOR_RIGHT
|
|
||||||
TOP -> Property.ICON_ANCHOR_TOP
|
|
||||||
BOTTOM -> Property.ICON_ANCHOR_BOTTOM
|
|
||||||
TOP_LEFT -> Property.ICON_ANCHOR_TOP_LEFT
|
|
||||||
TOP_RIGHT -> Property.ICON_ANCHOR_TOP_RIGHT
|
|
||||||
BOTTOM_LEFT -> Property.ICON_ANCHOR_BOTTOM_LEFT
|
|
||||||
BOTTOM_RIGHT -> Property.ICON_ANCHOR_BOTTOM_RIGHT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import androidx.compose.runtime.AbstractApplier
|
|
||||||
import org.maplibre.android.maps.MapLibreMap
|
|
||||||
import org.maplibre.android.maps.Style
|
|
||||||
import org.maplibre.android.plugins.annotation.SymbolManager
|
|
||||||
|
|
||||||
internal interface MapNode {
|
|
||||||
fun onAttached() {}
|
|
||||||
fun onRemoved() {}
|
|
||||||
fun onCleared() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private object MapNodeRoot : MapNode
|
|
||||||
|
|
||||||
internal class MapApplier(
|
|
||||||
val map: MapLibreMap,
|
|
||||||
val style: Style,
|
|
||||||
val symbolManager: SymbolManager,
|
|
||||||
) : AbstractApplier<MapNode>(MapNodeRoot) {
|
|
||||||
private val decorations = mutableListOf<MapNode>()
|
|
||||||
|
|
||||||
override fun onClear() {
|
|
||||||
symbolManager.deleteAll()
|
|
||||||
decorations.forEach { it.onCleared() }
|
|
||||||
decorations.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun insertBottomUp(index: Int, instance: MapNode) {
|
|
||||||
decorations.add(index, instance)
|
|
||||||
instance.onAttached()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun insertTopDown(index: Int, instance: MapNode) {
|
|
||||||
// insertBottomUp is preferred
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun move(from: Int, to: Int, count: Int) {
|
|
||||||
decorations.move(from, to, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun remove(index: Int, count: Int) {
|
|
||||||
repeat(count) {
|
|
||||||
decorations[index + it].onRemoved()
|
|
||||||
}
|
|
||||||
decorations.remove(index, count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import android.content.ComponentCallbacks2
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Composition
|
|
||||||
import androidx.compose.runtime.CompositionContext
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCompositionContext
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
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.viewinterop.AndroidView
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import kotlinx.collections.immutable.ImmutableMap
|
|
||||||
import kotlinx.collections.immutable.persistentMapOf
|
|
||||||
import kotlinx.coroutines.awaitCancellation
|
|
||||||
import org.maplibre.android.MapLibre
|
|
||||||
import org.maplibre.android.maps.MapLibreMap
|
|
||||||
import org.maplibre.android.maps.MapView
|
|
||||||
import org.maplibre.android.maps.Style
|
|
||||||
import org.maplibre.android.plugins.annotation.SymbolManager
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A compose container for a MapLibre [MapView].
|
|
||||||
*
|
|
||||||
* Heavily inspired by https://github.com/googlemaps/android-maps-compose
|
|
||||||
*
|
|
||||||
* @param styleUri a URI where to asynchronously fetch a style for the map
|
|
||||||
* @param modifier Modifier to be applied to the MapLibreMap
|
|
||||||
* @param images images added to the map's style to be later used with [Symbol]
|
|
||||||
* @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's
|
|
||||||
* camera state
|
|
||||||
* @param uiSettings the [MapUiSettings] to be used for UI-specific settings on the map
|
|
||||||
* @param symbolManagerSettings the [MapSymbolManagerSettings] to be used for symbol manager settings
|
|
||||||
* @param locationSettings the [MapLocationSettings] to be used for location settings
|
|
||||||
* @param content the content of the map
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
public fun MapLibreMap(
|
|
||||||
styleUri: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
images: ImmutableMap<String, Int> = persistentMapOf(),
|
|
||||||
cameraPositionState: CameraPositionState = rememberCameraPositionState(),
|
|
||||||
uiSettings: MapUiSettings = DefaultMapUiSettings,
|
|
||||||
symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings,
|
|
||||||
locationSettings: MapLocationSettings = DefaultMapLocationSettings,
|
|
||||||
content: (@Composable @MapLibreMapComposable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
// 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.background(Color.DarkGray)
|
|
||||||
) {
|
|
||||||
Text("[Map]", modifier = Modifier.align(Alignment.Center))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val mapView = remember {
|
|
||||||
MapLibre.getInstance(context)
|
|
||||||
MapView(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("ModifierReused")
|
|
||||||
AndroidView(modifier = modifier, factory = { mapView })
|
|
||||||
MapLifecycle(mapView)
|
|
||||||
|
|
||||||
// rememberUpdatedState and friends are used here to make these values observable to
|
|
||||||
// the subcomposition without providing a new content function each recomposition
|
|
||||||
val currentCameraPositionState by rememberUpdatedState(cameraPositionState)
|
|
||||||
val currentUiSettings by rememberUpdatedState(uiSettings)
|
|
||||||
val currentMapLocationSettings by rememberUpdatedState(locationSettings)
|
|
||||||
val currentSymbolManagerSettings by rememberUpdatedState(symbolManagerSettings)
|
|
||||||
|
|
||||||
val parentComposition = rememberCompositionContext()
|
|
||||||
val currentContent by rememberUpdatedState(content)
|
|
||||||
|
|
||||||
LaunchedEffect(styleUri, images) {
|
|
||||||
disposingComposition {
|
|
||||||
parentComposition.newComposition(
|
|
||||||
context = context,
|
|
||||||
mapView = mapView,
|
|
||||||
styleUri = styleUri,
|
|
||||||
images = images,
|
|
||||||
) {
|
|
||||||
MapUpdater(
|
|
||||||
cameraPositionState = currentCameraPositionState,
|
|
||||||
uiSettings = currentUiSettings,
|
|
||||||
locationSettings = currentMapLocationSettings,
|
|
||||||
symbolManagerSettings = currentSymbolManagerSettings,
|
|
||||||
)
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalCameraPositionState provides cameraPositionState,
|
|
||||||
) {
|
|
||||||
currentContent?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend inline fun disposingComposition(factory: () -> Composition) {
|
|
||||||
val composition = factory()
|
|
||||||
try {
|
|
||||||
awaitCancellation()
|
|
||||||
} finally {
|
|
||||||
composition.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend inline fun CompositionContext.newComposition(
|
|
||||||
context: Context,
|
|
||||||
mapView: MapView,
|
|
||||||
styleUri: String,
|
|
||||||
images: ImmutableMap<String, Int>,
|
|
||||||
noinline content: @Composable () -> Unit
|
|
||||||
): Composition {
|
|
||||||
val map = mapView.awaitMap()
|
|
||||||
val style = map.awaitStyle(context, styleUri, images)
|
|
||||||
val symbolManager = SymbolManager(mapView, map, style)
|
|
||||||
return Composition(
|
|
||||||
MapApplier(map, style, symbolManager),
|
|
||||||
this
|
|
||||||
).apply {
|
|
||||||
setContent(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend inline fun MapView.awaitMap(): MapLibreMap = suspendCoroutine { continuation ->
|
|
||||||
getMapAsync { map ->
|
|
||||||
continuation.resume(map)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend inline fun MapLibreMap.awaitStyle(
|
|
||||||
context: Context,
|
|
||||||
styleUri: String,
|
|
||||||
images: ImmutableMap<String, Int>,
|
|
||||||
): Style = suspendCoroutine { continuation ->
|
|
||||||
setStyle(
|
|
||||||
Style.Builder().apply {
|
|
||||||
fromUri(styleUri)
|
|
||||||
images.forEach { (id, drawableRes) ->
|
|
||||||
withImage(id, checkNotNull(context.getDrawable(drawableRes)) {
|
|
||||||
"Drawable resource $drawableRes with id $id not found"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { style ->
|
|
||||||
continuation.resume(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers lifecycle observers to the local [MapView].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun MapLifecycle(mapView: MapView) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
|
||||||
val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) }
|
|
||||||
DisposableEffect(context, lifecycle, mapView) {
|
|
||||||
val mapLifecycleObserver = mapView.lifecycleObserver(previousState)
|
|
||||||
val callbacks = mapView.componentCallbacks()
|
|
||||||
|
|
||||||
lifecycle.addObserver(mapLifecycleObserver)
|
|
||||||
context.registerComponentCallbacks(callbacks)
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
lifecycle.removeObserver(mapLifecycleObserver)
|
|
||||||
context.unregisterComponentCallbacks(callbacks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DisposableEffect(mapView) {
|
|
||||||
onDispose {
|
|
||||||
mapView.onDestroy()
|
|
||||||
mapView.removeAllViews()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MapView.lifecycleObserver(previousState: MutableState<Lifecycle.Event>): LifecycleEventObserver =
|
|
||||||
LifecycleEventObserver { _, event ->
|
|
||||||
event.targetState
|
|
||||||
when (event) {
|
|
||||||
Lifecycle.Event.ON_CREATE -> {
|
|
||||||
// Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in
|
|
||||||
// this case the MapLibreMap composable also doesn't leave the composition. So,
|
|
||||||
// recreating the map does not restore state properly which must be avoided.
|
|
||||||
if (previousState.value != Lifecycle.Event.ON_STOP) {
|
|
||||||
this.onCreate(Bundle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Lifecycle.Event.ON_START -> this.onStart()
|
|
||||||
Lifecycle.Event.ON_RESUME -> this.onResume()
|
|
||||||
Lifecycle.Event.ON_PAUSE -> this.onPause()
|
|
||||||
Lifecycle.Event.ON_STOP -> this.onStop()
|
|
||||||
Lifecycle.Event.ON_DESTROY -> {
|
|
||||||
// handled in onDispose
|
|
||||||
}
|
|
||||||
Lifecycle.Event.ON_ANY -> error("ON_ANY should never be used")
|
|
||||||
}
|
|
||||||
previousState.value = event
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MapView.componentCallbacks(): ComponentCallbacks2 =
|
|
||||||
object : ComponentCallbacks2 {
|
|
||||||
override fun onConfigurationChanged(config: Configuration) = Unit
|
|
||||||
|
|
||||||
@Suppress("OVERRIDE_DEPRECATION")
|
|
||||||
override fun onLowMemory() = Unit
|
|
||||||
|
|
||||||
override fun onTrimMemory(level: Int) {
|
|
||||||
// We call the `MapView.onLowMemory` method for any memory trim level
|
|
||||||
this@componentCallbacks.onLowMemory()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import androidx.compose.runtime.ComposableTargetMarker
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An annotation that can be used to mark a composable function as being expected to be use in a
|
|
||||||
* composable function that is also marked or inferred to be marked as a [MapLibreMapComposable].
|
|
||||||
*
|
|
||||||
* This will produce build warnings when [MapLibreMapComposable] composable functions are used outside
|
|
||||||
* of a [MapLibreMapComposable] content lambda, and vice versa.
|
|
||||||
*/
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
@ComposableTargetMarker(description = "MapLibre Map Composable")
|
|
||||||
@Target(
|
|
||||||
AnnotationTarget.FILE,
|
|
||||||
AnnotationTarget.FUNCTION,
|
|
||||||
AnnotationTarget.PROPERTY_GETTER,
|
|
||||||
AnnotationTarget.TYPE,
|
|
||||||
AnnotationTarget.TYPE_PARAMETER,
|
|
||||||
)
|
|
||||||
public annotation class MapLibreMapComposable
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
internal val DefaultMapLocationSettings = MapLocationSettings()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data class for UI-related settings on the map.
|
|
||||||
*
|
|
||||||
* Note: Should not be a data class if in need of maintaining binary compatibility
|
|
||||||
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
|
|
||||||
*/
|
|
||||||
public data class MapLocationSettings(
|
|
||||||
public val locationEnabled: Boolean = false,
|
|
||||||
public val backgroundTintColor: Color = Color.Unspecified,
|
|
||||||
public val foregroundTintColor: Color = Color.Unspecified,
|
|
||||||
public val backgroundStaleTintColor: Color = Color.Unspecified,
|
|
||||||
public val foregroundStaleTintColor: Color = Color.Unspecified,
|
|
||||||
public val accuracyColor: Color = Color.Unspecified,
|
|
||||||
public val pulseEnabled: Boolean = false,
|
|
||||||
public val pulseColor: Color = Color.Unspecified
|
|
||||||
)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
internal val DefaultMapSymbolManagerSettings = MapSymbolManagerSettings()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data class for UI-related settings on the map.
|
|
||||||
*
|
|
||||||
* Note: Should not be a data class if in need of maintaining binary compatibility
|
|
||||||
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
|
|
||||||
*/
|
|
||||||
public data class MapSymbolManagerSettings(
|
|
||||||
public val iconAllowOverlap: Boolean = false,
|
|
||||||
)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import android.view.Gravity
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
internal val DefaultMapUiSettings = MapUiSettings()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data class for UI-related settings on the map.
|
|
||||||
*
|
|
||||||
* Note: Should not be a data class if in need of maintaining binary compatibility
|
|
||||||
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
|
|
||||||
*/
|
|
||||||
public data class MapUiSettings(
|
|
||||||
public val compassEnabled: Boolean = true,
|
|
||||||
public val rotationGesturesEnabled: Boolean = true,
|
|
||||||
public val scrollGesturesEnabled: Boolean = true,
|
|
||||||
public val tiltGesturesEnabled: Boolean = true,
|
|
||||||
public val zoomGesturesEnabled: Boolean = true,
|
|
||||||
public val logoGravity: Int = Gravity.BOTTOM,
|
|
||||||
public val attributionGravity: Int = Gravity.BOTTOM,
|
|
||||||
public val attributionTintColor: Color = Color.Unspecified,
|
|
||||||
)
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
@file:Suppress("MatchingDeclarationName")
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.ComposeNode
|
|
||||||
import androidx.compose.runtime.currentComposer
|
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import org.maplibre.android.location.LocationComponentActivationOptions
|
|
||||||
import org.maplibre.android.location.LocationComponentOptions
|
|
||||||
import org.maplibre.android.location.OnCameraTrackingChangedListener
|
|
||||||
import org.maplibre.android.location.engine.LocationEngineRequest
|
|
||||||
import org.maplibre.android.maps.MapLibreMap
|
|
||||||
import org.maplibre.android.maps.Style
|
|
||||||
|
|
||||||
private const val LOCATION_REQUEST_INTERVAL = 750L
|
|
||||||
|
|
||||||
internal class MapPropertiesNode(
|
|
||||||
val map: MapLibreMap,
|
|
||||||
style: Style,
|
|
||||||
context: Context,
|
|
||||||
cameraPositionState: CameraPositionState,
|
|
||||||
locationSettings: MapLocationSettings,
|
|
||||||
) : MapNode {
|
|
||||||
init {
|
|
||||||
map.locationComponent.activateLocationComponent(
|
|
||||||
LocationComponentActivationOptions.Builder(context, style)
|
|
||||||
.locationComponentOptions(
|
|
||||||
LocationComponentOptions.builder(context)
|
|
||||||
.backgroundTintColor(locationSettings.backgroundTintColor.toArgb())
|
|
||||||
.foregroundTintColor(locationSettings.foregroundTintColor.toArgb())
|
|
||||||
.backgroundStaleTintColor(locationSettings.backgroundStaleTintColor.toArgb())
|
|
||||||
.foregroundStaleTintColor(locationSettings.foregroundStaleTintColor.toArgb())
|
|
||||||
.accuracyColor(locationSettings.accuracyColor.toArgb())
|
|
||||||
.pulseEnabled(locationSettings.pulseEnabled)
|
|
||||||
.pulseColor(locationSettings.pulseColor.toArgb())
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.locationEngineRequest(
|
|
||||||
LocationEngineRequest.Builder(LOCATION_REQUEST_INTERVAL)
|
|
||||||
.setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY)
|
|
||||||
.setFastestInterval(LOCATION_REQUEST_INTERVAL)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
cameraPositionState.setMap(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cameraPositionState = cameraPositionState
|
|
||||||
set(value) {
|
|
||||||
if (value == field) return
|
|
||||||
field.setMap(null)
|
|
||||||
field = value
|
|
||||||
value.setMap(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttached() {
|
|
||||||
map.addOnCameraIdleListener {
|
|
||||||
cameraPositionState.isMoving = false
|
|
||||||
// addOnCameraIdleListener is only invoked when the camera position
|
|
||||||
// is changed via .animate(). To handle updating state when .move()
|
|
||||||
// is used, it's necessary to set the camera's position here as well
|
|
||||||
cameraPositionState.rawPosition = map.cameraPosition
|
|
||||||
// Updating user location on every camera move due to lack of a better location updates API.
|
|
||||||
cameraPositionState.location = map.locationComponent.lastKnownLocation
|
|
||||||
}
|
|
||||||
map.addOnCameraMoveCancelListener {
|
|
||||||
cameraPositionState.isMoving = false
|
|
||||||
}
|
|
||||||
map.addOnCameraMoveStartedListener {
|
|
||||||
cameraPositionState.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(it)
|
|
||||||
cameraPositionState.isMoving = true
|
|
||||||
}
|
|
||||||
map.addOnCameraMoveListener {
|
|
||||||
cameraPositionState.rawPosition = map.cameraPosition
|
|
||||||
// Updating user location on every camera move due to lack of a better location updates API.
|
|
||||||
cameraPositionState.location = map.locationComponent.lastKnownLocation
|
|
||||||
}
|
|
||||||
map.locationComponent.addOnCameraTrackingChangedListener(object : OnCameraTrackingChangedListener {
|
|
||||||
override fun onCameraTrackingDismissed() {}
|
|
||||||
|
|
||||||
override fun onCameraTrackingChanged(currentMode: Int) {
|
|
||||||
cameraPositionState.rawCameraMode = CameraMode.fromInternal(currentMode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRemoved() {
|
|
||||||
cameraPositionState.setMap(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
cameraPositionState.setMap(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used to keep the primary map properties up to date. This should never leave the map composition.
|
|
||||||
*/
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
@Composable
|
|
||||||
internal inline fun MapUpdater(
|
|
||||||
cameraPositionState: CameraPositionState,
|
|
||||||
locationSettings: MapLocationSettings,
|
|
||||||
uiSettings: MapUiSettings,
|
|
||||||
symbolManagerSettings: MapSymbolManagerSettings,
|
|
||||||
) {
|
|
||||||
val mapApplier = currentComposer.applier as MapApplier
|
|
||||||
val map = mapApplier.map
|
|
||||||
val style = mapApplier.style
|
|
||||||
val symbolManager = mapApplier.symbolManager
|
|
||||||
val context = LocalContext.current
|
|
||||||
ComposeNode<MapPropertiesNode, MapApplier>(
|
|
||||||
factory = {
|
|
||||||
MapPropertiesNode(
|
|
||||||
map = map,
|
|
||||||
style = style,
|
|
||||||
context = context,
|
|
||||||
cameraPositionState = cameraPositionState,
|
|
||||||
locationSettings = locationSettings,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
update = {
|
|
||||||
set(locationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it }
|
|
||||||
|
|
||||||
set(uiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it }
|
|
||||||
set(uiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it }
|
|
||||||
set(uiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it }
|
|
||||||
set(uiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it }
|
|
||||||
set(uiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it }
|
|
||||||
set(uiSettings.logoGravity) { map.uiSettings.logoGravity = it }
|
|
||||||
set(uiSettings.attributionGravity) { map.uiSettings.attributionGravity = it }
|
|
||||||
set(uiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) }
|
|
||||||
|
|
||||||
set(symbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it }
|
|
||||||
|
|
||||||
update(cameraPositionState) { this.cameraPositionState = it }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
* Copyright 2021 Google LLC
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.maplibre.compose
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.ComposeNode
|
|
||||||
import androidx.compose.runtime.currentComposer
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.Saver
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import org.maplibre.android.geometry.LatLng
|
|
||||||
import org.maplibre.android.plugins.annotation.Symbol
|
|
||||||
import org.maplibre.android.plugins.annotation.SymbolManager
|
|
||||||
import org.maplibre.android.plugins.annotation.SymbolOptions
|
|
||||||
|
|
||||||
internal class SymbolNode(
|
|
||||||
val symbolManager: SymbolManager,
|
|
||||||
val symbol: Symbol,
|
|
||||||
) : MapNode {
|
|
||||||
override fun onRemoved() {
|
|
||||||
symbolManager.delete(symbol)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
symbolManager.delete(symbol)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A state object that can be hoisted to control and observe the symbol state.
|
|
||||||
*
|
|
||||||
* @param position the initial symbol position
|
|
||||||
*/
|
|
||||||
public class SymbolState(
|
|
||||||
position: LatLng
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Current position of the symbol.
|
|
||||||
*/
|
|
||||||
public var position: LatLng by mutableStateOf(position)
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
/**
|
|
||||||
* The default saver implementation for [SymbolState].
|
|
||||||
*/
|
|
||||||
public val Saver: Saver<SymbolState, LatLng> = Saver(
|
|
||||||
save = { it.position },
|
|
||||||
restore = { SymbolState(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
public fun rememberSymbolState(
|
|
||||||
position: LatLng = LatLng(0.0, 0.0)
|
|
||||||
): SymbolState = rememberSaveable(saver = SymbolState.Saver) {
|
|
||||||
SymbolState(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A composable for a symbol on the map.
|
|
||||||
*
|
|
||||||
* @param iconId an id of an image from the current [Style]
|
|
||||||
* @param state the [SymbolState] to be used to control or observe the symbol
|
|
||||||
* state such as its position and info window
|
|
||||||
* @param iconAnchor the anchor for the symbol image
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
@MapLibreMapComposable
|
|
||||||
public fun Symbol(
|
|
||||||
iconId: String,
|
|
||||||
state: SymbolState = rememberSymbolState(),
|
|
||||||
iconAnchor: IconAnchor? = null,
|
|
||||||
) {
|
|
||||||
val mapApplier = currentComposer.applier as MapApplier
|
|
||||||
val symbolManager = mapApplier.symbolManager
|
|
||||||
ComposeNode<SymbolNode, MapApplier>(
|
|
||||||
factory = {
|
|
||||||
SymbolNode(
|
|
||||||
symbolManager = symbolManager,
|
|
||||||
symbol = symbolManager.create(
|
|
||||||
SymbolOptions().apply {
|
|
||||||
withLatLng(state.position)
|
|
||||||
withIconImage(iconId)
|
|
||||||
iconAnchor?.let { withIconAnchor(it.toInternal()) }
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
update = {
|
|
||||||
update(state.position) {
|
|
||||||
this.symbol.latLng = it
|
|
||||||
symbolManager.update(this.symbol)
|
|
||||||
}
|
|
||||||
update(iconId) {
|
|
||||||
this.symbol.iconImage = it
|
|
||||||
symbolManager.update(this.symbol)
|
|
||||||
}
|
|
||||||
update(iconAnchor) {
|
|
||||||
this.symbol.iconAnchor = it?.toInternal()
|
|
||||||
symbolManager.update(this.symbol)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
|||||||
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||||
@@ -182,4 +183,30 @@ interface JoinedRoom : BaseRoom {
|
|||||||
* Subscribe to a [Flow] of [SendQueueUpdate] related to this room.
|
* Subscribe to a [Flow] of [SendQueueUpdate] related to this room.
|
||||||
*/
|
*/
|
||||||
fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate>
|
fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to live location shares in this room.
|
||||||
|
* @return Flow of list of active live location shares.
|
||||||
|
*/
|
||||||
|
fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start sharing live location in this room.
|
||||||
|
* @param durationMillis How long to share location (in milliseconds).
|
||||||
|
* @return Result indicating success or failure.
|
||||||
|
*/
|
||||||
|
suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop sharing live location in this room.
|
||||||
|
* @return Result indicating success or failure.
|
||||||
|
*/
|
||||||
|
suspend fun stopLiveLocationShare(): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a live location update while a live location share is active.
|
||||||
|
* @param geoUri The geo URI (e.g., "geo:51.5074,-0.1278").
|
||||||
|
* @return Result indicating success or failure.
|
||||||
|
*/
|
||||||
|
suspend fun sendLiveLocation(geoUri: String): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ package io.element.android.libraries.matrix.api.room.location
|
|||||||
|
|
||||||
enum class AssetType {
|
enum class AssetType {
|
||||||
SENDER,
|
SENDER,
|
||||||
PIN
|
PIN,
|
||||||
|
UNKNOWN
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.room.location
|
||||||
|
|
||||||
|
data class LiveLocationInfo(
|
||||||
|
val description: String?,
|
||||||
|
val geoUri: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user