From 5230f5a22d5fd86f6490e82c2e8f65a111ba273d Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 2 Oct 2025 15:10:43 +0200 Subject: [PATCH 01/13] Expose function to observe booking from DB --- .../org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt index 4bb46eda4af..38e1ba4bde8 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt @@ -41,6 +41,9 @@ interface BookingsDao { customerId: Long?, ): List + @Query("SELECT * FROM Bookings WHERE localSiteId = :localSiteId AND id = :bookingId LIMIT 1") + fun observeBooking(localSiteId: LocalId, bookingId: Long): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOrReplace(entity: BookingEntity): Long From 221c2b67102799ecbf78693e9b99f59423939391 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 2 Oct 2025 15:11:14 +0200 Subject: [PATCH 02/13] Add observeBooking function to BookingRepository --- .../woocommerce/android/ui/bookings/BookingsRepository.kt | 6 ++++++ .../fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt index 5f48291db65..81bac674ce1 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt @@ -39,6 +39,12 @@ class BookingsRepository @Inject constructor( limit = limit, filters = filters ) + + fun observeBooking(bookingId: Long): Flow = + bookingsStore.observeBooking( + site = selectedSite.get(), + bookingId = bookingId + ) } typealias Booking = BookingEntity diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt index 7374165fde6..19197030f71 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt @@ -60,6 +60,11 @@ class BookingsStore @Inject constructor( filters: List = emptyList() ): Flow> = bookingsDao.observeBookings(site.localId(), limit, filters) + fun observeBooking( + site: SiteModel, + bookingId: Long + ): Flow = bookingsDao.observeBooking(site.localId(), bookingId) + private fun BookingDto.toEntity(localSiteId: LocalId): BookingEntity = BookingEntity( id = RemoteId(id), localSiteId = localSiteId, From 16f633610b9d3bf337077f65a09d87c4fcbbb931 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 2 Oct 2025 15:48:03 +0200 Subject: [PATCH 03/13] Observe booking in the ViewModel --- .../android/ui/bookings/BookingMapper.kt | 4 ++++ .../ui/bookings/details/BookingDetailsViewModel.kt | 13 +++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt 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 new file mode 100644 index 00000000000..a6d0a9da300 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt @@ -0,0 +1,4 @@ +package com.woocommerce.android.ui.bookings + +class BookingMapper { +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt index e4ccaba1e0e..a353e5cb4cf 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt @@ -4,12 +4,15 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData import com.woocommerce.android.R +import com.woocommerce.android.ui.bookings.BookingsRepository import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus import com.woocommerce.android.viewmodel.ResourceProvider import com.woocommerce.android.viewmodel.ScopedViewModel import com.woocommerce.android.viewmodel.navArgs import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -17,6 +20,7 @@ import javax.inject.Inject class BookingDetailsViewModel @Inject constructor( savedState: SavedStateHandle, resourceProvider: ResourceProvider, + private val bookingsRepository: BookingsRepository, ) : ScopedViewModel(savedState) { private val navArgs: BookingDetailsFragmentArgs by savedState.navArgs() @@ -35,6 +39,7 @@ class BookingDetailsViewModel @Inject constructor( toolbarTitle = resourceProvider.getString(R.string.booking_details_title, navArgs.bookingId), ) } + observeBooking(navArgs.bookingId) } private fun onAttendanceStatusSelected(status: BookingAttendanceStatus) { @@ -48,4 +53,12 @@ class BookingDetailsViewModel @Inject constructor( private fun onCancelBooking() { // TODO Add logic to Cancel booking } + + private fun observeBooking(bookingId: Long) { + bookingsRepository.observeBooking(bookingId) + .onEach { booking -> + + } + .launchIn(this) + } } From 6bd0c812b9b21000f789ce0b19a5b2a2bc8b55ce Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 2 Oct 2025 15:48:40 +0200 Subject: [PATCH 04/13] Create BookingMapper class to map to ui models --- .../android/ui/bookings/BookingMapper.kt | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) 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 a6d0a9da300..9058f226161 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 @@ -1,4 +1,61 @@ package com.woocommerce.android.ui.bookings -class BookingMapper { +import com.woocommerce.android.ui.bookings.compose.BookingAppointmentDetailsModel +import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus +import com.woocommerce.android.ui.bookings.compose.BookingStatus +import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel +import com.woocommerce.android.util.CurrencyFormatter +import org.wordpress.android.fluxc.persistence.entity.BookingEntity +import java.time.Duration +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import javax.inject.Inject + +class BookingMapper @Inject constructor( + private val currencyFormatter: CurrencyFormatter, +) { + private val summaryDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime( + FormatStyle.MEDIUM, + FormatStyle.SHORT + ).withZone(ZoneOffset.UTC) + + private val detailsDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy") + .withZone(ZoneOffset.UTC) + private val timeRangeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + .withZone(ZoneOffset.UTC) + + fun Booking.toBookingSummaryModel(): BookingSummaryModel { + return BookingSummaryModel( + date = summaryDateFormatter.format(start), + // TODO replace mocked values when product and customer data are available + name = "Women’s Haircut", + customerName = "Margarita Nikolaevna", + attendanceStatus = BookingAttendanceStatus.BOOKED, + status = status.toUiModel() + ) + } + + fun Booking.toAppointmentDetailsModel(): BookingAppointmentDetailsModel { + val durationMinutes = Duration.between(start, end).toMinutes() + return BookingAppointmentDetailsModel( + date = detailsDateFormatter.format(start), + time = "${timeRangeFormatter.format(start)} - ${timeRangeFormatter.format(end)}", + // TODO replace mocked values when available from API + staff = "Marianne Renoir", + location = "238 Willow Creek Drive, Montgomery AL 36109", + duration = "$durationMinutes min", + price = currencyFormatter.formatCurrency(cost, currency) + ) + } + + private fun BookingEntity.Status.toUiModel(): BookingStatus = when (this) { + BookingEntity.Status.Paid -> BookingStatus.Paid + BookingEntity.Status.PendingConfirmation -> BookingStatus.PendingConfirmation + BookingEntity.Status.Cancelled -> BookingStatus.Cancelled + BookingEntity.Status.Complete -> BookingStatus.Complete + BookingEntity.Status.Confirmed -> BookingStatus.Confirmed + BookingEntity.Status.Unpaid -> BookingStatus.Unpaid + is BookingEntity.Status.Unknown -> BookingStatus.Unknown(this.key) + } } From ddc5ed955da6b2969d25ece2815753a6591d2024 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 2 Oct 2025 15:49:15 +0200 Subject: [PATCH 05/13] Map Booking object to the UI models in ViewModel --- .../bookings/details/BookingDetailsScreen.kt | 66 +++++++++++-------- .../details/BookingDetailsViewModel.kt | 34 +++++++++- .../details/BookingDetailsViewState.kt | 36 ++-------- 3 files changed, 74 insertions(+), 62 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsScreen.kt index 0a5383b4884..30dd3b8bc06 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsScreen.kt @@ -76,34 +76,44 @@ fun BookingDetailsScreen( .verticalScroll(rememberScrollState()) .padding(innerPadding) ) { - BookingSummary( - model = viewState.bookingSummary, - modifier = Modifier.fillMaxWidth() - ) - BookingAppointmentDetails( - model = viewState.bookingsAppointmentDetails, - onCancelBooking = viewState.onCancelBooking, - modifier = Modifier.fillMaxWidth() - ) - BookingCustomerDetails( - model = viewState.bookingCustomerDetails, - onEmailClick = {}, - onPhoneClick = {}, - modifier = Modifier.fillMaxWidth() - ) - BookingAttendanceSection( - status = viewState.bookingSummary.attendanceStatus, - onClick = { showAttendanceSheet.value = true }, - modifier = Modifier.fillMaxWidth() - ) - BookingPaymentSection( - model = viewState.bookingPaymentDetails, - status = viewState.bookingSummary.status, - onMarkAsPaid = { onViewOrder(viewState.orderId) }, - onViewOrder = { onViewOrder(viewState.orderId) }, - onMarkAsRefunded = { onViewOrder(viewState.orderId) }, - modifier = Modifier.fillMaxWidth() - ) + viewState.bookingSummary?.let { + BookingSummary( + model = viewState.bookingSummary, + modifier = Modifier.fillMaxWidth() + ) + } + viewState.bookingsAppointmentDetails?.let { + BookingAppointmentDetails( + model = viewState.bookingsAppointmentDetails, + onCancelBooking = viewState.onCancelBooking, + modifier = Modifier.fillMaxWidth() + ) + } + viewState.bookingCustomerDetails?.let { + BookingCustomerDetails( + model = viewState.bookingCustomerDetails, + onEmailClick = {}, + onPhoneClick = {}, + modifier = Modifier.fillMaxWidth() + ) + } + viewState.bookingSummary?.let { + BookingAttendanceSection( + status = viewState.bookingSummary.attendanceStatus, + onClick = { showAttendanceSheet.value = true }, + modifier = Modifier.fillMaxWidth() + ) + } + viewState.bookingPaymentDetails?.let { + BookingPaymentSection( + model = viewState.bookingPaymentDetails, + status = viewState.bookingSummary.status, + onMarkAsPaid = { onViewOrder(viewState.orderId) }, + onViewOrder = { onViewOrder(viewState.orderId) }, + onMarkAsRefunded = { onViewOrder(viewState.orderId) }, + modifier = Modifier.fillMaxWidth() + ) + } } if (showAttendanceSheet.value) { BookingAttendanceStatusBottomSheet( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt index a353e5cb4cf..b7011a8f155 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt @@ -4,8 +4,12 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData import com.woocommerce.android.R +import com.woocommerce.android.ui.bookings.Booking +import com.woocommerce.android.ui.bookings.BookingMapper import com.woocommerce.android.ui.bookings.BookingsRepository import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus +import com.woocommerce.android.ui.bookings.compose.BookingCustomerDetailsModel +import com.woocommerce.android.ui.bookings.compose.BookingPaymentDetailsModel import com.woocommerce.android.viewmodel.ResourceProvider import com.woocommerce.android.viewmodel.ScopedViewModel import com.woocommerce.android.viewmodel.navArgs @@ -21,6 +25,7 @@ class BookingDetailsViewModel @Inject constructor( savedState: SavedStateHandle, resourceProvider: ResourceProvider, private val bookingsRepository: BookingsRepository, + private val bookingMapper: BookingMapper, ) : ScopedViewModel(savedState) { private val navArgs: BookingDetailsFragmentArgs by savedState.navArgs() @@ -45,7 +50,7 @@ class BookingDetailsViewModel @Inject constructor( private fun onAttendanceStatusSelected(status: BookingAttendanceStatus) { _state.update { current -> current.copy( - bookingSummary = current.bookingSummary.copy(attendanceStatus = status) + bookingSummary = current.bookingSummary?.copy(attendanceStatus = status) ) } } @@ -57,8 +62,33 @@ class BookingDetailsViewModel @Inject constructor( private fun observeBooking(bookingId: Long) { bookingsRepository.observeBooking(bookingId) .onEach { booking -> - + booking?.let { updateStateWithBooking(it) } } .launchIn(this) } + + private fun updateStateWithBooking(booking: Booking) = with(bookingMapper) { + _state.update { current -> + current.copy( + bookingSummary = booking.toBookingSummaryModel(), + bookingsAppointmentDetails = booking.toAppointmentDetailsModel(), + bookingCustomerDetails = BookingCustomerDetailsModel( + name = "Margarita Nikolaevna", + email = "margarita@example.com", + phone = "+1 555-123-4567", + billingAddressLines = listOf( + "238 Willow Creek Drive", + "Montgomery AL 36109", + "United States" + ) + ), + bookingPaymentDetails = BookingPaymentDetailsModel( + service = "$55.00", + tax = "$4.50", + discount = "-", + total = "$59.50" + ) + ) + } + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewState.kt index 045d11c1cde..4da780e5a8c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewState.kt @@ -4,43 +4,15 @@ import com.woocommerce.android.ui.bookings.compose.BookingAppointmentDetailsMode import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus import com.woocommerce.android.ui.bookings.compose.BookingCustomerDetailsModel import com.woocommerce.android.ui.bookings.compose.BookingPaymentDetailsModel -import com.woocommerce.android.ui.bookings.compose.BookingStatus import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel data class BookingDetailsViewState( val toolbarTitle: String = "", val orderId: Long = 0L, - val bookingSummary: BookingSummaryModel = BookingSummaryModel( - date = "05/07/2025, 11:00 AM", - name = "Women’s Haircut", - customerName = "Margarita Nikolaevna", - attendanceStatus = BookingAttendanceStatus.NO_SHOW, - status = BookingStatus.Paid - ), - val bookingsAppointmentDetails: BookingAppointmentDetailsModel = BookingAppointmentDetailsModel( - date = "Monday, 05 July 2025", - time = "11:00 am - 12:00 pm", - staff = "Marianne Renoir", - location = "238 Willow Creek Drive, Montgomery AL 36109", - duration = "60 min", - price = "$55.00" - ), - val bookingCustomerDetails: BookingCustomerDetailsModel = BookingCustomerDetailsModel( - name = "Margarita Nikolaevna", - email = "margarita@example.com", - phone = "+1 555-123-4567", - billingAddressLines = listOf( - "238 Willow Creek Drive", - "Montgomery AL 36109", - "United States" - ) - ), - val bookingPaymentDetails: BookingPaymentDetailsModel = BookingPaymentDetailsModel( - service = "$55.00", - tax = "$4.50", - discount = "-", - total = "$59.50" - ), + val bookingSummary: BookingSummaryModel? = null, + val bookingsAppointmentDetails: BookingAppointmentDetailsModel? = null, + val bookingCustomerDetails: BookingCustomerDetailsModel? = null, + val bookingPaymentDetails: BookingPaymentDetailsModel? = null, val onCancelBooking: () -> Unit = {}, val onAttendanceStatusSelected: (BookingAttendanceStatus) -> Unit = { _ -> } ) From 13614a8f11988c418e5d52294f6c445802587a81 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 2 Oct 2025 16:08:03 +0200 Subject: [PATCH 06/13] Add tests for BookingMapper --- .../android/ui/bookings/BookingMapperTest.kt | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt 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 new file mode 100644 index 00000000000..ecac0747f01 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt @@ -0,0 +1,137 @@ +package com.woocommerce.android.ui.bookings + +import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus +import com.woocommerce.android.ui.bookings.compose.BookingStatus +import com.woocommerce.android.util.CurrencyFormatter +import com.woocommerce.android.viewmodel.BaseUnitTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.LocalOrRemoteId +import org.wordpress.android.fluxc.persistence.entity.BookingEntity +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@OptIn(ExperimentalCoroutinesApi::class) +class BookingMapperTest : BaseUnitTest() { + + private val currencyFormatter: CurrencyFormatter = mock() + private lateinit var mapper: BookingMapper + + @Before + fun setup() { + mapper = BookingMapper(currencyFormatter) + } + + @Test + fun `given booking, when mapped to summary model, then maps fields correctly`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start.plusSeconds(60 * 60) + val booking = sampleBooking( + status = BookingEntity.Status.Confirmed, + start = start, + end = end, + cost = "55.00", + currency = "USD" + ) + + val expectedDate = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) + .withZone(ZoneOffset.UTC) + .format(start) + + // WHEN + val model = mapper.run { booking.toBookingSummaryModel() } + + // THEN + assertThat(model.date).isEqualTo(expectedDate) + assertThat(model.name).isEqualTo("Women’s Haircut") + assertThat(model.customerName).isEqualTo("Margarita Nikolaevna") + assertThat(model.attendanceStatus).isEqualTo(BookingAttendanceStatus.BOOKED) + assertThat(model.status).isEqualTo(BookingStatus.Confirmed) + } + + @Test + fun `given booking, when mapped to appointment details model, then maps fields and formats price and time correctly`() { + // GIVEN + val start = Instant.parse("2025-07-05T11:00:00Z") + val end = start.plusSeconds(90 * 60) // 90 minutes + val booking = sampleBooking( + status = BookingEntity.Status.Paid, + start = start, + end = end, + cost = "55.00", + currency = "USD" + ) + + whenever(currencyFormatter.formatCurrency(eq("55.00"), eq("USD"), eq(true))).thenReturn("$55.00") + + val expectedDate = DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy") + .withZone(ZoneOffset.UTC) + .format(start) + val timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneOffset.UTC) + val expectedTime = "${timeFormatter.format(start)} - ${timeFormatter.format(end)}" + + // WHEN + val model = mapper.run { booking.toAppointmentDetailsModel() } + + // THEN + assertThat(model.date).isEqualTo(expectedDate) + assertThat(model.time).isEqualTo(expectedTime) + assertThat(model.staff).isEqualTo("Marianne Renoir") + assertThat(model.location).isEqualTo("238 Willow Creek Drive, Montgomery AL 36109") + assertThat(model.duration).isEqualTo("90 min") + assertThat(model.price).isEqualTo("$55.00") + } + + @Test + fun `given booking with unknown status, when mapped, then preserves Unknown key`() { + // GIVEN + val booking = sampleBooking(status = BookingEntity.Status.Unknown("weird-status")) + + // WHEN + val model = mapper.run { booking.toBookingSummaryModel() } + + // THEN + assertThat(model.status).isInstanceOf(BookingStatus.Unknown::class.java) + val unknown = model.status as BookingStatus.Unknown + assertThat(unknown.key).isEqualTo("weird-status") + } + + private fun sampleBooking( + status: BookingEntity.Status = BookingEntity.Status.Confirmed, + start: Instant = Instant.parse("2025-07-05T11:00:00Z"), + end: Instant = start.plus(Duration.ofHours(1)), + cost: String = "0.00", + currency: String = "USD" + ): BookingEntity { + return BookingEntity( + id = LocalOrRemoteId.RemoteId(1L), + localSiteId = LocalOrRemoteId.LocalId(1), + start = start, + end = end, + allDay = false, + status = status, + cost = cost, + currency = currency, + customerId = 1L, + productId = 1L, + resourceId = 1L, + dateCreated = start, + dateModified = end, + googleCalendarEventId = "", + orderId = 1L, + orderItemId = 1L, + parentId = 0L, + personCounts = listOf(1L), + localTimezone = "UTC" + ) + } +} From b43c1fda95d37e739d2caee4fc0675ecc4d5c198 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 2 Oct 2025 16:10:40 +0200 Subject: [PATCH 07/13] Use the BookingMapper in the BookingListViewModel --- .../android/ui/bookings/BookingMapper.kt | 8 +++++ .../ui/bookings/list/BookingListViewModel.kt | 8 +++-- .../ui/bookings/list/BookingListViewState.kt | 36 ------------------- .../bookings/list/BookingListViewModelTest.kt | 7 +++- 4 files changed, 20 insertions(+), 39 deletions(-) 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 9058f226161..9fb2ea39359 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 @@ -4,6 +4,7 @@ import com.woocommerce.android.ui.bookings.compose.BookingAppointmentDetailsMode import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus import com.woocommerce.android.ui.bookings.compose.BookingStatus import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel +import com.woocommerce.android.ui.bookings.list.BookingListItem import com.woocommerce.android.util.CurrencyFormatter import org.wordpress.android.fluxc.persistence.entity.BookingEntity import java.time.Duration @@ -36,6 +37,13 @@ class BookingMapper @Inject constructor( ) } + fun Booking.toUiModel(): BookingListItem { + return BookingListItem( + id = id.value, + summary = toBookingSummaryModel() + ) + } + fun Booking.toAppointmentDetailsModel(): BookingAppointmentDetailsModel { val durationMinutes = Duration.between(start, end).toMinutes() return BookingAppointmentDetailsModel( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt index 916a5511146..448de7d52aa 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.woocommerce.android.R +import com.woocommerce.android.ui.bookings.BookingMapper import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ScopedViewModel import com.woocommerce.android.viewmodel.getStateFlow @@ -21,7 +22,8 @@ import javax.inject.Inject class BookingListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val bookingListHandler: BookingListHandler, - private val filtersBuilder: BookingListFiltersBuilder + private val filtersBuilder: BookingListFiltersBuilder, + private val bookingMapper: BookingMapper, ) : ScopedViewModel(savedStateHandle) { private val loadingState = MutableStateFlow(BookingListLoadingState.Idle) private val selectedTab = savedStateHandle.getStateFlow(viewModelScope, BookingListTab.Today) @@ -30,7 +32,9 @@ class BookingListViewModel @Inject constructor( private var bookingsLoadMoreJob: Job? = null private val contentState = combine( - bookingListHandler.bookingsFlow.map { bookings -> bookings.map { it.toUiModel() } }, + bookingListHandler.bookingsFlow.map { bookings -> + with(bookingMapper) { bookings.map { it.toUiModel() } } + }, loadingState ) { bookings, loadingState -> BookingListContentState( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewState.kt index 97bf5f7dc27..db153f1c455 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewState.kt @@ -1,13 +1,6 @@ package com.woocommerce.android.ui.bookings.list -import com.woocommerce.android.ui.bookings.Booking -import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus -import com.woocommerce.android.ui.bookings.compose.BookingStatus import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel -import org.wordpress.android.fluxc.persistence.entity.BookingEntity -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle data class BookingListViewState( val contentState: BookingListContentState, @@ -41,32 +34,3 @@ enum class BookingListLoadingState { enum class BookingListTab { Today, Upcoming, All } - -fun Booking.toUiModel(): BookingListItem { - val dateFormatter = DateTimeFormatter.ofLocalizedDateTime( - FormatStyle.MEDIUM, - FormatStyle.SHORT - ).withZone(ZoneId.systemDefault()) - - // TODO replace the mocked details with real data when available from the API - return BookingListItem( - id = id.value, - summary = BookingSummaryModel( - date = dateFormatter.format(start), - name = "Women’s Haircut", - customerName = "Margarita Nikolaevna", - attendanceStatus = BookingAttendanceStatus.BOOKED, - status = status.toUiModel() - ) - ) -} - -private fun BookingEntity.Status.toUiModel(): BookingStatus = when (this) { - BookingEntity.Status.Paid -> BookingStatus.Paid - BookingEntity.Status.PendingConfirmation -> BookingStatus.PendingConfirmation - BookingEntity.Status.Cancelled -> BookingStatus.Cancelled - BookingEntity.Status.Complete -> BookingStatus.Complete - BookingEntity.Status.Confirmed -> BookingStatus.Confirmed - BookingEntity.Status.Unpaid -> BookingStatus.Unpaid - is BookingEntity.Status.Unknown -> BookingStatus.Unknown(this.key) -} 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 6f5a2e0edba..4305d2a497d 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 @@ -3,6 +3,8 @@ package com.woocommerce.android.ui.bookings.list import androidx.lifecycle.SavedStateHandle import com.woocommerce.android.R import com.woocommerce.android.ui.bookings.Booking +import com.woocommerce.android.ui.bookings.BookingMapper +import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.util.captureValues import com.woocommerce.android.util.getOrAwaitValue import com.woocommerce.android.viewmodel.BaseUnitTest @@ -42,6 +44,8 @@ class BookingListViewModelTest : BaseUnitTest() { } private val mockedNow = Instant.parse("2025-01-01T12:00:00Z") private val filtersBuilder = BookingListFiltersBuilder(Clock.fixed(mockedNow, ZoneId.of("UTC"))) + private val currencyFormatter = mock() + private val bookingMapper = BookingMapper(currencyFormatter) private lateinit var viewModel: BookingListViewModel @@ -54,7 +58,8 @@ class BookingListViewModelTest : BaseUnitTest() { viewModel = BookingListViewModel( savedStateHandle = SavedStateHandle(), bookingListHandler = bookingListHandler, - filtersBuilder = filtersBuilder + filtersBuilder = filtersBuilder, + bookingMapper = bookingMapper, ) } From c33ad874d7d9a8f4da9a5f5d18e33417a0a4f196 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 2 Oct 2025 16:29:50 +0200 Subject: [PATCH 08/13] Add tests for the BookingDetailsViewModel --- .../details/BookingDetailsViewModelTest.kt | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) 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 ec2b2d98306..5994d0e0403 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 @@ -2,19 +2,41 @@ package com.woocommerce.android.ui.bookings.details import androidx.lifecycle.SavedStateHandle import com.woocommerce.android.R +import com.woocommerce.android.ui.bookings.Booking +import com.woocommerce.android.ui.bookings.BookingMapper +import com.woocommerce.android.ui.bookings.BookingsRepository import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus +import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.util.getOrAwaitValue import com.woocommerce.android.viewmodel.BaseUnitTest import com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import org.assertj.core.api.Assertions.assertThat +import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.LocalOrRemoteId +import org.wordpress.android.fluxc.persistence.entity.BookingEntity +import java.time.Duration +import java.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class BookingDetailsViewModelTest : BaseUnitTest() { + private val currencyFormatter = mock() + private val bookingMapper = BookingMapper(currencyFormatter) + + private val bookingFlow = MutableSharedFlow() + + @Before + fun setup() { + whenever(currencyFormatter.formatCurrency(any(), any(), any())).thenReturn("$0.00") + } + @Test fun `given bookingId in SavedStateHandle, when ViewModel created, then toolbar title formatted`() { // Given @@ -42,6 +64,9 @@ class BookingDetailsViewModelTest : BaseUnitTest() { } val viewModel = createViewModel(savedState, resourceProvider) + val booking = getSampleBooking(1) + testBlocking { bookingFlow.emit(booking) } + // When val state = viewModel.state.getOrAwaitValue() state.onAttendanceStatusSelected(BookingAttendanceStatus.CANCELLED) @@ -51,12 +76,65 @@ class BookingDetailsViewModelTest : BaseUnitTest() { assertThat(updated).isEqualTo(BookingAttendanceStatus.CANCELLED) } + @Test + fun `given booking emitted, when observed by ViewModel, then state contains mapped objects`() = testBlocking { + // Given + val bookingId = 789L + val savedState = SavedStateHandle(mapOf("bookingId" to bookingId)) + val resourceProvider = mock { + on { getString(R.string.booking_details_title, bookingId) } doReturn "Booking #$bookingId" + } + val viewModel = createViewModel(savedState, resourceProvider) + + // When + val booking = getSampleBooking(2) + bookingFlow.emit(booking) + + // Then + val state = viewModel.state.getOrAwaitValue() + assertThat(state.bookingSummary).isNotNull + assertThat(state.bookingsAppointmentDetails).isNotNull + assertThat(state.bookingCustomerDetails).isNotNull + } + private fun createViewModel( savedState: SavedStateHandle, resourceProvider: ResourceProvider ): BookingDetailsViewModel { - return BookingDetailsViewModel(savedState, resourceProvider).apply { + val bookingsRepository = mock { + on { observeBooking(any()) } doReturn bookingFlow + } + return BookingDetailsViewModel( + savedState = savedState, + resourceProvider = resourceProvider, + bookingsRepository = bookingsRepository, + bookingMapper = bookingMapper + ).apply { state.observeForever { } } } + + private fun getSampleBooking(id: Int): Booking { + return BookingEntity( + id = LocalOrRemoteId.RemoteId(id.toLong()), + localSiteId = LocalOrRemoteId.LocalId(1), + start = Instant.now(), + end = Instant.now() + Duration.ofDays(1), + allDay = false, + status = BookingEntity.Status.Confirmed, + cost = "100.00", + currency = "USD", + customerId = 1L, + productId = 1L, + resourceId = 1L, + dateCreated = Instant.now(), + dateModified = Instant.now(), + googleCalendarEventId = "", + orderId = 1L, + orderItemId = 1L, + parentId = 0L, + personCounts = listOf(1L), + localTimezone = "" + ) + } } From 4533d98e81368bdd49f060b96a185a19d31a80b2 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 2 Oct 2025 17:08:48 +0200 Subject: [PATCH 09/13] Introduce BookingUiState object to wrap all ui models --- .../bookings/details/BookingDetailsScreen.kt | 86 +++++++++++-------- .../details/BookingDetailsViewModel.kt | 47 +++++----- .../details/BookingDetailsViewState.kt | 12 ++- .../details/BookingDetailsViewModelTest.kt | 6 +- 4 files changed, 86 insertions(+), 65 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsScreen.kt index 30dd3b8bc06..02009e854f9 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsScreen.kt @@ -26,6 +26,8 @@ import com.woocommerce.android.ui.bookings.compose.BookingAttendanceSection import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatusBottomSheet import com.woocommerce.android.ui.bookings.compose.BookingCustomerDetails +import com.woocommerce.android.ui.bookings.compose.BookingCustomerDetailsModel +import com.woocommerce.android.ui.bookings.compose.BookingPaymentDetailsModel import com.woocommerce.android.ui.bookings.compose.BookingPaymentSection import com.woocommerce.android.ui.bookings.compose.BookingStatus import com.woocommerce.android.ui.bookings.compose.BookingSummary @@ -76,38 +78,30 @@ fun BookingDetailsScreen( .verticalScroll(rememberScrollState()) .padding(innerPadding) ) { - viewState.bookingSummary?.let { + viewState.bookingUiState?.let { BookingSummary( - model = viewState.bookingSummary, + model = viewState.bookingUiState.bookingSummary, modifier = Modifier.fillMaxWidth() ) - } - viewState.bookingsAppointmentDetails?.let { BookingAppointmentDetails( - model = viewState.bookingsAppointmentDetails, + model = viewState.bookingUiState.bookingsAppointmentDetails, onCancelBooking = viewState.onCancelBooking, modifier = Modifier.fillMaxWidth() ) - } - viewState.bookingCustomerDetails?.let { BookingCustomerDetails( - model = viewState.bookingCustomerDetails, + model = viewState.bookingUiState.bookingCustomerDetails, onEmailClick = {}, onPhoneClick = {}, modifier = Modifier.fillMaxWidth() ) - } - viewState.bookingSummary?.let { BookingAttendanceSection( - status = viewState.bookingSummary.attendanceStatus, + status = viewState.bookingUiState.bookingSummary.attendanceStatus, onClick = { showAttendanceSheet.value = true }, modifier = Modifier.fillMaxWidth() ) - } - viewState.bookingPaymentDetails?.let { BookingPaymentSection( - model = viewState.bookingPaymentDetails, - status = viewState.bookingSummary.status, + model = viewState.bookingUiState.bookingPaymentDetails, + status = viewState.bookingUiState.bookingSummary.status, onMarkAsPaid = { onViewOrder(viewState.orderId) }, onViewOrder = { onViewOrder(viewState.orderId) }, onMarkAsRefunded = { onViewOrder(viewState.orderId) }, @@ -115,14 +109,14 @@ fun BookingDetailsScreen( ) } } - if (showAttendanceSheet.value) { - BookingAttendanceStatusBottomSheet( - onSelect = { status -> - viewState.onAttendanceStatusSelected(status) - }, - onDismiss = { showAttendanceSheet.value = false } - ) - } + } + if (showAttendanceSheet.value) { + BookingAttendanceStatusBottomSheet( + onSelect = { status -> + viewState.onAttendanceStatusSelected(status) + }, + onDismiss = { showAttendanceSheet.value = false } + ) } } } @@ -134,21 +128,39 @@ private fun BookingDetailsPreview() { BookingDetailsScreen( viewState = BookingDetailsViewState( toolbarTitle = "Booking #12345", - bookingSummary = BookingSummaryModel( - date = "05/07/2025, 11:00 AM", - name = "Women’s Haircut", - customerName = "Margarita Nikolaevna", - attendanceStatus = BookingAttendanceStatus.CHECKED_IN, - status = BookingStatus.Paid + bookingUiState = BookingUiState( + bookingSummary = BookingSummaryModel( + date = "05/07/2025, 11:00 AM", + name = "Women’s Haircut", + customerName = "Margarita Nikolaevna", + attendanceStatus = BookingAttendanceStatus.CHECKED_IN, + status = BookingStatus.Paid + ), + bookingsAppointmentDetails = BookingAppointmentDetailsModel( + date = "Monday, 05 July 2025", + time = "11:00 am - 12:00 pm", + staff = "Marianne Renoir", + location = "238 Willow Creek Drive, Montgomery AL 36109", + duration = "60 min", + price = "$55.00" + ), + bookingCustomerDetails = BookingCustomerDetailsModel( + name = "Margarita Nikolaevna", + email = "margarita@example.com", + phone = "+1 555-123-4567", + billingAddressLines = listOf( + "238 Willow Creek Drive", + "Montgomery AL 36109", + "United States" + ) + ), + bookingPaymentDetails = BookingPaymentDetailsModel( + service = "$55.00", + tax = "$4.50", + discount = "-", + total = "$59.50" + ) ), - bookingsAppointmentDetails = BookingAppointmentDetailsModel( - date = "Monday, 05 July 2025", - time = "11:00 am - 12:00 pm", - staff = "Marianne Renoir", - location = "238 Willow Creek Drive, Montgomery AL 36109", - duration = "60 min", - price = "$55.00" - ) ), onBack = {}, onViewOrder = {} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt index b7011a8f155..21454f639c3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt @@ -48,10 +48,15 @@ class BookingDetailsViewModel @Inject constructor( } private fun onAttendanceStatusSelected(status: BookingAttendanceStatus) { - _state.update { current -> - current.copy( - bookingSummary = current.bookingSummary?.copy(attendanceStatus = status) - ) + val bookingState = _state.value.bookingUiState + if (bookingState != null) { + _state.update { current -> + current.copy( + bookingUiState = bookingState.copy( + bookingSummary = bookingState.bookingSummary.copy(attendanceStatus = status) + ) + ) + } } } @@ -70,24 +75,26 @@ class BookingDetailsViewModel @Inject constructor( private fun updateStateWithBooking(booking: Booking) = with(bookingMapper) { _state.update { current -> current.copy( - bookingSummary = booking.toBookingSummaryModel(), - bookingsAppointmentDetails = booking.toAppointmentDetailsModel(), - bookingCustomerDetails = BookingCustomerDetailsModel( - name = "Margarita Nikolaevna", - email = "margarita@example.com", - phone = "+1 555-123-4567", - billingAddressLines = listOf( - "238 Willow Creek Drive", - "Montgomery AL 36109", - "United States" + bookingUiState = BookingUiState( + bookingSummary = booking.toBookingSummaryModel(), + bookingsAppointmentDetails = booking.toAppointmentDetailsModel(), + bookingCustomerDetails = BookingCustomerDetailsModel( + name = "Margarita Nikolaevna", + email = "margarita@example.com", + phone = "+1 555-123-4567", + billingAddressLines = listOf( + "238 Willow Creek Drive", + "Montgomery AL 36109", + "United States" + ) + ), + bookingPaymentDetails = BookingPaymentDetailsModel( + service = "$55.00", + tax = "$4.50", + discount = "-", + total = "$59.50" ) ), - bookingPaymentDetails = BookingPaymentDetailsModel( - service = "$55.00", - tax = "$4.50", - discount = "-", - total = "$59.50" - ) ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewState.kt index 4da780e5a8c..a121e9839a1 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewState.kt @@ -9,10 +9,14 @@ import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel data class BookingDetailsViewState( val toolbarTitle: String = "", val orderId: Long = 0L, - val bookingSummary: BookingSummaryModel? = null, - val bookingsAppointmentDetails: BookingAppointmentDetailsModel? = null, - val bookingCustomerDetails: BookingCustomerDetailsModel? = null, - val bookingPaymentDetails: BookingPaymentDetailsModel? = null, + val bookingUiState: BookingUiState? = null, val onCancelBooking: () -> Unit = {}, val onAttendanceStatusSelected: (BookingAttendanceStatus) -> Unit = { _ -> } ) + +data class BookingUiState( + val bookingSummary: BookingSummaryModel, + val bookingsAppointmentDetails: BookingAppointmentDetailsModel, + val bookingCustomerDetails: BookingCustomerDetailsModel, + val bookingPaymentDetails: BookingPaymentDetailsModel, +) 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 5994d0e0403..6461dd15a71 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 @@ -72,7 +72,7 @@ class BookingDetailsViewModelTest : BaseUnitTest() { state.onAttendanceStatusSelected(BookingAttendanceStatus.CANCELLED) // Then - val updated = viewModel.state.value?.bookingSummary?.attendanceStatus + val updated = viewModel.state.value?.bookingUiState?.bookingSummary?.attendanceStatus assertThat(updated).isEqualTo(BookingAttendanceStatus.CANCELLED) } @@ -92,9 +92,7 @@ class BookingDetailsViewModelTest : BaseUnitTest() { // Then val state = viewModel.state.getOrAwaitValue() - assertThat(state.bookingSummary).isNotNull - assertThat(state.bookingsAppointmentDetails).isNotNull - assertThat(state.bookingCustomerDetails).isNotNull + assertThat(state.bookingUiState).isNotNull } private fun createViewModel( From 509794d7b5731f41a1b4ea3847de4599bcf8a3f5 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Fri, 3 Oct 2025 11:06:22 +0200 Subject: [PATCH 10/13] Set orderId in BookingDetailsViewModel state --- .../android/ui/bookings/details/BookingDetailsViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt index 21454f639c3..2e2f96cfb67 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt @@ -75,6 +75,7 @@ class BookingDetailsViewModel @Inject constructor( private fun updateStateWithBooking(booking: Booking) = with(bookingMapper) { _state.update { current -> current.copy( + orderId = booking.orderId, bookingUiState = BookingUiState( bookingSummary = booking.toBookingSummaryModel(), bookingsAppointmentDetails = booking.toAppointmentDetailsModel(), From b1687573feeffc7fa691ac4b22fe4159ee915a67 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Mon, 6 Oct 2025 13:11:06 +0200 Subject: [PATCH 11/13] Update the code so that the DB is not observed when app in background --- .../details/BookingDetailsViewModel.kt | 108 ++++++++---------- .../details/BookingDetailsViewModelTest.kt | 55 ++++----- 2 files changed, 73 insertions(+), 90 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt index 2e2f96cfb67..82e31132530 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt @@ -15,88 +15,76 @@ import com.woocommerce.android.viewmodel.ScopedViewModel import com.woocommerce.android.viewmodel.navArgs import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull import javax.inject.Inject @HiltViewModel class BookingDetailsViewModel @Inject constructor( savedState: SavedStateHandle, resourceProvider: ResourceProvider, - private val bookingsRepository: BookingsRepository, + bookingsRepository: BookingsRepository, private val bookingMapper: BookingMapper, ) : ScopedViewModel(savedState) { private val navArgs: BookingDetailsFragmentArgs by savedState.navArgs() - private val _state = MutableStateFlow( - BookingDetailsViewState( - onCancelBooking = ::onCancelBooking, - onAttendanceStatusSelected = ::onAttendanceStatusSelected, - ) - ) - val state: LiveData = _state.asLiveData() + private val booking = bookingsRepository.observeBooking(navArgs.bookingId) - init { - _state.update { - it.copy( - toolbarTitle = resourceProvider.getString(R.string.booking_details_title, navArgs.bookingId), + // Temporary, the booking status should come from the stored object + private val bookingAttendanceStatus = MutableStateFlow(null) + + val state: LiveData = combine( + booking.filterNotNull(), + bookingAttendanceStatus + ) { booking, attendanceStatus -> + with(bookingMapper) { + BookingDetailsViewState( + toolbarTitle = resourceProvider.getString(R.string.booking_details_title, booking.id.value), + orderId = booking.orderId, + bookingUiState = buildBookingUiState(booking, attendanceStatus), + onCancelBooking = ::onCancelBooking, + onAttendanceStatusSelected = ::onAttendanceStatusSelected ) } - observeBooking(navArgs.bookingId) - } + }.asLiveData() private fun onAttendanceStatusSelected(status: BookingAttendanceStatus) { - val bookingState = _state.value.bookingUiState - if (bookingState != null) { - _state.update { current -> - current.copy( - bookingUiState = bookingState.copy( - bookingSummary = bookingState.bookingSummary.copy(attendanceStatus = status) - ) - ) - } - } + // Temporary, the booking status should come from the stored object + bookingAttendanceStatus.value = status } private fun onCancelBooking() { // TODO Add logic to Cancel booking } - private fun observeBooking(bookingId: Long) { - bookingsRepository.observeBooking(bookingId) - .onEach { booking -> - booking?.let { updateStateWithBooking(it) } + private fun BookingMapper.buildBookingUiState( + booking: Booking, + attendanceStatus: BookingAttendanceStatus? + ): BookingUiState = BookingUiState( + bookingSummary = booking.toBookingSummaryModel().let { + if (attendanceStatus != null) { + it.copy(attendanceStatus = attendanceStatus) + } else { + it } - .launchIn(this) - } - - private fun updateStateWithBooking(booking: Booking) = with(bookingMapper) { - _state.update { current -> - current.copy( - orderId = booking.orderId, - bookingUiState = BookingUiState( - bookingSummary = booking.toBookingSummaryModel(), - bookingsAppointmentDetails = booking.toAppointmentDetailsModel(), - bookingCustomerDetails = BookingCustomerDetailsModel( - name = "Margarita Nikolaevna", - email = "margarita@example.com", - phone = "+1 555-123-4567", - billingAddressLines = listOf( - "238 Willow Creek Drive", - "Montgomery AL 36109", - "United States" - ) - ), - bookingPaymentDetails = BookingPaymentDetailsModel( - service = "$55.00", - tax = "$4.50", - discount = "-", - total = "$59.50" - ) - ), + }, + bookingsAppointmentDetails = booking.toAppointmentDetailsModel(), + bookingCustomerDetails = BookingCustomerDetailsModel( + name = "Margarita Nikolaevna", + email = "margarita@example.com", + phone = "+1 555-123-4567", + billingAddressLines = listOf( + "238 Willow Creek Drive", + "Montgomery AL 36109", + "United States" ) - } - } + ), + bookingPaymentDetails = BookingPaymentDetailsModel( + service = "$55.00", + tax = "$4.50", + discount = "-", + total = "$59.50" + ) + ) } 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 6461dd15a71..072d44f4e04 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 @@ -11,12 +11,13 @@ import com.woocommerce.android.util.getOrAwaitValue import com.woocommerce.android.viewmodel.BaseUnitTest import com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.wordpress.android.fluxc.model.LocalOrRemoteId @@ -28,44 +29,42 @@ import java.time.Instant class BookingDetailsViewModelTest : BaseUnitTest() { private val currencyFormatter = mock() + private val resourceProvider = mock() private val bookingMapper = BookingMapper(currencyFormatter) - private val bookingFlow = MutableSharedFlow() + private val initialBooking = getSampleBooking(1) + private val bookingFlow = MutableStateFlow(initialBooking) @Before fun setup() { whenever(currencyFormatter.formatCurrency(any(), any(), any())).thenReturn("$0.00") + whenever( + resourceProvider.getString( + eq(R.string.booking_details_title), + any() + ) + ).thenReturn("Booking #${initialBooking.id.value}") } @Test - fun `given bookingId in SavedStateHandle, when ViewModel created, then toolbar title formatted`() { + fun `given booking, when emitted after ViewModel created, then toolbar title uses booking id`() = testBlocking { // Given - val bookingId = 123L - val savedState = SavedStateHandle(mapOf("bookingId" to bookingId)) - val resourceProvider = mock { - on { getString(R.string.booking_details_title, bookingId) } doReturn "Booking #$bookingId" - } + val savedState = SavedStateHandle(mapOf("bookingId" to 123L)) + val expectedBookingId = 1L // When - val viewModel = createViewModel(savedState, resourceProvider) + val viewModel = createViewModel(savedState) // Then - val state = viewModel.state.value - assertThat(state?.toolbarTitle).isEqualTo("Booking #$bookingId") + val state = viewModel.state.getOrAwaitValue() + assertThat(state.toolbarTitle).isEqualTo("Booking #$expectedBookingId") } @Test - fun `when onAttendanceStatusSelected called, then state updates with new status`() { + fun `when onAttendanceStatusSelected called, then state updates with new status`() = testBlocking { // Given - val bookingId = 456L - val savedState = SavedStateHandle(mapOf("bookingId" to bookingId)) - val resourceProvider = mock { - on { getString(R.string.booking_details_title, bookingId) } doReturn "Booking #$bookingId" - } - val viewModel = createViewModel(savedState, resourceProvider) - - val booking = getSampleBooking(1) - testBlocking { bookingFlow.emit(booking) } + val savedState = SavedStateHandle(mapOf("bookingId" to 456L)) + val viewModel = createViewModel(savedState) // When val state = viewModel.state.getOrAwaitValue() @@ -77,14 +76,10 @@ class BookingDetailsViewModelTest : BaseUnitTest() { } @Test - fun `given booking emitted, when observed by ViewModel, then state contains mapped objects`() = testBlocking { + fun `given booking emitted, when observed by ViewModel, then state is updated`() = testBlocking { // Given - val bookingId = 789L - val savedState = SavedStateHandle(mapOf("bookingId" to bookingId)) - val resourceProvider = mock { - on { getString(R.string.booking_details_title, bookingId) } doReturn "Booking #$bookingId" - } - val viewModel = createViewModel(savedState, resourceProvider) + val savedState = SavedStateHandle(mapOf("bookingId" to 789L)) + val viewModel = createViewModel(savedState) // When val booking = getSampleBooking(2) @@ -93,11 +88,11 @@ class BookingDetailsViewModelTest : BaseUnitTest() { // Then val state = viewModel.state.getOrAwaitValue() assertThat(state.bookingUiState).isNotNull + assertThat(state.orderId).isEqualTo(2L) } private fun createViewModel( savedState: SavedStateHandle, - resourceProvider: ResourceProvider ): BookingDetailsViewModel { val bookingsRepository = mock { on { observeBooking(any()) } doReturn bookingFlow @@ -128,7 +123,7 @@ class BookingDetailsViewModelTest : BaseUnitTest() { dateCreated = Instant.now(), dateModified = Instant.now(), googleCalendarEventId = "", - orderId = 1L, + orderId = id.toLong(), orderItemId = 1L, parentId = 0L, personCounts = listOf(1L), From 2df6a27db537621c857665e82e88d02be28aabb5 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Mon, 6 Oct 2025 20:34:57 +0200 Subject: [PATCH 12/13] Use FormatStyle.FULL instead of hardcoded date format --- .../kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt | 2 +- .../com/woocommerce/android/ui/bookings/BookingMapperTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 9fb2ea39359..043c2aa758f 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 @@ -21,7 +21,7 @@ class BookingMapper @Inject constructor( FormatStyle.SHORT ).withZone(ZoneOffset.UTC) - private val detailsDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy") + private val detailsDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL) .withZone(ZoneOffset.UTC) private val timeRangeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) .withZone(ZoneOffset.UTC) 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 ecac0747f01..47346661227 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 @@ -73,7 +73,7 @@ class BookingMapperTest : BaseUnitTest() { whenever(currencyFormatter.formatCurrency(eq("55.00"), eq("USD"), eq(true))).thenReturn("$55.00") - val expectedDate = DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy") + val expectedDate = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL) .withZone(ZoneOffset.UTC) .format(start) val timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneOffset.UTC) From 152b26632736029404966c385f0a3a04272df91d Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Mon, 6 Oct 2025 20:44:18 +0200 Subject: [PATCH 13/13] Rename Booking.toUiModel to toListItem --- .../kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt | 2 +- .../android/ui/bookings/list/BookingListViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 043c2aa758f..0f060824f6c 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 @@ -37,7 +37,7 @@ class BookingMapper @Inject constructor( ) } - fun Booking.toUiModel(): BookingListItem { + fun Booking.toListItem(): BookingListItem { return BookingListItem( id = id.value, summary = toBookingSummaryModel() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt index 2d8002c05fd..08e8bd1c8d2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt @@ -55,7 +55,7 @@ class BookingListViewModel @Inject constructor( private val contentState = combine( bookingListHandler.bookingsFlow.map { bookings -> - with(bookingMapper) { bookings.map { it.toUiModel() } } + with(bookingMapper) { bookings.map { it.toListItem() } } }, loadingState ) { bookings, loadingState ->