Skip to content
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,8 +10,10 @@ 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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingCustomerInfo
Expand Down Expand Up @@ -54,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),
Expand All @@ -63,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,
)
}

Expand Down Expand Up @@ -106,6 +110,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
}
Comment on lines +95 to +104
Copy link
Member

Choose a reason for hiding this comment

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

minor remark, WDYT about updating WCOutlinedButton to accept a loading parameter like what we do with WCColoredButton here, and move this logic to the component.

Copy link
Member

Choose a reason for hiding this comment

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

If you do, please also make sure to update WCColoredButton's implementation to pass enabled = enabled && !loading, to make sure we disable button during loading.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you don't mind, I would prefer to open a separate PR with it just to isolate the change that goes beyond Booking Details screen.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

PR is here #14768

}
HorizontalDivider(thickness = 0.5.dp)
}
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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)
)
}
Comment on lines +99 to +106
Copy link

Copilot AI Oct 14, 2025

Choose a reason for hiding this comment

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

[nitpick] The nullable icon handling suggests that some attendance statuses may not have icons. Consider adding a consistent icon for all statuses or documenting why certain statuses lack visual representation.

Copilot uses AI. Check for mistakes.
Column(modifier = Modifier.weight(1f)) {
Text(
text = status.text(),
Expand All @@ -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 { stringResource(it) } ?: ""

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = { }
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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.component.WCPullToRefreshBox
Expand Down Expand Up @@ -105,14 +106,21 @@ 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 }
)
}
if (viewState.showCancelBookingDialog) {
CancelBookingDialog(
message = viewState.cancelDialogMessage,
onDismiss = viewState.onDismissCancelDialog,
onConfirmCancel = viewState.onConfirmCancelBooking,
)
}
}
}

Expand Down Expand Up @@ -228,7 +236,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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,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
Expand All @@ -36,21 +38,36 @@ class BookingDetailsViewModel @Inject constructor(

// Temporary, the booking status should come from the stored object
private val bookingAttendanceStatus = MutableStateFlow<BookingAttendanceStatus?>(null)
private val cancelState = MutableStateFlow<CancelState>(CancelState.Idle)
private val showCancelDialog = MutableStateFlow(false)

val state: LiveData<BookingDetailsViewState> = combine(
booking,
bookingAttendanceStatus,
loadingState
) { booking, attendanceStatus, loadingState ->
loadingState,
showCancelDialog,
cancelState,
) { booking, attendanceStatus, loadingState, showDialog, cancel ->
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,
loadingState = loadingState,
bookingUiState = if (booking != null) {
buildBookingUiState(booking, attendanceStatus, cancel)
} else {
null
},
onCancelBooking = ::onCancelBooking,
onAttendanceStatusSelected = ::onAttendanceStatusSelected,
showCancelBookingDialog = showDialog,
cancelDialogMessage = cancelMessage,
onDismissCancelDialog = ::onDismissCancelDialog,
onConfirmCancelBooking = ::onConfirmCancelBooking,
loadingState = loadingState,
onRefresh = ::fetchBooking,
)
}
Expand Down Expand Up @@ -81,12 +98,25 @@ 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() = 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 {
Expand All @@ -96,7 +126,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)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ 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 = {},
Copy link
Member

Choose a reason for hiding this comment

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

What do you think about using the existing common DialogState here, I mean something like this:

data class BookingDetailsViewState(
    val toolbarTitle: String = "",
    val bookingUiState: BookingUiState? = null,
    val loadingState: BookingDetailsLoadingState = BookingDetailsLoadingState.Idle,
    val onCancelBooking: () -> Unit = {},
    val onAttendanceStatusSelected: (BookingAttendanceStatus) -> Unit = { _ -> },
    val dialogState: DialogState? = null,
    val onRefresh: () -> Unit = {},
) {
    val shouldShowSkeleton: Boolean = bookingUiState == null && loadingState == BookingDetailsLoadingState.Refreshing
}

Then, on the screen, we would use something like the following:

viewState.dialogState?.Render()

And we can then remove completely CancelBookingDialog.

One additional advantage of the above is that dialogState will be generic enough so that it can be used for any flow that requires a modal dialog in the details screen.

Also, DialogState accepts UiString, so you won't have to use ResourceProvider:

    fun buildCancelDialogMessage(booking: Booking, resourceProvider: ResourceProvider): UiString {
        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 UiString.UiStringRes(
            R.string.booking_cancel_dialog_message,
            listOf(
                UiString.UiStringText(customerName),
                UiString.UiStringText(serviceName),
                UiString.UiStringText(date),
                UiString.UiStringText(time)
            )
        )
    }

BTW, ResourceProvider doesn't play well with configuration changes, as it uses the app context behind the scenes.

Copy link
Member

Choose a reason for hiding this comment

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

I just noticed the buttons use custom colors in the dialog, so this changes things a bit, but personally I would still vote to use DialogState, we can add a parameter to DialogButton to set the text color, or even a whole ButtonColors, or we can update the Render function to allow customizing the button colors if we want to keep the color logic in the UI.

Also, if we want to match the other dialogs in the app, the dismiss color should use the primary color and not onSurface.

WDYT?

Copy link
Contributor Author

@AdamGrzybkowski AdamGrzybkowski Oct 16, 2025

Choose a reason for hiding this comment

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

That's a great idea, I was not aware of the DialogState. The migration is here d0c3f31

I kept the standard colors for now.

val onRefresh: () -> Unit = {},
) {
val shouldShowSkeleton: Boolean = bookingUiState == null && loadingState == BookingDetailsLoadingState.Refreshing
Expand All @@ -30,3 +34,8 @@ data class BookingUiState(
val bookingCustomerDetails: BookingCustomerDetailsModel,
val bookingPaymentDetails: BookingPaymentDetailsModel?,
)

sealed interface CancelState {
data object Idle : CancelState
data object InProgress : CancelState
}
9 changes: 0 additions & 9 deletions WooCommerce/src/main/res/drawable/ic_attendance_cancelled.xml

This file was deleted.

Loading