diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt index 188f8129f8..95f5129f91 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt @@ -29,21 +29,18 @@ fun LocationConstraintsDialog( onSubmitClick = onRequestPermissions, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), ) LocationConstraintsDialogState.PermissionDenied -> ConfirmationDialog( content = stringResource(CommonStrings.error_missing_location_auth_android, appName), onSubmitClick = onOpenAppSettings, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), ) LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog( content = stringResource(CommonStrings.error_location_service_disabled_android), onSubmitClick = onOpenLocationSettings, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), ) } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt index e4ecf331f6..6a45ce3def 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt @@ -8,14 +8,8 @@ package io.element.android.features.location.impl.share 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 label: String, -) { - FifteenMinutes(15.minutes, "15 minutes"), - OneHour(1.hours, "1 hour"), - EightHours(8.hours, "8 hours") -} + val formatted: String +) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 56b7b073d9..10fddf1e50 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -34,6 +34,7 @@ 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 @@ -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.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( @@ -56,6 +62,7 @@ class ShareLocationPresenter( private val buildMeta: BuildMeta, private val featureFlagService: FeatureFlagService, private val client: MatrixClient, + private val durationFormatter: DurationFormatter, ) : Presenter { @AssistedFactory fun interface Factory { @@ -105,7 +112,10 @@ class ShareLocationPresenter( ShareLocationEvent.ShowLiveLocationDurationPicker -> { val constraintsResult = checkLocationConstraints(permissionsState, locationActions) 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 { Constraints(constraintsResult.toDialogState()) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 72f94d5b06..8b1f494f1e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -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.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList data class ShareLocationState( val currentUser: MatrixUser, @@ -23,6 +24,6 @@ data class ShareLocationState( sealed interface Dialog { data object None : Dialog data class Constraints(val state: LocationConstraintsDialogState) : Dialog - data object LiveLocationDuration : Dialog + data class LiveLocationDurations(val durations: ImmutableList) : Dialog } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index efc7fdc8a1..facef74346 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -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.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" @@ -49,7 +52,13 @@ class ShareLocationStateProvider : PreviewParameterProvider hasLocationPermission = true, ), 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, hasLocationPermission = true, canShareLiveLocation = true, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index b463d494dc..4651389212 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -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.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 @@ -83,7 +84,8 @@ fun ShareLocationView( onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, ) - ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog( + is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog( + durations = dialogState.durations, onSelectDuration = { duration -> state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration)) context.toast("Not implemented yet!") @@ -252,6 +254,7 @@ private fun ShareLiveLocationItem( @Composable private fun LiveLocationDurationDialog( + durations: ImmutableList, onSelectDuration: (Duration) -> Unit, onDismiss: () -> Unit, ) { @@ -259,14 +262,14 @@ private fun LiveLocationDurationDialog( ListDialog( title = "Choose how long to share your live location.", submitText = stringResource(CommonStrings.action_continue), - onSubmit = { onSelectDuration(LiveLocationDuration.entries[selectedIndex].duration) }, + onSubmit = { onSelectDuration(durations[selectedIndex].duration) }, onDismissRequest = onDismiss, applyPaddingToContents = false, verticalArrangement = Arrangement.Top ) { - itemsIndexed(LiveLocationDuration.entries) { index, duration -> + itemsIndexed(durations) { index, duration -> RadioButtonListItem( - headline = duration.label, + headline = duration.formatted, selected = index == selectedIndex, onSelect = { selectedIndex = index }, compactLayout = true, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index 100e660820..edd000e02c 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -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.permissions.FakePermissionsPresenter 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.test.FakeMatrixClient @@ -46,6 +47,7 @@ class DefaultShareLocationEntryPointTest { buildMeta = aBuildMeta(), featureFlagService = FakeFeatureFlagService(), client = FakeMatrixClient(), + durationFormatter = FakeDurationFormatter(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 125d5036fe..92c27d9f21 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -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.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.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 @@ -54,13 +54,13 @@ class ShareLocationPresenterTest { 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 = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter - }, + permissionsPresenterFactory = { fakePermissionsPresenter }, room = joinedRoom, timelineMode = Timeline.Mode.Live, analyticsService = fakeAnalyticsService, @@ -69,6 +69,7 @@ class ShareLocationPresenterTest { buildMeta = fakeBuildMeta, featureFlagService = fakeFeatureFlagService, client = fakeMatrixClient, + durationFormatter = durationFormatter, ) @Test @@ -306,7 +307,7 @@ class ShareLocationPresenterTest { initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) val durationDialogState = awaitItem() - assertThat(durationDialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDuration) + assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java) cancelAndIgnoreRemainingEvents() } } diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt index 8ecf01c343..ac297d10e9 100644 --- a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt @@ -11,6 +11,19 @@ package io.element.android.libraries.dateformatter.api import java.util.Locale 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. * Hours in 1 digit or more. diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt new file mode 100644 index 0000000000..41a4c66481 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt @@ -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()) +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)) + } + } + } +} diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt new file mode 100644 index 0000000000..3fee5dbb0d --- /dev/null +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt @@ -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") + } +} diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt new file mode 100644 index 0000000000..7b5cf038ce --- /dev/null +++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt @@ -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) + } +}