Skip to content

Conversation

@AdamGrzybkowski
Copy link
Contributor

WOOMOB-1400

Description

The main goal of this PR is to implement BookingRepository in the BookingDetailsViewModel to observe the booking object in the DB.

This only observes the local object. We will have to fetch more information anyway, so that's where I think we can add refreshing of the booking model as well (if we think it's necessary).

Additionally, since we have to handle object mapping, I went ahead and created a mapper class BookingMapper, for all booking-related models. In one of the commits, I have added this mapper to the BookingListViewModel as well.

Steps to reproduce

  1. Use a CIAB site.
  2. Manually install the bookings plugin from this ZIP file: p1758531103469849/1758101141.913349-slack-C03L1NF1EA3
  3. You'll need to enable bookings v2 on your CIAB testing site. You can do that by installing the Code Snippets plugin to your site and adding the following PHP snippet: define( 'WC_BOOKINGS_NEXT_ENABLED', true );
  4. Create some bookings.
  5. Open the app and go to the booking tab
  6. Notice the data/time info on one of the booking item
  7. Tap it
  8. Confirm the same information is visible in the details screen

Testing information

No UI changes. The above should be enough.

The tests that have been performed

The above

Images/gif

Screen_recording_20251002_171432.mp4
  • I have considered if this change warrants release notes and have added them to RELEASE-NOTES.txt if necessary. Use the "[Internal]" label for non-user-facing changes.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements BookingRepository in BookingDetailsViewModel to observe booking objects from the database, allowing real-time updates of booking data in the details screen. It also introduces a centralized BookingMapper class to handle all booking-related model mappings and refactors the existing code to use this mapper.

Key Changes

  • Added database observation capability for individual bookings through new DAO and repository methods
  • Created a centralized BookingMapper class to handle all booking model transformations
  • Refactored the booking details screen to use observed data instead of hardcoded values

Reviewed Changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
BookingsDao.kt Added observeBooking method to query and observe individual booking entities
BookingsStore.kt Added observeBooking method to expose booking observation through the store layer
BookingsRepository.kt Added observeBooking method to provide booking observation at the repository level
BookingMapper.kt New centralized mapper class for all booking-related model transformations
BookingDetailsViewModel.kt Updated to observe booking data from repository and use mapper for transformations
BookingDetailsViewState.kt Refactored state structure to use nullable BookingUiState instead of hardcoded values
BookingDetailsScreen.kt Updated to handle nullable booking state and display content conditionally
BookingListViewModel.kt Updated to use the new centralized BookingMapper
BookingListViewState.kt Removed inline mapping functions that were moved to BookingMapper
Test files Updated and added tests to support the new mapper and repository integration

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

@dangermattic
Copy link
Collaborator

1 Warning
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger

Comment on lines +17 to +22
data class BookingUiState(
val bookingSummary: BookingSummaryModel,
val bookingsAppointmentDetails: BookingAppointmentDetailsModel,
val bookingCustomerDetails: BookingCustomerDetailsModel,
val bookingPaymentDetails: BookingPaymentDetailsModel,
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All those models would be nullable after this PR, so it's handy to wrap them in a single class to avoid multiple null checks.

Comment on lines +17 to +22
private val currencyFormatter: CurrencyFormatter,
) {
private val summaryDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(
FormatStyle.MEDIUM,
FormatStyle.SHORT
).withZone(ZoneOffset.UTC)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @hichamboushaba I've used the UTC based on your discussion.

@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Oct 2, 2025

📲 You can test the changes from this Pull Request in WooCommerce-Wear Android by scanning the QR code below to install the corresponding build.
App Name WooCommerce-Wear Android
Platform⌚️ Wear OS
FlavorJalapeno
Build TypeDebug
Commit152b266
Direct Downloadwoocommerce-wear-prototype-build-pr14682-152b266.apk

@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Oct 2, 2025

📲 You can test the changes from this Pull Request in WooCommerce Android by scanning the QR code below to install the corresponding build.

App Name WooCommerce Android
Platform📱 Mobile
FlavorJalapeno
Build TypeDebug
Commit152b266
Direct Downloadwoocommerce-prototype-build-pr14682-152b266.apk

@AdamGrzybkowski AdamGrzybkowski force-pushed the issue/WOOMOB-1400_observe_booking branch from 7c821d7 to 4533d98 Compare October 3, 2025 08:37
@hichamboushaba hichamboushaba self-assigned this Oct 3, 2025
@codecov-commenter
Copy link

codecov-commenter commented Oct 3, 2025

Codecov Report

❌ Patch coverage is 87.35632% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.38%. Comparing base (55f8621) to head (152b266).
⚠️ Report is 38 commits behind head on trunk.

Files with missing lines Patch % Lines
...m/woocommerce/android/ui/bookings/BookingMapper.kt 86.11% 0 Missing and 5 partials ⚠️
...commerce/android/ui/bookings/BookingsRepository.kt 0.00% 4 Missing ⚠️
...oid/ui/bookings/details/BookingDetailsViewModel.kt 97.29% 0 Missing and 1 partial ⚠️
...xc/network/rest/wpcom/wc/bookings/BookingsStore.kt 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##              trunk   #14682      +/-   ##
============================================
+ Coverage     38.37%   38.38%   +0.01%     
- Complexity     9842     9855      +13     
============================================
  Files          2097     2098       +1     
  Lines        116949   116974      +25     
  Branches      15651    15653       +2     
============================================
+ Hits          44876    44900      +24     
- Misses        67908    67909       +1     
  Partials       4165     4165              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@AdamGrzybkowski
Copy link
Contributor Author

@hichamboushaba I pushed one extra commit with a one-liner that I forgot to do after rebasing with trunk. 509794d

toolbarTitle = resourceProvider.getString(R.string.booking_details_title, navArgs.bookingId),
)
}
observeBooking(navArgs.bookingId)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before approving this, I just want to discuss one problem with this approach and decide whether we are OK with it or not.
With this approach, we observe the DB regardless of whether the app is in the foreground or not (this is part of what we discussed before on whether we should expose flows or LiveData).

I think a better approach (IMO 🙂) would be to have a cold flow exposed as LiveData, so the cold flow won't start until the screen reaches the Started lifecycle state, and to achieve it, we can think of something like the following patch:

Index: WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
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
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt	(revision 509794d7b5731f41a1b4ea3847de4599bcf8a3f5)
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt	(date 1759502430662)
@@ -4,7 +4,6 @@
 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
@@ -15,69 +14,41 @@
 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<BookingDetailsViewState> = _state.asLiveData()
-
-    init {
-        _state.update {
-            it.copy(
-                toolbarTitle = resourceProvider.getString(R.string.booking_details_title, navArgs.bookingId),
-            )
-        }
-        observeBooking(navArgs.bookingId)
-    }
-
-    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)
-                    )
-                )
-            }
-        }
-    }
-
-    private fun onCancelBooking() {
-        // TODO Add logic to Cancel booking
-    }
-
-    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(
+    private val booking = bookingsRepository.observeBooking(navArgs.bookingId)
+
+    // If we need this, then using `savedState.getStateFlow` would be better here to preserve state across process death,
+    private val bookingAttendanceStatus = MutableStateFlow<BookingAttendanceStatus?>(null)
+
+    val state: LiveData<BookingDetailsViewState> = combine(
+        booking.filterNotNull(),
+        bookingAttendanceStatus
+    ) { booking, attendanceStatus ->
+        with(bookingMapper) {
+            BookingDetailsViewState(
+                toolbarTitle = resourceProvider.getString(R.string.booking_details_title, booking.id),
                 orderId = booking.orderId,
                 bookingUiState = BookingUiState(
-                    bookingSummary = booking.toBookingSummaryModel(),
+                    bookingSummary = booking.toBookingSummaryModel().let {
+                        if (attendanceStatus != null) {
+                            it.copy(attendanceStatus = attendanceStatus)
+                        } else {
+                            it
+                        }
+                    },
                     bookingsAppointmentDetails = booking.toAppointmentDetailsModel(),
                     bookingCustomerDetails = BookingCustomerDetailsModel(
                         name = "Margarita Nikolaevna",
@@ -96,7 +67,24 @@
                         total = "$59.50"
                     )
                 ),
+                onCancelBooking = ::onCancelBooking,
+                onAttendanceStatusSelected = ::onAttendanceStatusSelected
             )
         }
+    }.asLiveData()
+
+    private fun onAttendanceStatusSelected(status: BookingAttendanceStatus) {
+        // Whether we'll need the `bookingAttendanceStatus` property or not depending
+        // on whether we'll have a "save" button for the whole screen or we'll update the status
+        // as soon as the user selects it from the bottom sheet.
+        // Personally I think we should update it immediately, and if we do, then we don't need the property
+        // we just need to manage the loading state, and when the API call is done, the booking will be updated accordingly
+        // via the `booking` flow.
+        // But this is a product decision. And this patch just preserves the current behavior.
+        bookingAttendanceStatus.value = status
+    }
+
+    private fun onCancelBooking() {
+        // TODO Add logic to Cancel booking
     }
 }

The patch also makes the whole screen reactive, so if during the implementation we find that we need the Order (which most probably will happen), then we can chain the Flows to observe also the Order from the DB, and then the DB will be the single source of truth, and drives the screen.

Please check it out, and share your thoughts. While I think observing the DB when the app is in the background should be avoided, if you don't agree with the above approach, I'm OK with accepting the PR as it's.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check it out, and share your thoughts. While I think observing the DB when the app is in the background should be avoided, if you don't agree with the above approach, I'm OK with accepting the PR as it's.

Thanks @hichamboushaba! You're obviously correct. I think I was working on an autopilot, not being used to LiveData 🤦

I will update the PR on Monday!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b168757

Copy link
Member

@hichamboushaba hichamboushaba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work @AdamGrzybkowski 💯, works well, I left two remarks, but they are not blockers.

FormatStyle.SHORT
).withZone(ZoneOffset.UTC)

private val detailsDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we should avoid hardcording formatting pattens, I'd use a localized format instead. I know it's used in the app is some spots, but IMO it's not something we should follow.

Suggested change
private val detailsDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy")
private val detailsDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)

The above will update the text from: Monday, 06 Oct 2025 to Monday, 6 October 2025 for UK english set as the locale.

Another approach if we are sure we want to keep the shorter month name is to use something like:

    private val detailsDateFormatter: DateTimeFormatter = DateTimeFormatterBuilder()
        .appendPattern("EEEE, ")
        .append(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
        .toFormatter()
        .withZone(ZoneOffset.UTC)

But personally I think using FormatStyle.FULL is better in this context, WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I did focus on the format I saw in the Figma, but this will work better across different locales. Thanks!

)
}

fun Booking.toUiModel(): BookingListItem {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

np, just to make the role a bit clearer, I'd rename this to toListItem(), WDYT?

@AdamGrzybkowski AdamGrzybkowski merged commit 55e165a into trunk Oct 6, 2025
15 checks passed
@AdamGrzybkowski AdamGrzybkowski deleted the issue/WOOMOB-1400_observe_booking branch October 6, 2025 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants