Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
8 changes: 8 additions & 0 deletions WooCommerce/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4234,6 +4234,14 @@
<string name="booking_payment_mark_as_paid">Mark as paid</string>
<string name="booking_payment_view_order">View order</string>
<string name="booking_fetch_error">Error fetching booking</string>
<string name="booking_duration_minute">%1$d minute</string>
<string name="booking_duration_minutes">%1$d minutes</string>
<string name="booking_duration_hour">%1$d hour</string>
<string name="booking_duration_hours">%1$d hours</string>
<string name="booking_duration_day">%1$d day</string>
<string name="booking_duration_days">%1$d days</string>
<string name="booking_duration_second">%1$d second</string>
<string name="booking_duration_seconds">%1$d seconds</string>

<string name="or_use_password" a8c-src-lib="module:login">Use password to sign in</string>
<string name="about_automattic_main_page_title">About %1$s</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,7 +44,7 @@ class BookingDetailsViewModelTest : BaseUnitTest() {
private val currencyFormatter = mock<CurrencyFormatter>()
private val resourceProvider = mock<ResourceProvider>()
private val getLocations = mock<GetLocations>()
private val bookingMapper = BookingMapper(currencyFormatter, getLocations)
private val bookingMapper = BookingMapper(currencyFormatter, getLocations, resourceProvider)
private val bookingsRepository = mock<BookingsRepository> {
on { observeBooking(any()) } doReturn bookingFlow
onBlocking { fetchBooking(any()) } doReturn Result.success(bookingFlow.value)
Expand All @@ -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<Int>(0)
if (qty == 1) "$qty day" else "$qty days"
}
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,7 +53,8 @@ class BookingListViewModelTest : BaseUnitTest() {
private val filtersBuilder = BookingListFiltersBuilder(Clock.fixed(mockedNow, ZoneId.of("UTC")))
private val currencyFormatter = mock<CurrencyFormatter>()
private val getLocations = mock<GetLocations>()
private val bookingMapper = BookingMapper(currencyFormatter, getLocations)
private val resourceProvider = mock<ResourceProvider>()
private val bookingMapper = BookingMapper(currencyFormatter, getLocations, resourceProvider)
private val bookingFiltersFlow = MutableStateFlow(BookingFilters())
private val bookingFilterRepository: BookingFilterRepository = mock {
on { bookingFiltersFlow } doReturn bookingFiltersFlow
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}