Use formatter for LLS duration

This commit is contained in:
ganfra
2026-03-24 10:15:25 +01:00
parent abdbc24b47
commit 7581d0ecf2
12 changed files with 275 additions and 24 deletions

View File

@@ -29,21 +29,18 @@ fun LocationConstraintsDialog(
onSubmitClick = onRequestPermissions, onSubmitClick = onRequestPermissions,
onDismiss = onDismiss, onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue), submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
) )
LocationConstraintsDialogState.PermissionDenied -> ConfirmationDialog( LocationConstraintsDialogState.PermissionDenied -> ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_auth_android, appName), content = stringResource(CommonStrings.error_missing_location_auth_android, appName),
onSubmitClick = onOpenAppSettings, onSubmitClick = onOpenAppSettings,
onDismiss = onDismiss, onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue), submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
) )
LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog( LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog(
content = stringResource(CommonStrings.error_location_service_disabled_android), content = stringResource(CommonStrings.error_location_service_disabled_android),
onSubmitClick = onOpenLocationSettings, onSubmitClick = onOpenLocationSettings,
onDismiss = onDismiss, onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue), submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
) )
} }
} }

View File

@@ -8,14 +8,8 @@
package io.element.android.features.location.impl.share package io.element.android.features.location.impl.share
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
enum class LiveLocationDuration( data class LiveLocationDuration(
val duration: Duration, val duration: Duration,
val label: String, val formatted: String
) { )
FifteenMinutes(15.minutes, "15 minutes"),
OneHour(1.hours, "1 hour"),
EightHours(8.hours, "8 hours")
}

View File

@@ -34,6 +34,7 @@ import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@@ -43,7 +44,12 @@ 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.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch 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 @AssistedInject
class ShareLocationPresenter( class ShareLocationPresenter(
@@ -56,6 +62,7 @@ class ShareLocationPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val client: MatrixClient, private val client: MatrixClient,
private val durationFormatter: DurationFormatter,
) : Presenter<ShareLocationState> { ) : Presenter<ShareLocationState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@@ -105,7 +112,10 @@ class ShareLocationPresenter(
ShareLocationEvent.ShowLiveLocationDurationPicker -> { ShareLocationEvent.ShowLiveLocationDurationPicker -> {
val constraintsResult = checkLocationConstraints(permissionsState, locationActions) val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
dialogState = if (constraintsResult is LocationConstraintsCheck.Success) { dialogState = if (constraintsResult is LocationConstraintsCheck.Success) {
ShareLocationState.Dialog.LiveLocationDuration val durations = LIVE_LOCATION_DURATIONS.map {
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
}
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
} else { } else {
Constraints(constraintsResult.toDialogState()) Constraints(constraintsResult.toDialogState())
} }

View File

@@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState( data class ShareLocationState(
val currentUser: MatrixUser, val currentUser: MatrixUser,
@@ -23,6 +24,6 @@ data class ShareLocationState(
sealed interface Dialog { sealed interface Dialog {
data object None : Dialog data object None : Dialog
data class Constraints(val state: LocationConstraintsDialogState) : Dialog data class Constraints(val state: LocationConstraintsDialogState) : Dialog
data object LiveLocationDuration : Dialog data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : Dialog
} }
} }

View File

@@ -12,6 +12,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState 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.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser 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" private const val APP_NAME = "ApplicationName"
@@ -49,7 +52,13 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
hasLocationPermission = true, hasLocationPermission = true,
), ),
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDuration, dialogState = ShareLocationState.Dialog.LiveLocationDurations(
persistentListOf(
LiveLocationDuration(15.minutes, "15 minutes"),
LiveLocationDuration(1.hours, "1 hour"),
LiveLocationDuration(8.hours, "8 hours"),
)
),
trackUserPosition = true, trackUserPosition = true,
hasLocationPermission = true, hasLocationPermission = true,
canShareLiveLocation = true, canShareLiveLocation = true,

View File

@@ -59,6 +59,7 @@ 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.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.camera.rememberCameraState
@@ -83,7 +84,8 @@ fun ShareLocationView(
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) }, onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
) )
ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog( is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
durations = dialogState.durations,
onSelectDuration = { duration -> onSelectDuration = { duration ->
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration)) state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))
context.toast("Not implemented yet!") context.toast("Not implemented yet!")
@@ -252,6 +254,7 @@ private fun ShareLiveLocationItem(
@Composable @Composable
private fun LiveLocationDurationDialog( private fun LiveLocationDurationDialog(
durations: ImmutableList<LiveLocationDuration>,
onSelectDuration: (Duration) -> Unit, onSelectDuration: (Duration) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -259,14 +262,14 @@ private fun LiveLocationDurationDialog(
ListDialog( ListDialog(
title = "Choose how long to share your live location.", title = "Choose how long to share your live location.",
submitText = stringResource(CommonStrings.action_continue), submitText = stringResource(CommonStrings.action_continue),
onSubmit = { onSelectDuration(LiveLocationDuration.entries[selectedIndex].duration) }, onSubmit = { onSelectDuration(durations[selectedIndex].duration) },
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
applyPaddingToContents = false, applyPaddingToContents = false,
verticalArrangement = Arrangement.Top verticalArrangement = Arrangement.Top
) { ) {
itemsIndexed(LiveLocationDuration.entries) { index, duration -> itemsIndexed(durations) { index, duration ->
RadioButtonListItem( RadioButtonListItem(
headline = duration.label, headline = duration.formatted,
selected = index == selectedIndex, selected = index == selectedIndex,
onSelect = { selectedIndex = index }, onSelect = { selectedIndex = index },
compactLayout = true, compactLayout = true,

View File

@@ -14,6 +14,7 @@ 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.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.FakeMatrixClient
@@ -46,6 +47,7 @@ class DefaultShareLocationEntryPointTest {
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
client = FakeMatrixClient(), client = FakeMatrixClient(),
durationFormatter = FakeDurationFormatter(),
) )
}, },
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),

View File

@@ -18,10 +18,10 @@ 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.features.location.impl.common.ui.LocationConstraintsDialogState
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.featureflag.test.FakeFeatureFlagService
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.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -54,13 +54,13 @@ class ShareLocationPresenterTest {
private val fakeFeatureFlagService = FakeFeatureFlagService() private val fakeFeatureFlagService = FakeFeatureFlagService()
private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID) private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
private val durationFormatter = FakeDurationFormatter()
private fun createShareLocationPresenter( private fun createShareLocationPresenter(
joinedRoom: JoinedRoom = FakeJoinedRoom(), joinedRoom: JoinedRoom = FakeJoinedRoom(),
locationActions: FakeLocationActions = fakeLocationActions, locationActions: FakeLocationActions = fakeLocationActions,
): ShareLocationPresenter = ShareLocationPresenter( ): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory { permissionsPresenterFactory = { fakePermissionsPresenter },
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
},
room = joinedRoom, room = joinedRoom,
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
analyticsService = fakeAnalyticsService, analyticsService = fakeAnalyticsService,
@@ -69,6 +69,7 @@ class ShareLocationPresenterTest {
buildMeta = fakeBuildMeta, buildMeta = fakeBuildMeta,
featureFlagService = fakeFeatureFlagService, featureFlagService = fakeFeatureFlagService,
client = fakeMatrixClient, client = fakeMatrixClient,
durationFormatter = durationFormatter,
) )
@Test @Test
@@ -306,7 +307,7 @@ class ShareLocationPresenterTest {
initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
val durationDialogState = awaitItem() val durationDialogState = awaitItem()
assertThat(durationDialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDuration) assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }

View File

@@ -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.

View File

@@ -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))
}
}
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}