Use formatter for LLS duration
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user