Skip to content

Commit 917cc7c

Browse files
Merge pull request #14808 from woocommerce/issue/WOOMOB-1561_cancel_booking
[WOOMOB-1561] Booking details - cancel booking action
2 parents ae1a066 + a5b1aa3 commit 917cc7c

File tree

16 files changed

+489
-63
lines changed

16 files changed

+489
-63
lines changed

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingMapper.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
2424
import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingCustomerInfo
2525
import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingPaymentInfo
2626
import org.wordpress.android.fluxc.persistence.entity.BookingEntity
27+
import org.wordpress.android.fluxc.persistence.entity.isCancellable
2728
import java.math.BigDecimal
2829
import java.time.Duration
2930
import java.time.ZoneOffset
@@ -79,6 +80,7 @@ class BookingMapper @Inject constructor(
7980
location = "238 Willow Creek Drive, Montgomery AL 36109",
8081
price = currencyFormatter.formatCurrency(cost, currency),
8182
cancelStatus = cancelStatus,
83+
cancelButtonVisible = isCancellable,
8284
duration = duration,
8385
)
8486
}
@@ -112,7 +114,7 @@ class BookingMapper @Inject constructor(
112114
orderStatus: String?,
113115
paymentMethod: String?,
114116
): BookingStatus {
115-
return if (orderStatus != "completed" && paymentMethod == "cod") {
117+
return if (orderStatus == "on-hold" && paymentMethod == "cod") {
116118
BookingStatus.PayOnSite
117119
} else {
118120
when (this) {
@@ -122,6 +124,7 @@ class BookingMapper @Inject constructor(
122124
BookingEntity.Status.Complete -> BookingStatus.Complete
123125
BookingEntity.Status.Confirmed -> BookingStatus.Confirmed
124126
BookingEntity.Status.Unpaid -> BookingStatus.Unpaid
127+
BookingEntity.Status.InCart -> BookingStatus.InCart
125128
is BookingEntity.Status.Unknown -> BookingStatus.Unknown(this.key)
126129
}
127130
}

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ class BookingsRepository @Inject constructor(
141141
}
142142
}
143143

144+
suspend fun cancelBooking(
145+
bookingId: Long,
146+
): Result<Unit> {
147+
val result = bookingsStore.updateBooking(
148+
site = selectedSite.get(),
149+
bookingId = bookingId,
150+
bookingUpdatePayload = BookingUpdatePayload(status = BookingEntity.Status.Cancelled),
151+
refreshOrder = true,
152+
)
153+
return if (result.isError) {
154+
Result.failure(WooException(result.error))
155+
} else {
156+
Result.success(Unit)
157+
}
158+
}
159+
144160
data class FetchResult(
145161
val bookings: List<Booking>,
146162
val hasMorePages: Boolean

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingAppointmentDetails.kt

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.woocommerce.android.ui.bookings.compose
22

33
import androidx.annotation.StringRes
4+
import androidx.compose.animation.AnimatedVisibility
45
import androidx.compose.foundation.background
56
import androidx.compose.foundation.layout.Arrangement
67
import androidx.compose.foundation.layout.Box
@@ -79,20 +80,23 @@ fun BookingAppointmentDetails(
7980
)
8081
AppointmentDetailsRow(
8182
label = R.string.booking_appointment_label_price,
82-
value = model.price
83-
)
84-
WCOutlinedButton(
85-
modifier = Modifier
86-
.fillMaxWidth()
87-
.padding(16.dp),
88-
colors = ButtonDefaults.outlinedButtonColors(
89-
contentColor = MaterialTheme.colorScheme.onSurface
90-
),
91-
onClick = onCancelBooking,
92-
enabled = model.cancelButtonEnabled,
93-
text = stringResource(R.string.booking_details_cancel_booking_button),
94-
loading = model.cancelInProgressShown,
83+
value = model.price,
84+
withDivider = model.cancelButtonVisible,
9585
)
86+
AnimatedVisibility(model.cancelButtonVisible) {
87+
WCOutlinedButton(
88+
modifier = Modifier
89+
.fillMaxWidth()
90+
.padding(16.dp),
91+
colors = ButtonDefaults.outlinedButtonColors(
92+
contentColor = MaterialTheme.colorScheme.onSurface
93+
),
94+
onClick = onCancelBooking,
95+
enabled = model.cancelButtonEnabled,
96+
text = stringResource(R.string.booking_details_cancel_booking_button),
97+
loading = model.cancelInProgressShown,
98+
)
99+
}
96100
HorizontalDivider(thickness = 0.5.dp)
97101
}
98102
}
@@ -101,6 +105,7 @@ fun BookingAppointmentDetails(
101105
@Composable
102106
private fun AppointmentDetailsRow(
103107
@StringRes label: Int,
108+
withDivider: Boolean = true,
104109
value: @Composable () -> Unit
105110
) {
106111
Column {
@@ -115,16 +120,22 @@ private fun AppointmentDetailsRow(
115120
value()
116121
}
117122
}
118-
HorizontalDivider(
119-
modifier = Modifier.padding(start = 16.dp),
120-
thickness = 0.5.dp
121-
)
123+
if (withDivider) {
124+
HorizontalDivider(
125+
modifier = Modifier.padding(start = 16.dp),
126+
thickness = 0.5.dp
127+
)
128+
}
122129
}
123130
}
124131

125132
@Composable
126-
private fun AppointmentDetailsRow(@StringRes label: Int, value: String) {
127-
AppointmentDetailsRow(label = label) {
133+
private fun AppointmentDetailsRow(
134+
@StringRes label: Int,
135+
withDivider: Boolean = true,
136+
value: String,
137+
) {
138+
AppointmentDetailsRow(label = label, withDivider = withDivider) {
128139
Text(
129140
text = value,
130141
style = MaterialTheme.typography.bodyMedium,
@@ -142,10 +153,11 @@ data class BookingAppointmentDetailsModel(
142153
val location: String,
143154
val duration: String,
144155
val price: String,
156+
val cancelButtonVisible: Boolean,
145157
val cancelStatus: CancelStatus,
146158
) {
147-
val cancelButtonEnabled: Boolean = cancelStatus != CancelStatus.InProgress
148-
val cancelInProgressShown: Boolean = cancelStatus == CancelStatus.InProgress
159+
val cancelButtonEnabled: Boolean = cancelButtonVisible && cancelStatus != CancelStatus.InProgress
160+
val cancelInProgressShown: Boolean = cancelButtonVisible && cancelStatus == CancelStatus.InProgress
149161
}
150162

151163
sealed interface BookingStaffMemberStatus {
@@ -166,6 +178,28 @@ private fun BookingAppointmentDetailsPreview() {
166178
location = "238 Willow Creek Drive, Montgomery AL 36109",
167179
duration = "60 min",
168180
price = "$55.00",
181+
cancelButtonVisible = true,
182+
cancelStatus = CancelStatus.Idle,
183+
),
184+
onCancelBooking = {},
185+
modifier = Modifier.fillMaxWidth()
186+
)
187+
}
188+
}
189+
190+
@LightDarkThemePreviews
191+
@Composable
192+
private fun BookingAppointmentDetailsCancelHiddenPreview() {
193+
WooThemeWithBackground {
194+
BookingAppointmentDetails(
195+
model = BookingAppointmentDetailsModel(
196+
date = "05/07/2025, 11:00 AM",
197+
time = "11:00 am - 12:00 pm",
198+
staff = BookingStaffMemberStatus.Loading,
199+
location = "238 Willow Creek Drive, Montgomery AL 36109",
200+
duration = "60 min",
201+
price = "$55.00",
202+
cancelButtonVisible = false,
169203
cancelStatus = CancelStatus.Idle,
170204
),
171205
onCancelBooking = {},

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/compose/BookingStatusTag.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ sealed interface BookingStatus {
3333
data object Paid : BookingStatus
3434
data object Cancelled : BookingStatus
3535
data object Complete : BookingStatus
36+
data object InCart : BookingStatus
3637
data class Unknown(val key: String) : BookingStatus
3738
}
3839

@@ -46,6 +47,7 @@ private fun BookingStatus.text(): String {
4647
BookingStatus.Cancelled -> stringResource(R.string.booking_payment_status_cancelled)
4748
BookingStatus.Complete -> stringResource(R.string.booking_payment_status_complete)
4849
BookingStatus.PayOnSite -> stringResource(R.string.booking_payment_status_pay_on_site)
50+
BookingStatus.InCart -> stringResource(R.string.booking_payment_status_in_cart)
4951
is BookingStatus.Unknown -> key
5052
}
5153
}

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsScreen.kt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.woocommerce.android.ui.bookings.details
22

3+
import androidx.compose.animation.AnimatedVisibility
34
import androidx.compose.foundation.background
45
import androidx.compose.foundation.layout.Arrangement
56
import androidx.compose.foundation.layout.Column
@@ -145,12 +146,14 @@ private fun BookingDetailsContent(
145146
model = booking.bookingCustomerDetails,
146147
modifier = Modifier.fillMaxWidth()
147148
)
148-
BookingAttendanceSection(
149-
status = booking.bookingSummary.attendanceStatus,
150-
attendanceUpdateStatus = booking.bookingSummary.attendanceUpdateStatus,
151-
onClick = onAttendanceStatusClicked,
152-
modifier = Modifier.fillMaxWidth()
153-
)
149+
AnimatedVisibility(booking.isAttendanceStatusEditable) {
150+
BookingAttendanceSection(
151+
status = booking.bookingSummary.attendanceStatus,
152+
attendanceUpdateStatus = booking.bookingSummary.attendanceUpdateStatus,
153+
onClick = onAttendanceStatusClicked,
154+
modifier = Modifier.fillMaxWidth()
155+
)
156+
}
154157
booking.bookingPaymentDetails?.let {
155158
BookingPaymentSection(
156159
model = it,
@@ -245,6 +248,7 @@ private fun BookingDetailsPreview() {
245248
location = "238 Willow Creek Drive, Montgomery AL 36109",
246249
duration = "60 min",
247250
price = "$55.00",
251+
cancelButtonVisible = true,
248252
cancelStatus = CancelStatus.Idle,
249253
),
250254
bookingCustomerDetails = BookingCustomerDetailsModel(
@@ -263,7 +267,8 @@ private fun BookingDetailsPreview() {
263267
discount = "-",
264268
total = "$59.50"
265269
),
266-
note = ""
270+
note = "",
271+
isAttendanceStatusEditable = true
267272
),
268273
),
269274
onBack = {},

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewModel.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
2222
import kotlinx.coroutines.ExperimentalCoroutinesApi
2323
import kotlinx.coroutines.async
2424
import kotlinx.coroutines.awaitAll
25-
import kotlinx.coroutines.delay
2625
import kotlinx.coroutines.flow.MutableStateFlow
2726
import kotlinx.coroutines.flow.SharingStarted
2827
import kotlinx.coroutines.flow.combine
@@ -32,7 +31,7 @@ import kotlinx.coroutines.flow.flowOf
3231
import kotlinx.coroutines.flow.shareIn
3332
import kotlinx.coroutines.launch
3433
import org.wordpress.android.fluxc.persistence.entity.BookingEntity
35-
import java.time.Duration
34+
import org.wordpress.android.fluxc.persistence.entity.isAttendanceStatusEditable
3635
import javax.inject.Inject
3736

3837
@OptIn(ExperimentalCoroutinesApi::class)
@@ -189,10 +188,12 @@ class BookingDetailsViewModel @Inject constructor(
189188
}
190189

191190
private fun onConfirmCancelBooking() = launch {
192-
// TODO Add logic to Cancel booking action
193191
showCancelBookingDialog.value = false
194192
cancelStatusState.value = CancelStatus.InProgress
195-
delay(Duration.ofSeconds(1).toMillis())
193+
bookingsRepository.cancelBooking(navArgs.bookingId)
194+
.onFailure {
195+
triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.booking_cancel_error))
196+
}
196197
cancelStatusState.value = CancelStatus.Idle
197198
}
198199

@@ -215,7 +216,8 @@ class BookingDetailsViewModel @Inject constructor(
215216
),
216217
bookingCustomerDetails = booking.order.customerInfo.toCustomerDetailsModel(),
217218
bookingPaymentDetails = booking.order.paymentInfo?.toPaymentDetailsModel(booking.currency),
218-
note = booking.note
219+
note = booking.note,
220+
isAttendanceStatusEditable = booking.isAttendanceStatusEditable
219221
)
220222

221223
private fun buildStaffMemberStatus(

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsViewState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ data class BookingUiState(
2626
val bookingCustomerDetails: BookingCustomerDetailsModel,
2727
val bookingPaymentDetails: BookingPaymentDetailsModel?,
2828
val note: String,
29+
val isAttendanceStatusEditable: Boolean
2930
)
3031

3132
sealed interface BookingDetailsLoadingState {

WooCommerce/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4252,6 +4252,7 @@
42524252
<string name="booking_payment_status_paid">Paid</string>
42534253
<string name="booking_payment_status_pending_confirmation">Pending Confirmation</string>
42544254
<string name="booking_payment_status_cancelled">Cancelled</string>
4255+
<string name="booking_payment_status_in_cart">In Cart</string>
42554256
<string name="booking_payment_status_confirmed">Confirmed</string>
42564257
<string name="booking_payment_status_complete">Complete</string>
42574258
<string name="booking_payment_status_pay_on_site">Pay on site</string>
@@ -4296,6 +4297,7 @@
42964297
<string name="booking_note_screen_title">Booking note</string>
42974298
<string name="booking_note_screen_done">DONE</string>
42984299
<string name="booking_note_screen_update_error">Error saving booking note</string>
4300+
<string name="booking_cancel_error">Error cancelling the booking</string>
42994301

43004302
<string name="or_use_password" a8c-src-lib="module:login">Use password to sign in</string>
43014303
<string name="about_automattic_main_page_title">About %1$s</string>

WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/BookingMapperTest.kt

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ class BookingMapperTest : BaseUnitTest() {
255255
}
256256

257257
@Test
258-
fun `given processing order with COD payment method, when mapped to summary model, then status is PayOnSite`() {
258+
fun `given on-hold order with COD payment method, when mapped to summary model, then status is PayOnSite`() {
259259
// GIVEN
260260
val booking = sampleBooking().let { original ->
261261
val paymentInfo = BookingPaymentInfo(
@@ -266,7 +266,7 @@ class BookingMapperTest : BaseUnitTest() {
266266
total = BigDecimal("55.00"),
267267
totalTax = BigDecimal("0.00")
268268
)
269-
val orderWithPayment = original.order.copy(paymentInfo = paymentInfo, status = "processing")
269+
val orderWithPayment = original.order.copy(paymentInfo = paymentInfo, status = "on-hold")
270270
original.copy(order = orderWithPayment)
271271
}
272272

@@ -277,6 +277,48 @@ class BookingMapperTest : BaseUnitTest() {
277277
assertThat(model.status).isEqualTo(BookingStatus.PayOnSite)
278278
}
279279

280+
@Test
281+
fun `given cancellable statuses, when mapped to appointment details, then cancel button visible`() {
282+
whenever(currencyFormatter.formatCurrency(any<String>(), any(), eq(true))).thenAnswer {
283+
val amount = it.getArgument<String>(0)
284+
val currency = it.getArgument<String>(1)
285+
"$currency$amount"
286+
}
287+
val statuses = listOf(
288+
BookingEntity.Status.Confirmed,
289+
BookingEntity.Status.Paid,
290+
BookingEntity.Status.Unpaid,
291+
BookingEntity.Status.PendingConfirmation,
292+
BookingEntity.Status.Unknown("some-new-status")
293+
)
294+
statuses.forEach { status ->
295+
val booking = sampleBooking(status = status, cost = "55.00", currency = "USD")
296+
val model =
297+
mapper.run { booking.toAppointmentDetailsModel(BookingStaffMemberStatus.Loading, CancelStatus.Idle) }
298+
assertThat(model.cancelButtonVisible).describedAs("status $status").isTrue()
299+
}
300+
}
301+
302+
@Test
303+
fun `given non-cancellable statuses, when mapped to appointment details, then cancel button hidden`() {
304+
whenever(currencyFormatter.formatCurrency(any<String>(), any(), eq(true))).thenAnswer {
305+
val amount = it.getArgument<String>(0)
306+
val currency = it.getArgument<String>(1)
307+
"$currency$amount"
308+
}
309+
val statuses = listOf(
310+
BookingEntity.Status.Cancelled,
311+
BookingEntity.Status.InCart,
312+
BookingEntity.Status.Complete
313+
)
314+
statuses.forEach { status ->
315+
val booking = sampleBooking(status = status, cost = "55.00", currency = "USD")
316+
val model =
317+
mapper.run { booking.toAppointmentDetailsModel(BookingStaffMemberStatus.Loading, CancelStatus.Idle) }
318+
assertThat(model.cancelButtonVisible).describedAs("status $status").isFalse()
319+
}
320+
}
321+
280322
private fun sampleBooking(
281323
status: BookingEntity.Status = BookingEntity.Status.Confirmed,
282324
start: Instant = Instant.parse("2025-07-05T11:00:00Z"),

0 commit comments

Comments
 (0)