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