From b0ec359deb2495813910bc3367dd1715a2be4364 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Tue, 14 Oct 2025 15:28:14 +0200 Subject: [PATCH 1/6] Remove cancelled option from BookingAttendanceStatusBottomSheet --- .../BookingAttendanceStatusBottomSheet.kt | 28 +++++++++++-------- .../res/drawable/ic_attendance_cancelled.xml | 9 ------ WooCommerce/src/main/res/values/strings.xml | 1 - 3 files changed, 17 insertions(+), 21 deletions(-) delete mode 100644 WooCommerce/src/main/res/drawable/ic_attendance_cancelled.xml diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAttendanceStatusBottomSheet.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAttendanceStatusBottomSheet.kt index ebb8952dd194..64857ae1182b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAttendanceStatusBottomSheet.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAttendanceStatusBottomSheet.kt @@ -70,7 +70,11 @@ private fun BookingAttendanceStatusSelection( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 22.dp) ) - BookingAttendanceStatus.entries.forEachIndexed { index, status -> + listOf( + BookingAttendanceStatus.BOOKED, + BookingAttendanceStatus.CHECKED_IN, + BookingAttendanceStatus.NO_SHOW, + ).forEachIndexed { index, status -> AttendanceStatusRow( status = status, onClick = { onSelect(status) } @@ -92,12 +96,14 @@ private fun AttendanceStatusRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - Icon( - painter = painterResource(status.iconRes), - contentDescription = status.text(), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp) - ) + status.iconRes?.let { iconRes -> + Icon( + painter = painterResource(iconRes), + contentDescription = status.text(), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } Column(modifier = Modifier.weight(1f)) { Text( text = status.text(), @@ -118,16 +124,16 @@ private fun AttendanceStatusRow( private fun BookingAttendanceStatus.description(): String = when (this) { BookingAttendanceStatus.BOOKED -> R.string.booking_attendance_status_booked_desc BookingAttendanceStatus.CHECKED_IN -> R.string.booking_attendance_status_checked_in_desc - BookingAttendanceStatus.CANCELLED -> R.string.booking_attendance_status_cancelled_desc BookingAttendanceStatus.NO_SHOW -> R.string.booking_attendance_status_no_show_desc -}.let { stringResource(it) } + else -> null +}.let { it?.let { id -> stringResource(id) } ?: "" } -private val BookingAttendanceStatus.iconRes: Int +private val BookingAttendanceStatus.iconRes: Int? get() = when (this) { BookingAttendanceStatus.BOOKED -> R.drawable.ic_attendance_booked BookingAttendanceStatus.CHECKED_IN -> R.drawable.ic_attendance_checked_in - BookingAttendanceStatus.CANCELLED -> R.drawable.ic_attendance_cancelled BookingAttendanceStatus.NO_SHOW -> R.drawable.ic_attendance_no_show + else -> null } @LightDarkThemePreviews diff --git a/WooCommerce/src/main/res/drawable/ic_attendance_cancelled.xml b/WooCommerce/src/main/res/drawable/ic_attendance_cancelled.xml deleted file mode 100644 index 058c63641342..000000000000 --- a/WooCommerce/src/main/res/drawable/ic_attendance_cancelled.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 15c48dc2dd8c..2a77fa92f733 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4227,7 +4227,6 @@ Update attendance status The appointment is scheduled but hasn’t happened yet. The customer arrived and the session took place as planned. - The client will no longer be able to attend. The client missed the appointment without canceling in advance. PAYMENT Service From 1916d68fcbd3a809f653301e45c9405b5452ca51 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Tue, 14 Oct 2025 16:19:33 +0200 Subject: [PATCH 2/6] Add a Cancel booking confirmation dialog --- .../android/ui/bookings/BookingMapper.kt | 17 ++++ .../bookings/compose/CancelBookingDialog.kt | 45 +++++++++++ .../bookings/details/BookingDetailsScreen.kt | 8 ++ .../details/BookingDetailsViewModel.kt | 28 +++++-- .../details/BookingDetailsViewState.kt | 6 +- WooCommerce/src/main/res/values/strings.xml | 4 + .../android/ui/bookings/BookingMapperTest.kt | 81 +++++++++++++++++-- .../details/BookingDetailsViewModelTest.kt | 62 ++++++++++++++ 8 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/CancelBookingDialog.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 index 632008ca0f91..be48c2bf0052 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,5 +1,6 @@ package com.woocommerce.android.ui.bookings +import com.woocommerce.android.R import com.woocommerce.android.extensions.isNotEqualTo import com.woocommerce.android.model.Address import com.woocommerce.android.model.GetLocations @@ -11,6 +12,7 @@ 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 com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingCustomerInfo @@ -106,6 +108,21 @@ class BookingMapper @Inject constructor( return "${billingFirstName.orEmpty()} ${billingLastName.orEmpty()}".trim().ifEmpty { null } } + fun buildCancelDialogMessage(booking: Booking, resourceProvider: ResourceProvider): String { + val customerName = booking.order.customerInfo?.fullName() + ?: resourceProvider.getString(R.string.customer_detail_guest_customer) + val serviceName = booking.order.productInfo?.name ?: "-" + val date = detailsDateFormatter.format(booking.start) + val time = timeRangeFormatter.format(booking.start) + return resourceProvider.getString( + R.string.booking_cancel_dialog_message, + customerName, + serviceName, + date, + time + ) + } + private suspend fun BookingCustomerInfo.address(): Address? { val countryCode = billingCountry ?: return null val (country, state) = withContext(Dispatchers.IO) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/CancelBookingDialog.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/CancelBookingDialog.kt new file mode 100644 index 000000000000..503b9061d4c5 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/CancelBookingDialog.kt @@ -0,0 +1,45 @@ +package com.woocommerce.android.ui.bookings.compose + +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.component.AlertDialog + +@Composable +fun CancelBookingDialog( + message: String, + onDismiss: () -> Unit, + onConfirmCancel: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(id = R.string.booking_cancel_dialog_title), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { Text(text = message) }, + confirmButton = { + TextButton( + onClick = onConfirmCancel, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(text = stringResource(id = R.string.booking_cancel_dialog_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface), + ) { + Text(text = stringResource(id = R.string.booking_cancel_dialog_keep)) + } + }, + neutralButton = { } + ) +} 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 2e53c081643b..d1450279f44b 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 @@ -41,6 +41,7 @@ import com.woocommerce.android.ui.bookings.compose.BookingPaymentSection import com.woocommerce.android.ui.bookings.compose.BookingStatus import com.woocommerce.android.ui.bookings.compose.BookingSummary import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel +import com.woocommerce.android.ui.bookings.compose.CancelBookingDialog import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.component.Toolbar import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews @@ -110,6 +111,13 @@ fun BookingDetailsScreen( onDismiss = { showAttendanceSheet.value = false } ) } + if (viewState.showCancelBookingDialog) { + CancelBookingDialog( + message = viewState.cancelDialogMessage, + onDismiss = viewState.onDismissCancelDialog, + onConfirmCancel = viewState.onConfirmCancelBooking, + ) + } } } 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 489c2e355d88..2c66ef5bb4e6 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 @@ -19,7 +19,7 @@ import javax.inject.Inject @HiltViewModel class BookingDetailsViewModel @Inject constructor( savedState: SavedStateHandle, - resourceProvider: ResourceProvider, + private val resourceProvider: ResourceProvider, bookingsRepository: BookingsRepository, private val bookingMapper: BookingMapper, ) : ScopedViewModel(savedState) { @@ -30,19 +30,28 @@ class BookingDetailsViewModel @Inject constructor( // Temporary, the booking status should come from the stored object private val bookingAttendanceStatus = MutableStateFlow(null) + private val showCancelDialog = MutableStateFlow(false) val state: LiveData = combine( booking, - bookingAttendanceStatus - ) { booking, attendanceStatus -> + bookingAttendanceStatus, + showCancelDialog + ) { booking, attendanceStatus, showDialog -> with(bookingMapper) { + val cancelMessage = booking?.let { + buildCancelDialogMessage(booking, resourceProvider) + } ?: "" BookingDetailsViewState( toolbarTitle = booking?.id?.value?.let { id -> resourceProvider.getString(R.string.booking_details_title, id) } ?: "", bookingUiState = if (booking != null) buildBookingUiState(booking, attendanceStatus) else null, onCancelBooking = ::onCancelBooking, - onAttendanceStatusSelected = ::onAttendanceStatusSelected + onAttendanceStatusSelected = ::onAttendanceStatusSelected, + showCancelBookingDialog = showDialog, + cancelDialogMessage = cancelMessage, + onDismissCancelDialog = ::onDismissCancelDialog, + onConfirmCancelBooking = ::onConfirmCancelBooking, ) } }.asLiveData() @@ -53,7 +62,16 @@ class BookingDetailsViewModel @Inject constructor( } private fun onCancelBooking() { - // TODO Add logic to Cancel booking + showCancelDialog.value = true + } + + private fun onDismissCancelDialog() { + showCancelDialog.value = false + } + + private fun onConfirmCancelBooking() { + // TODO Add logic to Cancel booking action + showCancelDialog.value = false } private suspend fun BookingMapper.buildBookingUiState( 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 b9f780c3ec70..2fddacbd348c 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 @@ -10,7 +10,11 @@ data class BookingDetailsViewState( val toolbarTitle: String = "", val bookingUiState: BookingUiState? = null, val onCancelBooking: () -> Unit = {}, - val onAttendanceStatusSelected: (BookingAttendanceStatus) -> Unit = { _ -> } + val onAttendanceStatusSelected: (BookingAttendanceStatus) -> Unit = { _ -> }, + val showCancelBookingDialog: Boolean = false, + val cancelDialogMessage: String = "", + val onDismissCancelDialog: () -> Unit = {}, + val onConfirmCancelBooking: () -> Unit = {}, ) { val shouldShowSkeleton: Boolean = bookingUiState == null diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 2a77fa92f733..b1855d57cde4 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4258,4 +4258,8 @@ Automattic logo Back icon App icon + Cancel booking + %1$s will no longer be able to attend “%2$s” on %3$s at %4$s. + No, keep it + Yes, cancel it 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 1537af7a6f3b..79670f2119e5 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 @@ -1,10 +1,12 @@ package com.woocommerce.android.ui.bookings +import com.woocommerce.android.R import com.woocommerce.android.model.GetLocations 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 com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -163,12 +165,84 @@ class BookingMapperTest : BaseUnitTest() { assertThat(model.total).isEqualTo("$110.00") } + @Test + fun `given booking, when building cancel dialog message, then formats using booking details`() { + // GIVEN + val resourceProvider = mock() + val start = Instant.parse("2025-09-12T16:00:00Z") + val booking = sampleBooking(start = start, end = start.plus(Duration.ofHours(1))) + val expectedDate = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withZone(ZoneOffset.UTC).format(start) + val expectedTime = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneOffset.UTC).format(start) + whenever( + resourceProvider.getString( + eq(R.string.booking_cancel_dialog_message), + any(), + any(), + any(), + any() + ) + ).thenAnswer { + val name = it.getArgument(1) + val service = it.getArgument(2) + val date = it.getArgument(3) + val time = it.getArgument(4) + "$name will no longer be able to attend “$service” on $date at $time." + } + + // WHEN + val message = mapper.buildCancelDialogMessage(booking, resourceProvider) + + // THEN + assertThat(message).isEqualTo( + "${booking.order.customerInfo?.billingFirstName} ${booking.order.customerInfo?.billingLastName} will no " + + "longer be able to attend “${booking.order.productInfo?.name}” on $expectedDate at $expectedTime." + ) + } + + @Test + fun `given booking without customer, when building cancel dialog message, then falls back to guest`() { + // GIVEN + val resourceProvider = mock() + whenever(resourceProvider.getString(eq(R.string.customer_detail_guest_customer))).thenReturn("Guest") + val start = Instant.parse("2025-09-12T16:00:00Z") + val bookingWithoutCustomer = sampleBooking(start = start, customerInfo = null) + val expectedDate = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withZone(ZoneOffset.UTC).format(start) + val expectedTime = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneOffset.UTC).format(start) + whenever( + resourceProvider.getString( + eq(R.string.booking_cancel_dialog_message), + any(), + any(), + any(), + any() + ) + ).thenAnswer { + val name = it.getArgument(1) + val service = it.getArgument(2) + val date = it.getArgument(3) + val time = it.getArgument(4) + "$name will no longer be able to attend “$service” on $date at $time." + } + + // WHEN + val message = mapper.buildCancelDialogMessage(bookingWithoutCustomer, resourceProvider) + + // THEN + assertThat(message).isEqualTo( + "Guest will no longer be able to attend “Women’s Haircut” on $expectedDate at $expectedTime." + ) + } + 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" + currency: String = "USD", + customerInfo: BookingCustomerInfo? = BookingCustomerInfo( + billingFirstName = "Margarita", + billingLastName = "Nikolaevna" + ) ): BookingEntity { return BookingEntity( id = LocalOrRemoteId.RemoteId(1L), @@ -193,10 +267,7 @@ class BookingMapperTest : BaseUnitTest() { order = BookingOrderInfo( status = "completed", productInfo = BookingProductInfo(name = "Women’s Haircut"), - customerInfo = BookingCustomerInfo( - billingFirstName = "Margarita", - billingLastName = "Nikolaevna" - ) + customerInfo = customerInfo, ) ) } 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 a9bb29de0ef5..acdd1fb808dd 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 @@ -47,6 +47,18 @@ class BookingDetailsViewModelTest : BaseUnitTest() { any() ) ).thenReturn("Booking #${initialBooking.id.value}") + // Stub guest customer fallback used by cancel message + whenever(resourceProvider.getString(eq(R.string.customer_detail_guest_customer))).thenReturn("Guest") + // Stub cancel dialog message formatting regardless of inputs + whenever( + resourceProvider.getString( + eq(R.string.booking_cancel_dialog_message), + any(), + any(), + any(), + any() + ) + ).thenReturn("Formatted cancel message") } @Test @@ -94,6 +106,56 @@ class BookingDetailsViewModelTest : BaseUnitTest() { assertThat(state.bookingUiState?.orderId).isEqualTo(2L) } + @Test + fun `when onCancelBooking called, then cancel dialog is shown with message`() = testBlocking { + // Given + val savedState = SavedStateHandle(mapOf("bookingId" to 111L)) + val viewModel = createViewModel(savedState) + val state = viewModel.state.getOrAwaitValue() + + // When + state.onCancelBooking() + + // Then + val updated = viewModel.state.getOrAwaitValue() + assertThat(updated.showCancelBookingDialog).isTrue() + assertThat(updated.cancelDialogMessage).isEqualTo("Formatted cancel message") + } + + @Test + fun `given cancel dialog shown, when onDismissCancelDialog called, then dialog is hidden`() = testBlocking { + // Given + val savedState = SavedStateHandle(mapOf("bookingId" to 222L)) + val viewModel = createViewModel(savedState) + val state = viewModel.state.getOrAwaitValue() + state.onCancelBooking() + assertThat(viewModel.state.getOrAwaitValue().showCancelBookingDialog).isTrue() + + // When + viewModel.state.getOrAwaitValue().onDismissCancelDialog() + + // Then + val updated = viewModel.state.getOrAwaitValue() + assertThat(updated.showCancelBookingDialog).isFalse() + } + + @Test + fun `given cancel dialog shown, when onConfirmCancelBooking called, then dialog is hidden`() = testBlocking { + // Given + val savedState = SavedStateHandle(mapOf("bookingId" to 333L)) + val viewModel = createViewModel(savedState) + val state = viewModel.state.getOrAwaitValue() + state.onCancelBooking() + assertThat(viewModel.state.getOrAwaitValue().showCancelBookingDialog).isTrue() + + // When + viewModel.state.getOrAwaitValue().onConfirmCancelBooking() + + // Then + val updated = viewModel.state.getOrAwaitValue() + assertThat(updated.showCancelBookingDialog).isFalse() + } + private fun createViewModel( savedState: SavedStateHandle, ): BookingDetailsViewModel { From bf3935b16d029152d79160b5c52fa1ae5b554640 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Tue, 14 Oct 2025 16:34:49 +0200 Subject: [PATCH 3/6] Show progress indicator when cancelling the booking --- .../android/ui/bookings/BookingMapper.kt | 6 +++-- .../compose/BookingAppointmentDetails.kt | 27 +++++++++++++++---- .../bookings/details/BookingDetailsScreen.kt | 3 ++- .../details/BookingDetailsViewModel.kt | 25 ++++++++++++----- .../details/BookingDetailsViewState.kt | 5 ++++ .../android/ui/bookings/BookingMapperTest.kt | 4 ++- 6 files changed, 55 insertions(+), 15 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 be48c2bf0052..fab379e07460 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 @@ -10,6 +10,7 @@ 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 +import com.woocommerce.android.ui.bookings.details.CancelState import com.woocommerce.android.ui.bookings.list.BookingListItem import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.viewmodel.ResourceProvider @@ -56,7 +57,7 @@ class BookingMapper @Inject constructor( ) } - fun Booking.toAppointmentDetailsModel(): BookingAppointmentDetailsModel { + fun Booking.toAppointmentDetailsModel(cancelState: CancelState): BookingAppointmentDetailsModel { val durationMinutes = Duration.between(start, end).toMinutes() return BookingAppointmentDetailsModel( date = detailsDateFormatter.format(start), @@ -65,7 +66,8 @@ class BookingMapper @Inject constructor( staff = "Marianne Renoir", location = "238 Willow Creek Drive, Montgomery AL 36109", duration = "$durationMinutes min", - price = currencyFormatter.formatCurrency(cost, currency) + price = currencyFormatter.formatCurrency(cost, currency), + cancelState = cancelState, ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAppointmentDetails.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAppointmentDetails.kt index 7338e79f096d..b4b719232846 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAppointmentDetails.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAppointmentDetails.kt @@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -17,6 +20,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.woocommerce.android.R +import com.woocommerce.android.ui.bookings.details.CancelState import com.woocommerce.android.ui.compose.component.WCOutlinedButton import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground @@ -62,9 +66,17 @@ fun BookingAppointmentDetails( colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.onSurface ), - onClick = onCancelBooking + onClick = onCancelBooking, + enabled = model.cancelButtonEnabled, ) { - Text(text = stringResource(R.string.booking_details_cancel_booking_button)) + if (model.cancelInProgressShown) { + CircularProgressIndicator( + color = LocalContentColor.current, + modifier = Modifier.size(24.dp) + ) + } else { + Text(text = stringResource(R.string.booking_details_cancel_booking_button)) + } } HorizontalDivider(thickness = 0.5.dp) } @@ -103,8 +115,12 @@ data class BookingAppointmentDetailsModel( val staff: String, val location: String, val duration: String, - val price: String -) + val price: String, + val cancelState: CancelState, +) { + val cancelButtonEnabled: Boolean = cancelState != CancelState.InProgress + val cancelInProgressShown: Boolean = cancelState == CancelState.InProgress +} @LightDarkThemePreviews @Composable @@ -117,7 +133,8 @@ private fun BookingAppointmentDetailsPreview() { staff = "Marianne Renoir", location = "238 Willow Creek Drive, Montgomery AL 36109", duration = "60 min", - price = "$55.00" + price = "$55.00", + cancelState = CancelState.Idle, ), onCancelBooking = {}, modifier = Modifier.fillMaxWidth() 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 d1450279f44b..06a6c08555b6 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 @@ -233,7 +233,8 @@ private fun BookingDetailsPreview() { staff = "Marianne Renoir", location = "238 Willow Creek Drive, Montgomery AL 36109", duration = "60 min", - price = "$55.00" + price = "$55.00", + cancelState = CancelState.Idle, ), bookingCustomerDetails = BookingCustomerDetailsModel( name = "Margarita Nikolaevna", 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 2c66ef5bb4e6..43b399a22ca3 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 @@ -12,8 +12,11 @@ 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.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import java.time.Duration import javax.inject.Inject @HiltViewModel @@ -30,13 +33,15 @@ class BookingDetailsViewModel @Inject constructor( // Temporary, the booking status should come from the stored object private val bookingAttendanceStatus = MutableStateFlow(null) + private val cancelState = MutableStateFlow(CancelState.Idle) private val showCancelDialog = MutableStateFlow(false) val state: LiveData = combine( booking, bookingAttendanceStatus, - showCancelDialog - ) { booking, attendanceStatus, showDialog -> + showCancelDialog, + cancelState, + ) { booking, attendanceStatus, showDialog, cancel -> with(bookingMapper) { val cancelMessage = booking?.let { buildCancelDialogMessage(booking, resourceProvider) @@ -45,7 +50,11 @@ class BookingDetailsViewModel @Inject constructor( toolbarTitle = booking?.id?.value?.let { id -> resourceProvider.getString(R.string.booking_details_title, id) } ?: "", - bookingUiState = if (booking != null) buildBookingUiState(booking, attendanceStatus) else null, + bookingUiState = if (booking != null) { + buildBookingUiState(booking, attendanceStatus, cancel) + } else { + null + }, onCancelBooking = ::onCancelBooking, onAttendanceStatusSelected = ::onAttendanceStatusSelected, showCancelBookingDialog = showDialog, @@ -69,14 +78,18 @@ class BookingDetailsViewModel @Inject constructor( showCancelDialog.value = false } - private fun onConfirmCancelBooking() { + private fun onConfirmCancelBooking() = launch { // TODO Add logic to Cancel booking action showCancelDialog.value = false + cancelState.value = CancelState.InProgress + delay(Duration.ofSeconds(1).toMillis()) + cancelState.value = CancelState.Idle } private suspend fun BookingMapper.buildBookingUiState( booking: Booking, - attendanceStatus: BookingAttendanceStatus? + attendanceStatus: BookingAttendanceStatus?, + cancelState: CancelState, ): BookingUiState = BookingUiState( orderId = booking.orderId, bookingSummary = booking.toBookingSummaryModel().let { @@ -86,7 +99,7 @@ class BookingDetailsViewModel @Inject constructor( it } }, - bookingsAppointmentDetails = booking.toAppointmentDetailsModel(), + bookingsAppointmentDetails = booking.toAppointmentDetailsModel(cancelState), bookingCustomerDetails = booking.order.customerInfo.toCustomerDetailsModel(), bookingPaymentDetails = booking.order.paymentInfo?.toPaymentDetailsModel(booking.currency) ) 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 2fddacbd348c..82a6f19c7a0c 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 @@ -27,3 +27,8 @@ data class BookingUiState( val bookingCustomerDetails: BookingCustomerDetailsModel, val bookingPaymentDetails: BookingPaymentDetailsModel?, ) + +sealed interface CancelState { + data object Idle : CancelState + data object InProgress : CancelState +} 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 79670f2119e5..2277f068242d 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 @@ -4,6 +4,7 @@ import com.woocommerce.android.R import com.woocommerce.android.model.GetLocations import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus import com.woocommerce.android.ui.bookings.compose.BookingStatus +import com.woocommerce.android.ui.bookings.details.CancelState import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.viewmodel.BaseUnitTest import com.woocommerce.android.viewmodel.ResourceProvider @@ -98,7 +99,7 @@ class BookingMapperTest : BaseUnitTest() { val expectedTime = "${timeFormatter.format(start)} - ${timeFormatter.format(end)}" // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel() } + val model = mapper.run { booking.toAppointmentDetailsModel(CancelState.Idle) } // THEN assertThat(model.date).isEqualTo(expectedDate) @@ -107,6 +108,7 @@ class BookingMapperTest : BaseUnitTest() { assertThat(model.location).isEqualTo("238 Willow Creek Drive, Montgomery AL 36109") assertThat(model.duration).isEqualTo("90 min") assertThat(model.price).isEqualTo("$55.00") + assertThat(model.cancelState).isEqualTo(CancelState.Idle) } @Test From b8ea834c0d81bcacf83f6242021ed0f4d9957ddf Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Tue, 14 Oct 2025 17:24:21 +0200 Subject: [PATCH 4/6] Update BookingAttendanceStatus.description() --- .../ui/bookings/compose/BookingAttendanceStatusBottomSheet.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAttendanceStatusBottomSheet.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAttendanceStatusBottomSheet.kt index 64857ae1182b..1c883cdf234a 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAttendanceStatusBottomSheet.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAttendanceStatusBottomSheet.kt @@ -126,7 +126,7 @@ private fun BookingAttendanceStatus.description(): String = when (this) { BookingAttendanceStatus.CHECKED_IN -> R.string.booking_attendance_status_checked_in_desc BookingAttendanceStatus.NO_SHOW -> R.string.booking_attendance_status_no_show_desc else -> null -}.let { it?.let { id -> stringResource(id) } ?: "" } +}?.let { stringResource(it) } ?: "" private val BookingAttendanceStatus.iconRes: Int? get() = when (this) { From d0c3f31f3ec92339187f6a81cc1424a3e308d334 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 16 Oct 2025 10:58:25 +0200 Subject: [PATCH 5/6] Refactor cancel booking flow to use DialogState --- .../android/ui/bookings/BookingMapper.kt | 26 +++--- .../compose/BookingAppointmentDetails.kt | 10 +-- .../bookings/compose/CancelBookingDialog.kt | 45 ----------- .../bookings/details/BookingDetailsScreen.kt | 12 +-- .../details/BookingDetailsViewModel.kt | 80 +++++++++++++------ .../details/BookingDetailsViewState.kt | 12 ++- .../android/ui/bookings/BookingMapperTest.kt | 80 ++++++++----------- .../details/BookingDetailsViewModelTest.kt | 29 +++---- 8 files changed, 123 insertions(+), 171 deletions(-) delete mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/CancelBookingDialog.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 index 74d19d3fc88c..ccc3e9b30bbd 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.R import com.woocommerce.android.extensions.isNotEqualTo import com.woocommerce.android.model.Address import com.woocommerce.android.model.GetLocations +import com.woocommerce.android.model.UiString import com.woocommerce.android.ui.bookings.compose.BookingAppointmentDetailsModel import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus import com.woocommerce.android.ui.bookings.compose.BookingCustomerDetailsModel @@ -11,10 +12,9 @@ import com.woocommerce.android.ui.bookings.compose.BookingPaymentDetailsModel import com.woocommerce.android.ui.bookings.compose.BookingStaffMemberStatus import com.woocommerce.android.ui.bookings.compose.BookingStatus import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel -import com.woocommerce.android.ui.bookings.details.CancelState +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.viewmodel.ResourceProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingCustomerInfo @@ -60,7 +60,7 @@ class BookingMapper @Inject constructor( fun Booking.toAppointmentDetailsModel( staffMemberStatus: BookingStaffMemberStatus?, - cancelState: CancelState, + cancelStatus: CancelStatus, ): BookingAppointmentDetailsModel { val durationMinutes = Duration.between(start, end).toMinutes() return BookingAppointmentDetailsModel( @@ -71,7 +71,7 @@ class BookingMapper @Inject constructor( location = "238 Willow Creek Drive, Montgomery AL 36109", duration = "$durationMinutes min", price = currencyFormatter.formatCurrency(cost, currency), - cancelState = cancelState, + cancelStatus = cancelStatus, ) } @@ -114,18 +114,20 @@ class BookingMapper @Inject constructor( return "${billingFirstName.orEmpty()} ${billingLastName.orEmpty()}".trim().ifEmpty { null } } - fun buildCancelDialogMessage(booking: Booking, resourceProvider: ResourceProvider): String { - val customerName = booking.order.customerInfo?.fullName() - ?: resourceProvider.getString(R.string.customer_detail_guest_customer) + fun buildCancelDialogMessage(booking: Booking): UiString { + val customerName = booking.order.customerInfo?.fullName()?.let { UiString.UiStringText(it) } + ?: UiString.UiStringRes(R.string.customer_detail_guest_customer) val serviceName = booking.order.productInfo?.name ?: "-" val date = detailsDateFormatter.format(booking.start) val time = timeRangeFormatter.format(booking.start) - return resourceProvider.getString( + return UiString.UiStringRes( R.string.booking_cancel_dialog_message, - customerName, - serviceName, - date, - time + listOf( + customerName, + UiString.UiStringText(serviceName), + UiString.UiStringText(date), + UiString.UiStringText(time) + ) ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAppointmentDetails.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAppointmentDetails.kt index e6f70d5b5be5..537932d8e0c0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAppointmentDetails.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAppointmentDetails.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.woocommerce.android.R -import com.woocommerce.android.ui.bookings.details.CancelState +import com.woocommerce.android.ui.bookings.details.CancelStatus import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.component.WCOutlinedButton import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews @@ -152,10 +152,10 @@ data class BookingAppointmentDetailsModel( val location: String, val duration: String, val price: String, - val cancelState: CancelState, + val cancelStatus: CancelStatus, ) { - val cancelButtonEnabled: Boolean = cancelState != CancelState.InProgress - val cancelInProgressShown: Boolean = cancelState == CancelState.InProgress + val cancelButtonEnabled: Boolean = cancelStatus != CancelStatus.InProgress + val cancelInProgressShown: Boolean = cancelStatus == CancelStatus.InProgress } sealed interface BookingStaffMemberStatus { @@ -176,7 +176,7 @@ private fun BookingAppointmentDetailsPreview() { location = "238 Willow Creek Drive, Montgomery AL 36109", duration = "60 min", price = "$55.00", - cancelState = CancelState.Idle, + cancelStatus = CancelStatus.Idle, ), onCancelBooking = {}, modifier = Modifier.fillMaxWidth() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/CancelBookingDialog.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/CancelBookingDialog.kt deleted file mode 100644 index 503b9061d4c5..000000000000 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/CancelBookingDialog.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.woocommerce.android.ui.bookings.compose - -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.woocommerce.android.R -import com.woocommerce.android.ui.compose.component.AlertDialog - -@Composable -fun CancelBookingDialog( - message: String, - onDismiss: () -> Unit, - onConfirmCancel: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(id = R.string.booking_cancel_dialog_title), - style = MaterialTheme.typography.titleLarge - ) - }, - text = { Text(text = message) }, - confirmButton = { - TextButton( - onClick = onConfirmCancel, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { - Text(text = stringResource(id = R.string.booking_cancel_dialog_confirm)) - } - }, - dismissButton = { - TextButton( - onClick = onDismiss, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface), - ) { - Text(text = stringResource(id = R.string.booking_cancel_dialog_keep)) - } - }, - neutralButton = { } - ) -} 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 0336c810cec6..9c3d1a4e91e7 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 @@ -40,7 +40,7 @@ import com.woocommerce.android.ui.bookings.compose.BookingStaffMemberStatus import com.woocommerce.android.ui.bookings.compose.BookingStatus import com.woocommerce.android.ui.bookings.compose.BookingSummary import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel -import com.woocommerce.android.ui.bookings.compose.CancelBookingDialog +import com.woocommerce.android.ui.compose.Render import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.component.Toolbar import com.woocommerce.android.ui.compose.component.WCPullToRefreshBox @@ -115,13 +115,7 @@ fun BookingDetailsScreen( onDismiss = { showAttendanceSheet.value = false } ) } - if (viewState.showCancelBookingDialog) { - CancelBookingDialog( - message = viewState.cancelDialogMessage, - onDismiss = viewState.onDismissCancelDialog, - onConfirmCancel = viewState.onConfirmCancelBooking, - ) - } + viewState.cancelBookingDialogState?.Render() } } @@ -238,7 +232,7 @@ private fun BookingDetailsPreview() { location = "238 Willow Creek Drive, Montgomery AL 36109", duration = "60 min", price = "$55.00", - cancelState = CancelState.Idle, + cancelStatus = CancelStatus.Idle, ), bookingCustomerDetails = BookingCustomerDetailsModel( name = "Margarita Nikolaevna", 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 1c0210fadb3a..007d8edc2463 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 @@ -5,7 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.woocommerce.android.R -import com.woocommerce.android.extensions.combine +import com.woocommerce.android.model.UiString import com.woocommerce.android.tools.NetworkStatus import com.woocommerce.android.ui.bookings.Booking import com.woocommerce.android.ui.bookings.BookingMapper @@ -13,6 +13,7 @@ import com.woocommerce.android.ui.bookings.BookingResource import com.woocommerce.android.ui.bookings.BookingsRepository import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus import com.woocommerce.android.ui.bookings.compose.BookingStaffMemberStatus +import com.woocommerce.android.ui.compose.DialogState import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ResourceProvider import com.woocommerce.android.viewmodel.ScopedViewModel @@ -24,6 +25,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -55,36 +57,62 @@ class BookingDetailsViewModel @Inject constructor( // Temporary, the booking status should come from the stored object private val bookingAttendanceStatus = MutableStateFlow(null) - private val cancelState = MutableStateFlow(CancelState.Idle) - private val showCancelDialog = MutableStateFlow(false) - val state: LiveData = combine( + private val cancelStatusState = MutableStateFlow(CancelStatus.Idle) + private val showCancelBookingDialog = MutableStateFlow(false) + + val cancelBookingDialogState = combine( + booking, + showCancelBookingDialog, + ) { booking, showCancelBooking -> + if (showCancelBooking && booking != null) { + val message = bookingMapper.buildCancelDialogMessage(booking) + DialogState( + title = UiString.UiStringRes(R.string.booking_cancel_dialog_title), + message = message, + positiveButton = DialogState.DialogButton( + text = UiString.UiStringRes(R.string.booking_cancel_dialog_confirm), + onClick = ::onConfirmCancelBooking + ), + negativeButton = DialogState.DialogButton( + text = UiString.UiStringRes(R.string.booking_cancel_dialog_keep), + onClick = ::onDismissCancelDialog + ), + ) + } else { + null + } + } + + val bookingUiStateFlow = combine( booking, bookingAttendanceStatus, loadingState, resource, - showCancelDialog, - cancelState, - ) { booking, attendanceStatus, loadingState, resource, showDialog, cancel -> + cancelStatusState, + ) { booking, attendanceStatus, loadingState, resource, cancelStatus -> + if (booking != null) { + bookingMapper.buildBookingUiState(booking, attendanceStatus, resource, loadingState, cancelStatus) + } else { + null + } + } + + val state: LiveData = combine( + booking, + bookingUiStateFlow, + loadingState, + cancelBookingDialogState, + ) { booking, bookingUiState, loadingState, cancelBookingDialog -> with(bookingMapper) { - val cancelMessage = booking?.let { - buildCancelDialogMessage(booking, resourceProvider) - } ?: "" BookingDetailsViewState( toolbarTitle = booking?.id?.value?.let { id -> resourceProvider.getString(R.string.booking_details_title, id) } ?: "", - bookingUiState = if (booking != null) { - buildBookingUiState(booking, attendanceStatus, resource, loadingState, cancel) - } else { - null - }, + bookingUiState = bookingUiState, onCancelBooking = ::onCancelBooking, onAttendanceStatusSelected = ::onAttendanceStatusSelected, - showCancelBookingDialog = showDialog, - cancelDialogMessage = cancelMessage, - onDismissCancelDialog = ::onDismissCancelDialog, - onConfirmCancelBooking = ::onConfirmCancelBooking, + cancelBookingDialogState = cancelBookingDialog, loadingState = loadingState, onRefresh = ::fetchBooking, ) @@ -129,19 +157,19 @@ class BookingDetailsViewModel @Inject constructor( } private fun onCancelBooking() { - showCancelDialog.value = true + showCancelBookingDialog.value = true } private fun onDismissCancelDialog() { - showCancelDialog.value = false + showCancelBookingDialog.value = false } private fun onConfirmCancelBooking() = launch { // TODO Add logic to Cancel booking action - showCancelDialog.value = false - cancelState.value = CancelState.InProgress + showCancelBookingDialog.value = false + cancelStatusState.value = CancelStatus.InProgress delay(Duration.ofSeconds(1).toMillis()) - cancelState.value = CancelState.Idle + cancelStatusState.value = CancelStatus.Idle } private suspend fun BookingMapper.buildBookingUiState( @@ -149,7 +177,7 @@ class BookingDetailsViewModel @Inject constructor( attendanceStatus: BookingAttendanceStatus?, resource: BookingResource?, loadingState: BookingDetailsLoadingState, - cancelState: CancelState, + cancelStatus: CancelStatus, ): BookingUiState = BookingUiState( orderId = booking.orderId, bookingSummary = booking.toBookingSummaryModel().let { @@ -165,7 +193,7 @@ class BookingDetailsViewModel @Inject constructor( resource = resource, loadingState = loadingState ), - cancelState = cancelState + cancelStatus = cancelStatus ), bookingCustomerDetails = booking.order.customerInfo.toCustomerDetailsModel(), bookingPaymentDetails = booking.order.paymentInfo?.toPaymentDetailsModel(booking.currency) 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 00abd089fa44..872e1965dfbd 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 @@ -5,6 +5,7 @@ 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.BookingSummaryModel +import com.woocommerce.android.ui.compose.DialogState sealed interface BookingDetailsLoadingState { data object Idle : BookingDetailsLoadingState @@ -18,10 +19,7 @@ data class BookingDetailsViewState( val loadingState: BookingDetailsLoadingState = BookingDetailsLoadingState.Idle, val onCancelBooking: () -> Unit = {}, val onAttendanceStatusSelected: (BookingAttendanceStatus) -> Unit = { _ -> }, - val showCancelBookingDialog: Boolean = false, - val cancelDialogMessage: String = "", - val onDismissCancelDialog: () -> Unit = {}, - val onConfirmCancelBooking: () -> Unit = {}, + val cancelBookingDialogState: DialogState? = null, val onRefresh: () -> Unit = {}, ) { val shouldShowSkeleton: Boolean = bookingUiState == null && loadingState == BookingDetailsLoadingState.Refreshing @@ -35,7 +33,7 @@ data class BookingUiState( val bookingPaymentDetails: BookingPaymentDetailsModel?, ) -sealed interface CancelState { - data object Idle : CancelState - data object InProgress : CancelState +sealed interface CancelStatus { + data object Idle : CancelStatus + data object InProgress : CancelStatus } 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 0f0e0ea3a43c..ee1081feb77f 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 @@ -2,13 +2,13 @@ package com.woocommerce.android.ui.bookings import com.woocommerce.android.R import com.woocommerce.android.model.GetLocations +import com.woocommerce.android.model.UiString import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus import com.woocommerce.android.ui.bookings.compose.BookingStaffMemberStatus import com.woocommerce.android.ui.bookings.compose.BookingStatus -import com.woocommerce.android.ui.bookings.details.CancelState +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 @@ -101,7 +101,7 @@ class BookingMapperTest : BaseUnitTest() { val expectedTime = "${timeFormatter.format(start)} - ${timeFormatter.format(end)}" // WHEN - val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelState.Idle) } + val model = mapper.run { booking.toAppointmentDetailsModel(staffMemberStatus, CancelStatus.Idle) } // THEN assertThat(model.date).isEqualTo(expectedDate) @@ -110,7 +110,7 @@ class BookingMapperTest : BaseUnitTest() { assertThat(model.location).isEqualTo("238 Willow Creek Drive, Montgomery AL 36109") assertThat(model.duration).isEqualTo("90 min") assertThat(model.price).isEqualTo("$55.00") - assertThat(model.cancelState).isEqualTo(CancelState.Idle) + assertThat(model.cancelStatus).isEqualTo(CancelStatus.Idle) } @Test @@ -176,69 +176,55 @@ class BookingMapperTest : BaseUnitTest() { @Test fun `given booking, when building cancel dialog message, then formats using booking details`() { // GIVEN - val resourceProvider = mock() val start = Instant.parse("2025-09-12T16:00:00Z") val booking = sampleBooking(start = start, end = start.plus(Duration.ofHours(1))) val expectedDate = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withZone(ZoneOffset.UTC).format(start) val expectedTime = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneOffset.UTC).format(start) - whenever( - resourceProvider.getString( - eq(R.string.booking_cancel_dialog_message), - any(), - any(), - any(), - any() - ) - ).thenAnswer { - val name = it.getArgument(1) - val service = it.getArgument(2) - val date = it.getArgument(3) - val time = it.getArgument(4) - "$name will no longer be able to attend “$service” on $date at $time." - } // WHEN - val message = mapper.buildCancelDialogMessage(booking, resourceProvider) + val message = mapper.buildCancelDialogMessage(booking) // THEN - assertThat(message).isEqualTo( - "${booking.order.customerInfo?.billingFirstName} ${booking.order.customerInfo?.billingLastName} will no " + - "longer be able to attend “${booking.order.productInfo?.name}” on $expectedDate at $expectedTime." - ) + val customerName = + "${booking.order.customerInfo?.billingFirstName} ${booking.order.customerInfo?.billingLastName}" + assertThat(message) + .isEqualTo( + UiString.UiStringRes( + R.string.booking_cancel_dialog_message, + listOf( + UiString.UiStringText(customerName), + UiString.UiStringText("${booking.order.productInfo?.name}"), + UiString.UiStringText(expectedDate), + UiString.UiStringText(expectedTime) + ) + ) + ) } @Test fun `given booking without customer, when building cancel dialog message, then falls back to guest`() { // GIVEN - val resourceProvider = mock() - whenever(resourceProvider.getString(eq(R.string.customer_detail_guest_customer))).thenReturn("Guest") val start = Instant.parse("2025-09-12T16:00:00Z") - val bookingWithoutCustomer = sampleBooking(start = start, customerInfo = null) + val booking = sampleBooking(start = start, customerInfo = null) val expectedDate = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withZone(ZoneOffset.UTC).format(start) val expectedTime = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneOffset.UTC).format(start) - whenever( - resourceProvider.getString( - eq(R.string.booking_cancel_dialog_message), - any(), - any(), - any(), - any() - ) - ).thenAnswer { - val name = it.getArgument(1) - val service = it.getArgument(2) - val date = it.getArgument(3) - val time = it.getArgument(4) - "$name will no longer be able to attend “$service” on $date at $time." - } // WHEN - val message = mapper.buildCancelDialogMessage(bookingWithoutCustomer, resourceProvider) + val message = mapper.buildCancelDialogMessage(booking) // THEN - assertThat(message).isEqualTo( - "Guest will no longer be able to attend “Women’s Haircut” on $expectedDate at $expectedTime." - ) + assertThat(message) + .isEqualTo( + UiString.UiStringRes( + R.string.booking_cancel_dialog_message, + listOf( + UiString.UiStringRes(R.string.customer_detail_guest_customer), + UiString.UiStringText("${booking.order.productInfo?.name}"), + UiString.UiStringText(expectedDate), + UiString.UiStringText(expectedTime) + ) + ) + ) } private fun sampleBooking( 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 1ac1447c579a..3afdf1b99990 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 @@ -61,18 +61,6 @@ class BookingDetailsViewModelTest : BaseUnitTest() { any() ) ).thenReturn("Booking #${initialBooking.id.value}") - // Stub guest customer fallback used by cancel message - whenever(resourceProvider.getString(eq(R.string.customer_detail_guest_customer))).thenReturn("Guest") - // Stub cancel dialog message formatting regardless of inputs - whenever( - resourceProvider.getString( - eq(R.string.booking_cancel_dialog_message), - any(), - any(), - any(), - any() - ) - ).thenReturn("Formatted cancel message") } @Test @@ -228,8 +216,7 @@ class BookingDetailsViewModelTest : BaseUnitTest() { // Then val updated = viewModel.state.getOrAwaitValue() - assertThat(updated.showCancelBookingDialog).isTrue() - assertThat(updated.cancelDialogMessage).isEqualTo("Formatted cancel message") + assertThat(updated.cancelBookingDialogState).isNotNull } @Test @@ -239,14 +226,15 @@ class BookingDetailsViewModelTest : BaseUnitTest() { val viewModel = createViewModel(savedState) val state = viewModel.state.getOrAwaitValue() state.onCancelBooking() - assertThat(viewModel.state.getOrAwaitValue().showCancelBookingDialog).isTrue() + val stateWithDialog = viewModel.state.getOrAwaitValue() + assertThat(stateWithDialog.cancelBookingDialogState).isNotNull // When - viewModel.state.getOrAwaitValue().onDismissCancelDialog() + stateWithDialog.cancelBookingDialogState?.negativeButton?.onClick() // Then val updated = viewModel.state.getOrAwaitValue() - assertThat(updated.showCancelBookingDialog).isFalse() + assertThat(updated.cancelBookingDialogState).isNull() } @Test @@ -256,14 +244,15 @@ class BookingDetailsViewModelTest : BaseUnitTest() { val viewModel = createViewModel(savedState) val state = viewModel.state.getOrAwaitValue() state.onCancelBooking() - assertThat(viewModel.state.getOrAwaitValue().showCancelBookingDialog).isTrue() + val stateWithDialog = viewModel.state.getOrAwaitValue() + assertThat(stateWithDialog.cancelBookingDialogState).isNotNull() // When - viewModel.state.getOrAwaitValue().onConfirmCancelBooking() + stateWithDialog.cancelBookingDialogState?.positiveButton?.onClick() // Then val updated = viewModel.state.getOrAwaitValue() - assertThat(updated.showCancelBookingDialog).isFalse() + assertThat(updated.cancelBookingDialogState).isNull() } private fun createViewModel( From 841bba310e6c44382fbfccf096bf1b2347cb015c Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 16 Oct 2025 16:09:04 +0200 Subject: [PATCH 6/6] Rename cancelBookingDialogState to dialogState --- .../ui/bookings/details/BookingDetailsScreen.kt | 2 +- .../ui/bookings/details/BookingDetailsViewModel.kt | 6 +++--- .../ui/bookings/details/BookingDetailsViewState.kt | 2 +- .../details/BookingDetailsViewModelTest.kt | 14 +++++++------- 4 files changed, 12 insertions(+), 12 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 9c3d1a4e91e7..aaf256de255e 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 @@ -115,7 +115,7 @@ fun BookingDetailsScreen( onDismiss = { showAttendanceSheet.value = false } ) } - viewState.cancelBookingDialogState?.Render() + viewState.dialogState?.Render() } } 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 007d8edc2463..cfd2c404bbb1 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 @@ -61,7 +61,7 @@ class BookingDetailsViewModel @Inject constructor( private val cancelStatusState = MutableStateFlow(CancelStatus.Idle) private val showCancelBookingDialog = MutableStateFlow(false) - val cancelBookingDialogState = combine( + private val cancelBookingDialogState = combine( booking, showCancelBookingDialog, ) { booking, showCancelBooking -> @@ -84,7 +84,7 @@ class BookingDetailsViewModel @Inject constructor( } } - val bookingUiStateFlow = combine( + private val bookingUiStateFlow = combine( booking, bookingAttendanceStatus, loadingState, @@ -112,7 +112,7 @@ class BookingDetailsViewModel @Inject constructor( bookingUiState = bookingUiState, onCancelBooking = ::onCancelBooking, onAttendanceStatusSelected = ::onAttendanceStatusSelected, - cancelBookingDialogState = cancelBookingDialog, + dialogState = cancelBookingDialog, loadingState = loadingState, onRefresh = ::fetchBooking, ) 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 872e1965dfbd..8f57c73d1c4e 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 @@ -19,7 +19,7 @@ data class BookingDetailsViewState( val loadingState: BookingDetailsLoadingState = BookingDetailsLoadingState.Idle, val onCancelBooking: () -> Unit = {}, val onAttendanceStatusSelected: (BookingAttendanceStatus) -> Unit = { _ -> }, - val cancelBookingDialogState: DialogState? = null, + val dialogState: DialogState? = null, val onRefresh: () -> Unit = {}, ) { val shouldShowSkeleton: Boolean = bookingUiState == null && loadingState == BookingDetailsLoadingState.Refreshing 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 3afdf1b99990..e092895cb05b 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 @@ -216,7 +216,7 @@ class BookingDetailsViewModelTest : BaseUnitTest() { // Then val updated = viewModel.state.getOrAwaitValue() - assertThat(updated.cancelBookingDialogState).isNotNull + assertThat(updated.dialogState).isNotNull } @Test @@ -227,14 +227,14 @@ class BookingDetailsViewModelTest : BaseUnitTest() { val state = viewModel.state.getOrAwaitValue() state.onCancelBooking() val stateWithDialog = viewModel.state.getOrAwaitValue() - assertThat(stateWithDialog.cancelBookingDialogState).isNotNull + assertThat(stateWithDialog.dialogState).isNotNull // When - stateWithDialog.cancelBookingDialogState?.negativeButton?.onClick() + stateWithDialog.dialogState?.negativeButton?.onClick() // Then val updated = viewModel.state.getOrAwaitValue() - assertThat(updated.cancelBookingDialogState).isNull() + assertThat(updated.dialogState).isNull() } @Test @@ -245,14 +245,14 @@ class BookingDetailsViewModelTest : BaseUnitTest() { val state = viewModel.state.getOrAwaitValue() state.onCancelBooking() val stateWithDialog = viewModel.state.getOrAwaitValue() - assertThat(stateWithDialog.cancelBookingDialogState).isNotNull() + assertThat(stateWithDialog.dialogState).isNotNull() // When - stateWithDialog.cancelBookingDialogState?.positiveButton?.onClick() + stateWithDialog.dialogState?.positiveButton?.onClick() // Then val updated = viewModel.state.getOrAwaitValue() - assertThat(updated.cancelBookingDialogState).isNull() + assertThat(updated.dialogState).isNull() } private fun createViewModel(