diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt
index ccc3e9b30bbd..0a8551f2273c 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt
@@ -15,6 +15,9 @@ import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel
import com.woocommerce.android.ui.bookings.details.CancelStatus
import com.woocommerce.android.ui.bookings.list.BookingListItem
import com.woocommerce.android.util.CurrencyFormatter
+import com.woocommerce.android.util.normalizeDuration
+import com.woocommerce.android.util.toHumanReadableFormat
+import com.woocommerce.android.viewmodel.ResourceProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingCustomerInfo
@@ -29,7 +32,8 @@ import javax.inject.Inject
class BookingMapper @Inject constructor(
private val currencyFormatter: CurrencyFormatter,
- private val getLocations: GetLocations
+ private val getLocations: GetLocations,
+ private val resourceProvider: ResourceProvider
) {
private val summaryDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(
FormatStyle.MEDIUM,
@@ -62,16 +66,18 @@ class BookingMapper @Inject constructor(
staffMemberStatus: BookingStaffMemberStatus?,
cancelStatus: CancelStatus,
): BookingAppointmentDetailsModel {
- val durationMinutes = Duration.between(start, end).toMinutes()
+ val duration = Duration.between(start, end)
+ .normalizeDuration()
+ .toHumanReadableFormat(resourceProvider)
return BookingAppointmentDetailsModel(
date = detailsDateFormatter.format(start),
time = "${timeRangeFormatter.format(start)} - ${timeRangeFormatter.format(end)}",
staff = staffMemberStatus,
// TODO replace mocked values when available from API
location = "238 Willow Creek Drive, Montgomery AL 36109",
- duration = "$durationMinutes min",
price = currencyFormatter.formatCurrency(cost, currency),
cancelStatus = cancelStatus,
+ duration = duration,
)
}
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/DurationExt.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/DurationExt.kt
new file mode 100644
index 000000000000..7cb01c338b55
--- /dev/null
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/DurationExt.kt
@@ -0,0 +1,76 @@
+package com.woocommerce.android.util
+
+import com.woocommerce.android.R
+import com.woocommerce.android.viewmodel.ResourceProvider
+import java.time.Duration
+
+/**
+ * Normalize duration by adjusting for precision issues.
+ *
+ * This function handles cases where a booking duration is very close to
+ * common time boundaries (days/hours) but falls short due to precision issues.
+ * It rounds up durations that are within one minute of these boundaries.
+ */
+fun Duration.normalizeDuration(): Duration {
+ val dayInSeconds = Duration.ofDays(1).seconds
+ val hourInSeconds = Duration.ofHours(1).seconds
+ val minuteInSeconds = Duration.ofMinutes(1).seconds
+
+ var durationInSeconds = this.seconds
+ val boundaries = listOf(dayInSeconds, hourInSeconds)
+ for (boundary in boundaries) {
+ val remainder = durationInSeconds % boundary
+ val difference = if (remainder == 0L) 0L else boundary - remainder
+ if (difference > 0 && difference <= minuteInSeconds) {
+ durationInSeconds += difference
+ }
+ }
+ return Duration.ofSeconds(durationInSeconds)
+}
+
+@Suppress("LongMethod")
+fun Duration.toHumanReadableFormat(resourceProvider: ResourceProvider): String {
+ if (this < Duration.ofMinutes(1)) {
+ return resourceProvider.getQuantityString(
+ quantity = seconds.toInt(),
+ default = R.string.booking_duration_seconds,
+ one = R.string.booking_duration_second
+ )
+ }
+
+ val days = toDays()
+ val hours = minusDays(days).toHours()
+ val minutes = minusDays(days).minusHours(hours).toMinutes()
+
+ return buildString {
+ if (days > 0) {
+ append(
+ resourceProvider.getQuantityString(
+ quantity = days.toInt(),
+ default = R.string.booking_duration_days,
+ one = R.string.booking_duration_day
+ )
+ )
+ }
+ if (hours > 0) {
+ append(" ")
+ append(
+ resourceProvider.getQuantityString(
+ quantity = hours.toInt(),
+ default = R.string.booking_duration_hours,
+ one = R.string.booking_duration_hour
+ )
+ )
+ }
+ if (minutes > 0) {
+ append(" ")
+ append(
+ resourceProvider.getQuantityString(
+ quantity = minutes.toInt(),
+ default = R.string.booking_duration_minutes,
+ one = R.string.booking_duration_minute
+ )
+ )
+ }
+ }.trim()
+}
diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml
index e7e0d642b324..f11172b53a70 100644
--- a/WooCommerce/src/main/res/values/strings.xml
+++ b/WooCommerce/src/main/res/values/strings.xml
@@ -4234,6 +4234,14 @@
Mark as paid
View order
Error fetching booking
+ %1$d minute
+ %1$d minutes
+ %1$d hour
+ %1$d hours
+ %1$d day
+ %1$d days
+ %1$d second
+ %1$d seconds
Use password to sign in
About %1$s
diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt
index ee1081feb77f..c2e0204635fa 100644
--- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt
+++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt
@@ -9,11 +9,13 @@ import com.woocommerce.android.ui.bookings.compose.BookingStatus
import com.woocommerce.android.ui.bookings.details.CancelStatus
import com.woocommerce.android.util.CurrencyFormatter
import com.woocommerce.android.viewmodel.BaseUnitTest
+import com.woocommerce.android.viewmodel.ResourceProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
@@ -42,11 +44,35 @@ class BookingMapperTest : BaseUnitTest() {
}
}
private val getLocations: GetLocations = mock()
+ private val resourceProvider: ResourceProvider = mock {
+ on { getQuantityString(any(), any(), anyOrNull(), anyOrNull()) } doAnswer { invocation ->
+ val quantity = invocation.arguments[0] as Int
+ val default = invocation.arguments[1] as Int
+ val one = invocation.arguments[3] as Int?
+ when (quantity) {
+ 1 -> when (one) {
+ R.string.booking_duration_second -> "1 second"
+ R.string.booking_duration_minute -> "1 minute"
+ R.string.booking_duration_hour -> "1 hour"
+ R.string.booking_duration_day -> "1 day"
+ else -> ""
+ }
+
+ else -> when (default) {
+ R.string.booking_duration_seconds -> "$quantity seconds"
+ R.string.booking_duration_minutes -> "$quantity minutes"
+ R.string.booking_duration_hours -> "$quantity hours"
+ R.string.booking_duration_days -> "$quantity days"
+ else -> ""
+ }
+ }
+ }
+ }
private lateinit var mapper: BookingMapper
@Before
fun setup() {
- mapper = BookingMapper(currencyFormatter, getLocations)
+ mapper = BookingMapper(currencyFormatter, getLocations, resourceProvider)
}
@Test
@@ -108,7 +134,7 @@ class BookingMapperTest : BaseUnitTest() {
assertThat(model.time).isEqualTo(expectedTime)
assertThat(model.staff).isEqualTo(staffMemberStatus)
assertThat(model.location).isEqualTo("238 Willow Creek Drive, Montgomery AL 36109")
- assertThat(model.duration).isEqualTo("90 min")
+ assertThat(model.duration).isEqualTo("1 hour 30 minutes")
assertThat(model.price).isEqualTo("$55.00")
assertThat(model.cancelStatus).isEqualTo(CancelStatus.Idle)
}
diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModelTest.kt
index e092895cb05b..f6c7bb9b17cc 100644
--- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModelTest.kt
+++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModelTest.kt
@@ -22,6 +22,7 @@ import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.eq
@@ -43,7 +44,7 @@ class BookingDetailsViewModelTest : BaseUnitTest() {
private val currencyFormatter = mock()
private val resourceProvider = mock()
private val getLocations = mock()
- private val bookingMapper = BookingMapper(currencyFormatter, getLocations)
+ private val bookingMapper = BookingMapper(currencyFormatter, getLocations, resourceProvider)
private val bookingsRepository = mock {
on { observeBooking(any()) } doReturn bookingFlow
onBlocking { fetchBooking(any()) } doReturn Result.success(bookingFlow.value)
@@ -61,6 +62,19 @@ class BookingDetailsViewModelTest : BaseUnitTest() {
any()
)
).thenReturn("Booking #${initialBooking.id.value}")
+
+ // Stub duration formatting strings used by BookingMapper for exact days
+ whenever(
+ resourceProvider.getQuantityString(
+ quantity = any(),
+ default = eq(R.string.booking_duration_days),
+ zero = anyOrNull(),
+ one = eq(R.string.booking_duration_day)
+ )
+ ).thenAnswer {
+ val qty = it.getArgument(0)
+ if (qty == 1) "$qty day" else "$qty days"
+ }
}
@Test
diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt
index bf87a00e72e2..e5a77082b483 100644
--- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt
+++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt
@@ -11,6 +11,7 @@ import com.woocommerce.android.util.captureValues
import com.woocommerce.android.util.getOrAwaitValue
import com.woocommerce.android.viewmodel.BaseUnitTest
import com.woocommerce.android.viewmodel.MultiLiveEvent
+import com.woocommerce.android.viewmodel.ResourceProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
@@ -52,7 +53,8 @@ class BookingListViewModelTest : BaseUnitTest() {
private val filtersBuilder = BookingListFiltersBuilder(Clock.fixed(mockedNow, ZoneId.of("UTC")))
private val currencyFormatter = mock()
private val getLocations = mock()
- private val bookingMapper = BookingMapper(currencyFormatter, getLocations)
+ private val resourceProvider = mock()
+ private val bookingMapper = BookingMapper(currencyFormatter, getLocations, resourceProvider)
private val bookingFiltersFlow = MutableStateFlow(BookingFilters())
private val bookingFilterRepository: BookingFilterRepository = mock {
on { bookingFiltersFlow } doReturn bookingFiltersFlow
diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/util/DurationExtTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/util/DurationExtTest.kt
new file mode 100644
index 000000000000..0b77ee69b917
--- /dev/null
+++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/util/DurationExtTest.kt
@@ -0,0 +1,127 @@
+package com.woocommerce.android.util
+
+import com.woocommerce.android.R
+import com.woocommerce.android.viewmodel.ResourceProvider
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.mock
+import java.time.Duration
+
+class DurationExtTest {
+
+ private lateinit var resourceProvider: ResourceProvider
+
+ @Before
+ fun setUp() {
+ resourceProvider = mock {
+ on { getQuantityString(any(), any(), anyOrNull(), anyOrNull()) } doAnswer { invocation ->
+ val quantity = invocation.arguments[0] as Int
+ val default = invocation.arguments[1] as Int
+ val one = invocation.arguments[3] as Int?
+ when (quantity) {
+ 1 -> when (one) {
+ R.string.booking_duration_second -> "1 second"
+ R.string.booking_duration_minute -> "1 minute"
+ R.string.booking_duration_hour -> "1 hour"
+ R.string.booking_duration_day -> "1 day"
+ else -> ""
+ }
+ else -> when (default) {
+ R.string.booking_duration_seconds -> "$quantity seconds"
+ R.string.booking_duration_minutes -> "$quantity minutes"
+ R.string.booking_duration_hours -> "$quantity hours"
+ R.string.booking_duration_days -> "$quantity days"
+ else -> ""
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `given day without less than a minute, when normalizeDuration, then rounds up to nearest day`() {
+ val duration = Duration.ofDays(1).minusSeconds(30)
+ val expected = Duration.ofDays(1)
+ assertEquals(expected, duration.normalizeDuration())
+ }
+
+ @Test
+ fun `given almost an hour without less than a minute, when normalizeDuration, then rounds up to nearest hour`() {
+ val duration = Duration.ofHours(1).minusSeconds(30)
+ val expected = Duration.ofHours(1)
+ assertEquals(expected, duration.normalizeDuration())
+ }
+
+ @Test
+ fun `given an hour without more than a minute, when normalizeDuration, then does not change duration`() {
+ val duration = Duration.ofHours(1).minusMinutes(2)
+ assertEquals(duration, duration.normalizeDuration())
+ }
+
+ @Test
+ fun `given duration of 45 seconds, when toHumanReadableFormat, then formatted correctly`() {
+ val duration = Duration.ofSeconds(45)
+ val expected = "45 seconds"
+ assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
+ }
+
+ @Test
+ fun `given duration of 1 minute, when toHumanReadableFormat, then formatted correctly`() {
+ val duration = Duration.ofMinutes(1)
+ val expected = "1 minute"
+ assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
+ }
+
+ @Test
+ fun `given duration of 30 minutes, when toHumanReadableFormat, then formatted correctly`() {
+ val duration = Duration.ofMinutes(30)
+ val expected = "30 minutes"
+ assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
+ }
+
+ @Test
+ fun `given duration of 1 hour, when toHumanReadableFormat, then formatted correctly`() {
+ val duration = Duration.ofHours(1)
+ val expected = "1 hour"
+ assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
+ }
+
+ @Test
+ fun `given duration of 2 hours, when toHumanReadableFormat, then formatted correctly`() {
+ val duration = Duration.ofHours(2)
+ val expected = "2 hours"
+ assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
+ }
+
+ @Test
+ fun `given duration of 1 day, when toHumanReadableFormat, then formatted correctly`() {
+ val duration = Duration.ofDays(1)
+ val expected = "1 day"
+ assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
+ }
+
+ @Test
+ fun `given duration of 3 days, when toHumanReadableFormat, then formatted correctly`() {
+ val duration = Duration.ofDays(3)
+ val expected = "3 days"
+ assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
+ }
+
+ @Test
+ fun `given duration of 1 day and 2 hours, when toHumanReadableFormat, then formatted correctly`() {
+ val duration = Duration.ofDays(1).plusHours(2)
+ val expected = "1 day 2 hours"
+ assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
+ }
+
+ @Test
+ fun `given duration of 1 day, 2 hours and 30 minutes, when toHumanReadableFormat, then formatted correctly`() {
+ val duration = Duration.ofDays(1).plusHours(2).plusMinutes(30)
+ val expected = "1 day 2 hours 30 minutes"
+ assertEquals(expected, duration.toHumanReadableFormat(resourceProvider))
+ }
+}