Skip to content

Commit 1916d68

Browse files
Add a Cancel booking confirmation dialog
1 parent b0ec359 commit 1916d68

File tree

8 files changed

+240
-11
lines changed

8 files changed

+240
-11
lines changed

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

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

3+
import com.woocommerce.android.R
34
import com.woocommerce.android.extensions.isNotEqualTo
45
import com.woocommerce.android.model.Address
56
import com.woocommerce.android.model.GetLocations
@@ -11,6 +12,7 @@ import com.woocommerce.android.ui.bookings.compose.BookingStatus
1112
import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel
1213
import com.woocommerce.android.ui.bookings.list.BookingListItem
1314
import com.woocommerce.android.util.CurrencyFormatter
15+
import com.woocommerce.android.viewmodel.ResourceProvider
1416
import kotlinx.coroutines.Dispatchers
1517
import kotlinx.coroutines.withContext
1618
import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingCustomerInfo
@@ -106,6 +108,21 @@ class BookingMapper @Inject constructor(
106108
return "${billingFirstName.orEmpty()} ${billingLastName.orEmpty()}".trim().ifEmpty { null }
107109
}
108110

111+
fun buildCancelDialogMessage(booking: Booking, resourceProvider: ResourceProvider): String {
112+
val customerName = booking.order.customerInfo?.fullName()
113+
?: resourceProvider.getString(R.string.customer_detail_guest_customer)
114+
val serviceName = booking.order.productInfo?.name ?: "-"
115+
val date = detailsDateFormatter.format(booking.start)
116+
val time = timeRangeFormatter.format(booking.start)
117+
return resourceProvider.getString(
118+
R.string.booking_cancel_dialog_message,
119+
customerName,
120+
serviceName,
121+
date,
122+
time
123+
)
124+
}
125+
109126
private suspend fun BookingCustomerInfo.address(): Address? {
110127
val countryCode = billingCountry ?: return null
111128
val (country, state) = withContext(Dispatchers.IO) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.woocommerce.android.ui.bookings.compose
2+
3+
import androidx.compose.material3.ButtonDefaults
4+
import androidx.compose.material3.MaterialTheme
5+
import androidx.compose.material3.Text
6+
import androidx.compose.material3.TextButton
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.ui.res.stringResource
9+
import com.woocommerce.android.R
10+
import com.woocommerce.android.ui.compose.component.AlertDialog
11+
12+
@Composable
13+
fun CancelBookingDialog(
14+
message: String,
15+
onDismiss: () -> Unit,
16+
onConfirmCancel: () -> Unit,
17+
) {
18+
AlertDialog(
19+
onDismissRequest = onDismiss,
20+
title = {
21+
Text(
22+
text = stringResource(id = R.string.booking_cancel_dialog_title),
23+
style = MaterialTheme.typography.titleLarge
24+
)
25+
},
26+
text = { Text(text = message) },
27+
confirmButton = {
28+
TextButton(
29+
onClick = onConfirmCancel,
30+
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
31+
) {
32+
Text(text = stringResource(id = R.string.booking_cancel_dialog_confirm))
33+
}
34+
},
35+
dismissButton = {
36+
TextButton(
37+
onClick = onDismiss,
38+
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
39+
) {
40+
Text(text = stringResource(id = R.string.booking_cancel_dialog_keep))
41+
}
42+
},
43+
neutralButton = { }
44+
)
45+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import com.woocommerce.android.ui.bookings.compose.BookingPaymentSection
4141
import com.woocommerce.android.ui.bookings.compose.BookingStatus
4242
import com.woocommerce.android.ui.bookings.compose.BookingSummary
4343
import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel
44+
import com.woocommerce.android.ui.bookings.compose.CancelBookingDialog
4445
import com.woocommerce.android.ui.compose.animations.SkeletonView
4546
import com.woocommerce.android.ui.compose.component.Toolbar
4647
import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews
@@ -110,6 +111,13 @@ fun BookingDetailsScreen(
110111
onDismiss = { showAttendanceSheet.value = false }
111112
)
112113
}
114+
if (viewState.showCancelBookingDialog) {
115+
CancelBookingDialog(
116+
message = viewState.cancelDialogMessage,
117+
onDismiss = viewState.onDismissCancelDialog,
118+
onConfirmCancel = viewState.onConfirmCancelBooking,
119+
)
120+
}
113121
}
114122
}
115123

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import javax.inject.Inject
1919
@HiltViewModel
2020
class BookingDetailsViewModel @Inject constructor(
2121
savedState: SavedStateHandle,
22-
resourceProvider: ResourceProvider,
22+
private val resourceProvider: ResourceProvider,
2323
bookingsRepository: BookingsRepository,
2424
private val bookingMapper: BookingMapper,
2525
) : ScopedViewModel(savedState) {
@@ -30,19 +30,28 @@ class BookingDetailsViewModel @Inject constructor(
3030

3131
// Temporary, the booking status should come from the stored object
3232
private val bookingAttendanceStatus = MutableStateFlow<BookingAttendanceStatus?>(null)
33+
private val showCancelDialog = MutableStateFlow(false)
3334

3435
val state: LiveData<BookingDetailsViewState> = combine(
3536
booking,
36-
bookingAttendanceStatus
37-
) { booking, attendanceStatus ->
37+
bookingAttendanceStatus,
38+
showCancelDialog
39+
) { booking, attendanceStatus, showDialog ->
3840
with(bookingMapper) {
41+
val cancelMessage = booking?.let {
42+
buildCancelDialogMessage(booking, resourceProvider)
43+
} ?: ""
3944
BookingDetailsViewState(
4045
toolbarTitle = booking?.id?.value?.let { id ->
4146
resourceProvider.getString(R.string.booking_details_title, id)
4247
} ?: "",
4348
bookingUiState = if (booking != null) buildBookingUiState(booking, attendanceStatus) else null,
4449
onCancelBooking = ::onCancelBooking,
45-
onAttendanceStatusSelected = ::onAttendanceStatusSelected
50+
onAttendanceStatusSelected = ::onAttendanceStatusSelected,
51+
showCancelBookingDialog = showDialog,
52+
cancelDialogMessage = cancelMessage,
53+
onDismissCancelDialog = ::onDismissCancelDialog,
54+
onConfirmCancelBooking = ::onConfirmCancelBooking,
4655
)
4756
}
4857
}.asLiveData()
@@ -53,7 +62,16 @@ class BookingDetailsViewModel @Inject constructor(
5362
}
5463

5564
private fun onCancelBooking() {
56-
// TODO Add logic to Cancel booking
65+
showCancelDialog.value = true
66+
}
67+
68+
private fun onDismissCancelDialog() {
69+
showCancelDialog.value = false
70+
}
71+
72+
private fun onConfirmCancelBooking() {
73+
// TODO Add logic to Cancel booking action
74+
showCancelDialog.value = false
5775
}
5876

5977
private suspend fun BookingMapper.buildBookingUiState(

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ data class BookingDetailsViewState(
1010
val toolbarTitle: String = "",
1111
val bookingUiState: BookingUiState? = null,
1212
val onCancelBooking: () -> Unit = {},
13-
val onAttendanceStatusSelected: (BookingAttendanceStatus) -> Unit = { _ -> }
13+
val onAttendanceStatusSelected: (BookingAttendanceStatus) -> Unit = { _ -> },
14+
val showCancelBookingDialog: Boolean = false,
15+
val cancelDialogMessage: String = "",
16+
val onDismissCancelDialog: () -> Unit = {},
17+
val onConfirmCancelBooking: () -> Unit = {},
1418
) {
1519

1620
val shouldShowSkeleton: Boolean = bookingUiState == null

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4258,4 +4258,8 @@
42584258
<string name="about_automattic_logo_description">Automattic logo</string>
42594259
<string name="about_automattic_back_icon_description">Back icon</string>
42604260
<string name="about_automattic_app_icon_description">App icon</string>
4261+
<string name="booking_cancel_dialog_title">Cancel booking</string>
4262+
<string name="booking_cancel_dialog_message">%1$s will no longer be able to attend “%2$s” on %3$s at %4$s.</string>
4263+
<string name="booking_cancel_dialog_keep">No, keep it</string>
4264+
<string name="booking_cancel_dialog_confirm">Yes, cancel it</string>
42614265
</resources>

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

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

3+
import com.woocommerce.android.R
34
import com.woocommerce.android.model.GetLocations
45
import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus
56
import com.woocommerce.android.ui.bookings.compose.BookingStatus
67
import com.woocommerce.android.util.CurrencyFormatter
78
import com.woocommerce.android.viewmodel.BaseUnitTest
9+
import com.woocommerce.android.viewmodel.ResourceProvider
810
import kotlinx.coroutines.ExperimentalCoroutinesApi
911
import org.assertj.core.api.Assertions.assertThat
1012
import org.junit.Before
@@ -163,12 +165,84 @@ class BookingMapperTest : BaseUnitTest() {
163165
assertThat(model.total).isEqualTo("$110.00")
164166
}
165167

168+
@Test
169+
fun `given booking, when building cancel dialog message, then formats using booking details`() {
170+
// GIVEN
171+
val resourceProvider = mock<ResourceProvider>()
172+
val start = Instant.parse("2025-09-12T16:00:00Z")
173+
val booking = sampleBooking(start = start, end = start.plus(Duration.ofHours(1)))
174+
val expectedDate = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withZone(ZoneOffset.UTC).format(start)
175+
val expectedTime = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneOffset.UTC).format(start)
176+
whenever(
177+
resourceProvider.getString(
178+
eq(R.string.booking_cancel_dialog_message),
179+
any(),
180+
any(),
181+
any(),
182+
any()
183+
)
184+
).thenAnswer {
185+
val name = it.getArgument<String>(1)
186+
val service = it.getArgument<String>(2)
187+
val date = it.getArgument<String>(3)
188+
val time = it.getArgument<String>(4)
189+
"$name will no longer be able to attend “$service” on $date at $time."
190+
}
191+
192+
// WHEN
193+
val message = mapper.buildCancelDialogMessage(booking, resourceProvider)
194+
195+
// THEN
196+
assertThat(message).isEqualTo(
197+
"${booking.order.customerInfo?.billingFirstName} ${booking.order.customerInfo?.billingLastName} will no " +
198+
"longer be able to attend “${booking.order.productInfo?.name}” on $expectedDate at $expectedTime."
199+
)
200+
}
201+
202+
@Test
203+
fun `given booking without customer, when building cancel dialog message, then falls back to guest`() {
204+
// GIVEN
205+
val resourceProvider = mock<ResourceProvider>()
206+
whenever(resourceProvider.getString(eq(R.string.customer_detail_guest_customer))).thenReturn("Guest")
207+
val start = Instant.parse("2025-09-12T16:00:00Z")
208+
val bookingWithoutCustomer = sampleBooking(start = start, customerInfo = null)
209+
val expectedDate = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withZone(ZoneOffset.UTC).format(start)
210+
val expectedTime = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneOffset.UTC).format(start)
211+
whenever(
212+
resourceProvider.getString(
213+
eq(R.string.booking_cancel_dialog_message),
214+
any(),
215+
any(),
216+
any(),
217+
any()
218+
)
219+
).thenAnswer {
220+
val name = it.getArgument<String>(1)
221+
val service = it.getArgument<String>(2)
222+
val date = it.getArgument<String>(3)
223+
val time = it.getArgument<String>(4)
224+
"$name will no longer be able to attend “$service” on $date at $time."
225+
}
226+
227+
// WHEN
228+
val message = mapper.buildCancelDialogMessage(bookingWithoutCustomer, resourceProvider)
229+
230+
// THEN
231+
assertThat(message).isEqualTo(
232+
"Guest will no longer be able to attend “Women’s Haircut” on $expectedDate at $expectedTime."
233+
)
234+
}
235+
166236
private fun sampleBooking(
167237
status: BookingEntity.Status = BookingEntity.Status.Confirmed,
168238
start: Instant = Instant.parse("2025-07-05T11:00:00Z"),
169239
end: Instant = start.plus(Duration.ofHours(1)),
170240
cost: String = "0.00",
171-
currency: String = "USD"
241+
currency: String = "USD",
242+
customerInfo: BookingCustomerInfo? = BookingCustomerInfo(
243+
billingFirstName = "Margarita",
244+
billingLastName = "Nikolaevna"
245+
)
172246
): BookingEntity {
173247
return BookingEntity(
174248
id = LocalOrRemoteId.RemoteId(1L),
@@ -193,10 +267,7 @@ class BookingMapperTest : BaseUnitTest() {
193267
order = BookingOrderInfo(
194268
status = "completed",
195269
productInfo = BookingProductInfo(name = "Women’s Haircut"),
196-
customerInfo = BookingCustomerInfo(
197-
billingFirstName = "Margarita",
198-
billingLastName = "Nikolaevna"
199-
)
270+
customerInfo = customerInfo,
200271
)
201272
)
202273
}

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ class BookingDetailsViewModelTest : BaseUnitTest() {
4747
any()
4848
)
4949
).thenReturn("Booking #${initialBooking.id.value}")
50+
// Stub guest customer fallback used by cancel message
51+
whenever(resourceProvider.getString(eq(R.string.customer_detail_guest_customer))).thenReturn("Guest")
52+
// Stub cancel dialog message formatting regardless of inputs
53+
whenever(
54+
resourceProvider.getString(
55+
eq(R.string.booking_cancel_dialog_message),
56+
any(),
57+
any(),
58+
any(),
59+
any()
60+
)
61+
).thenReturn("Formatted cancel message")
5062
}
5163

5264
@Test
@@ -94,6 +106,56 @@ class BookingDetailsViewModelTest : BaseUnitTest() {
94106
assertThat(state.bookingUiState?.orderId).isEqualTo(2L)
95107
}
96108

109+
@Test
110+
fun `when onCancelBooking called, then cancel dialog is shown with message`() = testBlocking {
111+
// Given
112+
val savedState = SavedStateHandle(mapOf("bookingId" to 111L))
113+
val viewModel = createViewModel(savedState)
114+
val state = viewModel.state.getOrAwaitValue()
115+
116+
// When
117+
state.onCancelBooking()
118+
119+
// Then
120+
val updated = viewModel.state.getOrAwaitValue()
121+
assertThat(updated.showCancelBookingDialog).isTrue()
122+
assertThat(updated.cancelDialogMessage).isEqualTo("Formatted cancel message")
123+
}
124+
125+
@Test
126+
fun `given cancel dialog shown, when onDismissCancelDialog called, then dialog is hidden`() = testBlocking {
127+
// Given
128+
val savedState = SavedStateHandle(mapOf("bookingId" to 222L))
129+
val viewModel = createViewModel(savedState)
130+
val state = viewModel.state.getOrAwaitValue()
131+
state.onCancelBooking()
132+
assertThat(viewModel.state.getOrAwaitValue().showCancelBookingDialog).isTrue()
133+
134+
// When
135+
viewModel.state.getOrAwaitValue().onDismissCancelDialog()
136+
137+
// Then
138+
val updated = viewModel.state.getOrAwaitValue()
139+
assertThat(updated.showCancelBookingDialog).isFalse()
140+
}
141+
142+
@Test
143+
fun `given cancel dialog shown, when onConfirmCancelBooking called, then dialog is hidden`() = testBlocking {
144+
// Given
145+
val savedState = SavedStateHandle(mapOf("bookingId" to 333L))
146+
val viewModel = createViewModel(savedState)
147+
val state = viewModel.state.getOrAwaitValue()
148+
state.onCancelBooking()
149+
assertThat(viewModel.state.getOrAwaitValue().showCancelBookingDialog).isTrue()
150+
151+
// When
152+
viewModel.state.getOrAwaitValue().onConfirmCancelBooking()
153+
154+
// Then
155+
val updated = viewModel.state.getOrAwaitValue()
156+
assertThat(updated.showCancelBookingDialog).isFalse()
157+
}
158+
97159
private fun createViewModel(
98160
savedState: SavedStateHandle,
99161
): BookingDetailsViewModel {

0 commit comments

Comments
 (0)