Skip to content
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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
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
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.CancelStatus
import com.woocommerce.android.ui.bookings.list.BookingListItem
import com.woocommerce.android.util.CurrencyFormatter
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -56,7 +59,8 @@ class BookingMapper @Inject constructor(
}

fun Booking.toAppointmentDetailsModel(
staffMemberStatus: BookingStaffMemberStatus?
staffMemberStatus: BookingStaffMemberStatus?,
cancelStatus: CancelStatus,
): BookingAppointmentDetailsModel {
val durationMinutes = Duration.between(start, end).toMinutes()
return BookingAppointmentDetailsModel(
Expand All @@ -66,7 +70,8 @@ class BookingMapper @Inject constructor(
// TODO replace mocked values when available from API
location = "238 Willow Creek Drive, Montgomery AL 36109",
duration = "$durationMinutes min",
price = currencyFormatter.formatCurrency(cost, currency)
price = currencyFormatter.formatCurrency(cost, currency),
cancelStatus = cancelStatus,
)
}

Expand Down Expand Up @@ -109,6 +114,23 @@ class BookingMapper @Inject constructor(
return "${billingFirstName.orEmpty()} ${billingLastName.orEmpty()}".trim().ifEmpty { null }
}

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 UiString.UiStringRes(
R.string.booking_cancel_dialog_message,
listOf(
customerName,
UiString.UiStringText(serviceName),
UiString.UiStringText(date),
UiString.UiStringText(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 @@ -8,8 +8,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 @@ -19,6 +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.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
Expand Down Expand Up @@ -87,9 +91,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 @@ -139,8 +151,12 @@ data class BookingAppointmentDetailsModel(
val staff: BookingStaffMemberStatus?,
val location: String,
val duration: String,
val price: String
)
val price: String,
val cancelStatus: CancelStatus,
) {
val cancelButtonEnabled: Boolean = cancelStatus != CancelStatus.InProgress
val cancelInProgressShown: Boolean = cancelStatus == CancelStatus.InProgress
}

sealed interface BookingStaffMemberStatus {
data object Loading : BookingStaffMemberStatus
Expand All @@ -159,7 +175,8 @@ private fun BookingAppointmentDetailsPreview() {
staff = BookingStaffMemberStatus.Loading,
location = "238 Willow Creek Drive, Montgomery AL 36109",
duration = "60 min",
price = "$55.00"
price = "$55.00",
cancelStatus = CancelStatus.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
Expand Up @@ -40,6 +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.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
Expand Down Expand Up @@ -106,14 +107,15 @@ 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 }
)
}
viewState.dialogState?.Render()
}
}

Expand Down Expand Up @@ -229,7 +231,8 @@ private fun BookingDetailsPreview() {
staff = BookingStaffMemberStatus.Loaded("Marianne Renoir"),
location = "238 Willow Creek Drive, Montgomery AL 36109",
duration = "60 min",
price = "$55.00"
price = "$55.00",
cancelStatus = CancelStatus.Idle,
),
bookingCustomerDetails = BookingCustomerDetailsModel(
name = "Margarita Nikolaevna",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.woocommerce.android.R
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
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
Expand All @@ -20,6 +22,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
Expand All @@ -28,6 +31,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import java.time.Duration
import javax.inject.Inject

@OptIn(ExperimentalCoroutinesApi::class)
Expand All @@ -54,25 +58,62 @@ class BookingDetailsViewModel @Inject constructor(
// Temporary, the booking status should come from the stored object
private val bookingAttendanceStatus = MutableStateFlow<BookingAttendanceStatus?>(null)

val state: LiveData<BookingDetailsViewState> = combine(
private val cancelStatusState = MutableStateFlow<CancelStatus>(CancelStatus.Idle)
private val showCancelBookingDialog = MutableStateFlow(false)

private 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
}
}

private val bookingUiStateFlow = combine(
booking,
bookingAttendanceStatus,
loadingState,
resource
) { booking, attendanceStatus, loadingState, resource ->
resource,
cancelStatusState,
) { booking, attendanceStatus, loadingState, resource, cancelStatus ->
if (booking != null) {
bookingMapper.buildBookingUiState(booking, attendanceStatus, resource, loadingState, cancelStatus)
} else {
null
}
}

val state: LiveData<BookingDetailsViewState> = combine(
booking,
bookingUiStateFlow,
loadingState,
cancelBookingDialogState,
Comment on lines +102 to +105
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was becoming quite big, so I extracted some of those to separate Flows (booking Ui state, and cancel booking dialog state)

) { booking, bookingUiState, loadingState, cancelBookingDialog ->
with(bookingMapper) {
BookingDetailsViewState(
toolbarTitle = booking?.id?.value?.let { id ->
resourceProvider.getString(R.string.booking_details_title, id)
} ?: "",
bookingUiState = if (booking != null) {
buildBookingUiState(booking, resource, attendanceStatus, loadingState)
} else {
null
},
loadingState = loadingState,
bookingUiState = bookingUiState,
onCancelBooking = ::onCancelBooking,
onAttendanceStatusSelected = ::onAttendanceStatusSelected,
dialogState = cancelBookingDialog,
loadingState = loadingState,
onRefresh = ::fetchBooking,
)
}
Expand Down Expand Up @@ -116,14 +157,27 @@ class BookingDetailsViewModel @Inject constructor(
}

private fun onCancelBooking() {
// TODO Add logic to Cancel booking
showCancelBookingDialog.value = true
}

private fun onDismissCancelDialog() {
showCancelBookingDialog.value = false
}

private fun onConfirmCancelBooking() = launch {
// TODO Add logic to Cancel booking action
showCancelBookingDialog.value = false
cancelStatusState.value = CancelStatus.InProgress
delay(Duration.ofSeconds(1).toMillis())
cancelStatusState.value = CancelStatus.Idle
}

private suspend fun BookingMapper.buildBookingUiState(
booking: Booking,
resource: BookingResource?,
attendanceStatus: BookingAttendanceStatus?,
loadingState: BookingDetailsLoadingState
resource: BookingResource?,
loadingState: BookingDetailsLoadingState,
cancelStatus: CancelStatus,
): BookingUiState = BookingUiState(
orderId = booking.orderId,
bookingSummary = booking.toBookingSummaryModel().let {
Expand All @@ -138,7 +192,8 @@ class BookingDetailsViewModel @Inject constructor(
resourceId = booking.resourceId,
resource = resource,
loadingState = loadingState
)
),
cancelStatus = cancelStatus
),
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 @@ -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
Expand All @@ -18,6 +19,7 @@ data class BookingDetailsViewState(
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
Expand All @@ -30,3 +32,8 @@ data class BookingUiState(
val bookingCustomerDetails: BookingCustomerDetailsModel,
val bookingPaymentDetails: BookingPaymentDetailsModel?,
)

sealed interface CancelStatus {
data object Idle : CancelStatus
data object InProgress : CancelStatus
}
Loading