diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt index 6e0cbab2e76e..134a720b6998 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt @@ -4,6 +4,7 @@ import com.woocommerce.android.WooException import com.woocommerce.android.tools.SelectedSite import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingUpdatePayload import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsFilterOption import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsOrderOption import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsStore @@ -62,6 +63,13 @@ class BookingsRepository @Inject constructor( bookingId = bookingId ) + suspend fun getBooking(bookingId: Long): Booking? { + return bookingsStore.getBooking( + site = selectedSite.get(), + bookingId = bookingId + ) + } + suspend fun fetchBooking( bookingId: Long ): Result { @@ -105,10 +113,26 @@ class BookingsRepository @Inject constructor( bookingId: Long, attendanceStatus: BookingEntity.AttendanceStatus, ): Result { - val result = bookingsStore.updateAttendanceStatus( + val result = bookingsStore.updateBooking( + site = selectedSite.get(), + bookingId = bookingId, + bookingUpdatePayload = BookingUpdatePayload(attendanceStatus = attendanceStatus) + ) + return if (result.isError) { + Result.failure(WooException(result.error)) + } else { + Result.success(Unit) + } + } + + suspend fun updateNote( + bookingId: Long, + note: String, + ): Result { + val result = bookingsStore.updateBooking( site = selectedSite.get(), bookingId = bookingId, - attendanceStatus = attendanceStatus + bookingUpdatePayload = BookingUpdatePayload(note = note) ) return if (result.isError) { Result.failure(WooException(result.error)) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsFragment.kt index d4b6b91c95ea..e21f5a434878 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/details/BookingDetailsFragment.kt @@ -6,6 +6,7 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import com.woocommerce.android.ui.base.BaseFragment import com.woocommerce.android.ui.base.UIMessageResolver import com.woocommerce.android.ui.compose.composeView @@ -21,6 +22,7 @@ class BookingDetailsFragment : BaseFragment() { lateinit var uiMessageResolver: UIMessageResolver private val viewModel: BookingDetailsViewModel by viewModels() + private val args: BookingDetailsFragmentArgs by navArgs() override val activityAppBarStatus: AppBarStatus get() = AppBarStatus.Hidden @@ -35,6 +37,12 @@ class BookingDetailsFragment : BaseFragment() { BookingDetailsFragmentDirections .actionBookingDetailsFragmentToOrderDetailFragment(orderId) ) + }, + onViewNotes = { + findNavController().navigate( + BookingDetailsFragmentDirections + .actionBookingDetailsFragmentToBookingNoteFragment(args.bookingId) + ) } ) } 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 aa1ae8d0071d..3bd2ea2d83c8 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 @@ -53,7 +53,8 @@ import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground fun BookingDetailsScreen( viewModel: BookingDetailsViewModel, onBack: () -> Unit, - onViewOrder: (Long) -> Unit + onViewOrder: (Long) -> Unit, + onViewNotes: () -> Unit, ) { val viewState by viewModel.state.observeAsState() @@ -62,6 +63,7 @@ fun BookingDetailsScreen( viewState = it, onBack = onBack, onViewOrder = onViewOrder, + onViewNotes = onViewNotes, ) } } @@ -72,6 +74,7 @@ fun BookingDetailsScreen( viewState: BookingDetailsViewState, onBack: () -> Unit, onViewOrder: (Long) -> Unit, + onViewNotes: () -> Unit, ) { val showAttendanceSheet = remember { mutableStateOf(false) } Scaffold( @@ -103,6 +106,7 @@ fun BookingDetailsScreen( onCancelBooking = viewState.onCancelBooking, onViewOrder = onViewOrder, onAttendanceStatusClicked = { showAttendanceSheet.value = true }, + onViewNotes = onViewNotes, ) } } @@ -126,6 +130,7 @@ private fun BookingDetailsContent( onCancelBooking: () -> Unit, onViewOrder: (Long) -> Unit, onAttendanceStatusClicked: () -> Unit, + onViewNotes: () -> Unit, ) { BookingSummary( model = booking.bookingSummary, @@ -158,7 +163,7 @@ private fun BookingDetailsContent( } BookingNoteSection( note = booking.note, - onClick = {}, + onClick = onViewNotes, modifier = Modifier.fillMaxWidth() ) } @@ -262,7 +267,8 @@ private fun BookingDetailsPreview() { ), ), onBack = {}, - onViewOrder = {} + onViewOrder = {}, + onViewNotes = {}, ) } } @@ -278,6 +284,7 @@ private fun BookingDetailsLoadingPreview() { ), onBack = {}, onViewOrder = {}, + onViewNotes = {}, ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteFragment.kt new file mode 100644 index 000000000000..9b23ab556d08 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteFragment.kt @@ -0,0 +1,54 @@ +package com.woocommerce.android.ui.bookings.note + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.base.UIMessageResolver +import com.woocommerce.android.ui.compose.composeView +import com.woocommerce.android.ui.main.AppBarStatus +import com.woocommerce.android.viewmodel.MultiLiveEvent +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class BookingNoteFragment : BaseFragment() { + + @Inject + lateinit var uiMessageResolver: UIMessageResolver + + private val viewModel: BookingNoteViewModel by viewModels() + + override val activityAppBarStatus: AppBarStatus + get() = AppBarStatus.Hidden + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return composeView { + BookingNoteScreen( + viewModel = viewModel, + onBack = { findNavController().popBackStack() }, + ) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + handleEvents() + } + + private fun handleEvents() { + viewModel.event.observe(viewLifecycleOwner) { event -> + when (event) { + is MultiLiveEvent.Event.ShowSnackbar -> { + uiMessageResolver.showSnack(event.message) + } + is MultiLiveEvent.Event.Exit -> { + findNavController().navigateUp() + } + } + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteScreen.kt new file mode 100644 index 000000000000..daf105c7a4b1 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteScreen.kt @@ -0,0 +1,146 @@ +package com.woocommerce.android.ui.bookings.note + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.component.Toolbar +import com.woocommerce.android.ui.compose.component.WCTextButton + +@Composable +fun BookingNoteScreen( + viewModel: BookingNoteViewModel, + onBack: () -> Unit, +) { + val viewState by viewModel.state.observeAsState() + viewState?.let { + BookingNoteScreen( + viewState = it, + onBack = onBack, + ) + } +} + +@Composable +fun BookingNoteScreen( + viewState: BookingNoteViewState, + onBack: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + + // Request focus only after the note is available in the composition + LaunchedEffect(viewState.editedNote != null) { + focusRequester.requestFocus() + } + + Scaffold( + topBar = { + Toolbar( + title = stringResource(R.string.booking_note_screen_title), + onNavigationButtonClick = onBack, + actions = { + if (viewState.isSaveVisible) { + WCTextButton( + onClick = viewState.onSaveClicked, + enabled = viewState.isSaveEnabled, + ) { + when (viewState.noteSaveStatus) { + is NoteSaveStatus.Idle -> { + Text(stringResource(R.string.booking_note_screen_done)) + } + + is NoteSaveStatus.InProgress -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp) + ) + } + } + } + } + }, + modifier = Modifier.shadow(4.dp) + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .padding(top = innerPadding.calculateTopPadding()) + ) { + viewState.editedNote?.let { + NoteTextField( + value = it, + onValueChange = viewState.onNoteChange, + enabled = viewState.noteEditable, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp) + .focusRequester(focusRequester) + ) + } + } + } +} + +@Composable +private fun NoteTextField( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + var textFieldValueState by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = value, + selection = TextRange(value.length) + ) + ) + } + + val textFieldValue = textFieldValueState.copy(text = value) + + val lastValue by rememberUpdatedState(value) + + BasicTextField( + value = textFieldValue, + onValueChange = { + textFieldValueState = it + if (it.text != lastValue) { + // Update external value when changed + onValueChange(it.text) + } + }, + enabled = enabled, + modifier = modifier, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary) + ) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModel.kt new file mode 100644 index 000000000000..ad6e8e7969fb --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModel.kt @@ -0,0 +1,73 @@ +package com.woocommerce.android.ui.bookings.note + +import androidx.lifecycle.LiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.asLiveData +import com.woocommerce.android.R +import com.woocommerce.android.ui.bookings.BookingsRepository +import com.woocommerce.android.viewmodel.MultiLiveEvent +import com.woocommerce.android.viewmodel.ScopedViewModel +import com.woocommerce.android.viewmodel.navArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class BookingNoteViewModel @Inject constructor( + savedState: SavedStateHandle, + private val bookingsRepository: BookingsRepository, +) : ScopedViewModel(savedState) { + + private val navArgs: BookingNoteFragmentArgs by savedState.navArgs() + + private val initialNoteState = MutableStateFlow("") + private val editedNoteState = MutableStateFlow(null) + private val noteSaveStatusFlow = MutableStateFlow(NoteSaveStatus.Idle) + + val state: LiveData = combine( + initialNoteState, + editedNoteState, + noteSaveStatusFlow + ) { initialNote, editedNote, noteSaveStatus -> + BookingNoteViewState( + initialNote = initialNote, + editedNote = editedNote, + noteSaveStatus = noteSaveStatus, + onNoteChange = ::onNoteChange, + onSaveClicked = ::saveNote, + ) + }.asLiveData() + + init { + launch { + val booking = bookingsRepository.getBooking(navArgs.bookingId) + if (booking != null) { + val initialNote = booking.note + initialNoteState.value = initialNote + editedNoteState.value = initialNote + } else { + triggerEvent(MultiLiveEvent.Event.Exit) + } + } + } + + private fun onNoteChange(value: String) { + editedNoteState.value = value + } + + private fun saveNote() { + launch { + noteSaveStatusFlow.value = NoteSaveStatus.InProgress + bookingsRepository.updateNote(navArgs.bookingId, editedNoteState.value.orEmpty().trim()) + .onFailure { + triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.booking_note_screen_update_error)) + } + .onSuccess { + triggerEvent(MultiLiveEvent.Event.Exit) + } + noteSaveStatusFlow.value = NoteSaveStatus.Idle + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewState.kt new file mode 100644 index 000000000000..42c54a6d9f4f --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewState.kt @@ -0,0 +1,24 @@ +package com.woocommerce.android.ui.bookings.note + +data class BookingNoteViewState( + val initialNote: String = "", + val editedNote: String? = null, + val noteSaveStatus: NoteSaveStatus = NoteSaveStatus.Idle, + val onNoteChange: (String) -> Unit = {}, + val onSaveClicked: () -> Unit = {}, +) { + + val isSaveVisible: Boolean + get() = initialNote.trim() != editedNote.orEmpty().trim() + + val isSaveEnabled: Boolean + get() = noteSaveStatus == NoteSaveStatus.Idle + + val noteEditable: Boolean + get() = noteSaveStatus == NoteSaveStatus.Idle +} + +sealed interface NoteSaveStatus { + data object Idle : NoteSaveStatus + data object InProgress : NoteSaveStatus +} diff --git a/WooCommerce/src/main/res/navigation/nav_graph_bookings.xml b/WooCommerce/src/main/res/navigation/nav_graph_bookings.xml index 26a3b86085d5..e0acc8019d55 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_bookings.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_bookings.xml @@ -30,10 +30,22 @@ android:name="orderId" app:argType="long" /> + + + + diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 196f5bbfa007..73f5817d798e 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4280,6 +4280,13 @@ BOOKING NOTE Add note This is a private note. It’ll not be shared with the customer. + 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 + Booking note + DONE + Error saving booking note Use password to sign in About %1$s @@ -4307,8 +4314,4 @@ 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/note/BookingNoteViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModelTest.kt new file mode 100644 index 000000000000..4524e109cc46 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModelTest.kt @@ -0,0 +1,193 @@ +package com.woocommerce.android.ui.bookings.note + +import androidx.lifecycle.SavedStateHandle +import com.woocommerce.android.R +import com.woocommerce.android.ui.bookings.BookingsRepository +import com.woocommerce.android.util.getOrAwaitValue +import com.woocommerce.android.viewmodel.BaseUnitTest +import com.woocommerce.android.viewmodel.MultiLiveEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.LocalOrRemoteId +import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingOrderInfo +import org.wordpress.android.fluxc.persistence.entity.BookingEntity +import java.time.Duration +import java.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class BookingNoteViewModelTest : BaseUnitTest() { + + private val bookingId = 42L + + private val booking = BookingEntity( + id = LocalOrRemoteId.RemoteId(bookingId), + localSiteId = LocalOrRemoteId.LocalId(1), + start = Instant.now(), + end = Instant.now() + Duration.ofDays(1), + allDay = false, + status = BookingEntity.Status.Confirmed, + cost = "100.00", + currency = "USD", + customerId = 1L, + productId = 1L, + resourceId = 1L, + dateCreated = Instant.now(), + dateModified = Instant.now(), + googleCalendarEventId = "", + orderId = bookingId, + orderItemId = 1L, + parentId = 0L, + personCounts = listOf(1L), + localTimezone = "", + attendanceStatus = BookingEntity.AttendanceStatus.Booked, + note = "Initial note", + order = BookingOrderInfo() + ) + + private val savedStateHandle: SavedStateHandle = BookingNoteFragmentArgs(bookingId).toSavedStateHandle() + + private val bookingsRepository = mock { + onBlocking { getBooking(any()) } doReturn booking + onBlocking { updateNote(any(), any()) } doReturn Result.success(Unit) + } + + @Test + fun `given booking exists, when ViewModel created, then initial and edited notes are set`() = testBlocking { + // Given + When + val viewModel = createViewModel() + + // Then + val state = viewModel.state.getOrAwaitValue() + assertThat(state.initialNote).isEqualTo(booking.note) + assertThat(state.editedNote).isEqualTo(booking.note) + // save not visible when same (after trim) + assertThat(state.isSaveVisible).isFalse() + assertThat(state.isSaveEnabled).isTrue() + assertThat(state.noteEditable).isTrue() + } + + @Test + fun `given booking missing, when ViewModel created, then exit event is triggered`() = testBlocking { + // Given + whenever(bookingsRepository.getBooking(any())).thenReturn(null) + + // When + val viewModel = createViewModel() + + // Then + val event = viewModel.event.getOrAwaitValue() + assertThat(event).isEqualTo(MultiLiveEvent.Event.Exit) + } + + @Test + fun `when onNoteChange called, then edited note and save visibility update`() = testBlocking { + // Given + val viewModel = createViewModel() + val state = viewModel.state.getOrAwaitValue() + + // When: whitespace-only change should not make save visible + state.onNoteChange(" ${booking.note} ") + val updated1 = viewModel.state.getOrAwaitValue() + + // Then + assertThat(updated1.editedNote).isEqualTo(" ${booking.note} ") + assertThat(updated1.isSaveVisible).isFalse() + + // When: real change + val newNote = "New note" + updated1.onNoteChange(newNote) + val updated2 = viewModel.state.getOrAwaitValue() + + // Then + assertThat(updated2.editedNote).isEqualTo(newNote) + assertThat(updated2.isSaveVisible).isTrue() + } + + @Test + fun `when onSaveClicked succeeds, then repository called with trimmed note and exit event triggered`() = testBlocking { + // Given + val viewModel = createViewModel() + val state = viewModel.state.getOrAwaitValue() + state.onNoteChange(" New note ") + viewModel.state.getOrAwaitValue() + + // When + state.onSaveClicked() + + // Then + verify(bookingsRepository, times(1)).updateNote(eq(bookingId), eq("New note")) + val event = viewModel.event.getOrAwaitValue() + assertThat(event).isEqualTo(MultiLiveEvent.Event.Exit) + // status goes back to Idle at the end + val finalState = viewModel.state.getOrAwaitValue() + assertThat(finalState.noteSaveStatus).isEqualTo(NoteSaveStatus.Idle) + } + + @Test + fun `when onSaveClicked in progress, then UI disables editing and save button`() = testBlocking { + // Given: make updateNote suspend for a while to catch InProgress + whenever(bookingsRepository.updateNote(any(), any())).doSuspendableAnswer { + delay(100) + Result.success(Unit) + } + val viewModel = createViewModel() + val state = viewModel.state.getOrAwaitValue() + state.onNoteChange("Changed") + viewModel.state.getOrAwaitValue() + + // When + state.onSaveClicked() + + // Then: immediately after click, status should become InProgress + val inProgress = viewModel.state.getOrAwaitValue() + assertThat(inProgress.noteSaveStatus).isEqualTo(NoteSaveStatus.InProgress) + assertThat(inProgress.isSaveEnabled).isFalse() + assertThat(inProgress.noteEditable).isFalse() + + // Let it finish + advanceUntilIdle() + val finalState = viewModel.state.getOrAwaitValue() + assertThat(finalState.noteSaveStatus).isEqualTo(NoteSaveStatus.Idle) + } + + @Test + fun `when onSaveClicked fails, then snackbar shown and status returns to Idle`() = testBlocking { + // Given + whenever(bookingsRepository.updateNote(any(), any())).thenReturn(Result.failure(Exception("fail"))) + val viewModel = createViewModel() + val state = viewModel.state.getOrAwaitValue() + state.onNoteChange("Changed") + viewModel.state.getOrAwaitValue() + + // When + state.onSaveClicked() + + // Then + val event = viewModel.event.getOrAwaitValue() + assertThat(event).isEqualTo(MultiLiveEvent.Event.ShowSnackbar(R.string.booking_note_screen_update_error)) + val finalState = viewModel.state.getOrAwaitValue() + assertThat(finalState.noteSaveStatus).isEqualTo(NoteSaveStatus.Idle) + } + + private fun createViewModel( + savedState: SavedStateHandle = savedStateHandle, + ): BookingNoteViewModel { + return BookingNoteViewModel( + savedState = savedState, + bookingsRepository = bookingsRepository + ).apply { + state.observeForever { } + } + } +} diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingUpdatePayload.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingUpdatePayload.kt new file mode 100644 index 000000000000..81d611222b36 --- /dev/null +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingUpdatePayload.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings + +import org.wordpress.android.fluxc.persistence.entity.BookingEntity + +data class BookingUpdatePayload( + val attendanceStatus: BookingEntity.AttendanceStatus? = null, + val note: String? = null +) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsRestClient.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsRestClient.kt index ff7cf3d5b26b..2ec72f816358 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsRestClient.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsRestClient.kt @@ -36,17 +36,18 @@ class BookingsRestClient @Inject constructor( } } - suspend fun updateAttendanceStatus( + suspend fun updateBooking( site: SiteModel, bookingId: Long, - attendanceStatus: String + payload: BookingUpdatePayload, ): WooPayload { val endpoint = WOOCOMMERCE.bookings.id(bookingId).pathV2Bookings + val body = payload.asMap val response = wooNetwork.executePutGsonRequest( site = site, path = endpoint, clazz = BookingDto::class.java, - body = mapOf("attendance_status" to attendanceStatus) + body = body, ) return when (response) { is Success -> WooPayload(response.data) @@ -121,3 +122,13 @@ class BookingsRestClient @Inject constructor( } } } + +private val BookingUpdatePayload.asMap: Map + get() = buildMap { + attendanceStatus?.let { + put("attendance_status", it.key) + } + note?.let { + put("note", it) + } + } diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt index 7936263e40da..cc4f343d36b7 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt @@ -89,6 +89,13 @@ class BookingsStore @Inject internal constructor( bookingId: Long ): Flow = bookingsDao.observeBooking(site.localId(), bookingId) + suspend fun getBooking( + site: SiteModel, + bookingId: Long + ): BookingEntity? { + return bookingsDao.getBooking(site.localId(), bookingId) + } + suspend fun fetchBooking( site: SiteModel, bookingId: Long @@ -144,13 +151,13 @@ class BookingsStore @Inject internal constructor( resourceId: Long ): Flow = bookingsDao.observeResource(site.localId(), resourceId) - suspend fun updateAttendanceStatus( + suspend fun updateBooking( site: SiteModel, bookingId: Long, - attendanceStatus: BookingEntity.AttendanceStatus, + bookingUpdatePayload: BookingUpdatePayload, ): WooResult { - return coroutineEngine.withDefaultContext(AppLog.T.API, this, "updateAttendanceStatus") { - val response = bookingsRestClient.updateAttendanceStatus(site, bookingId, attendanceStatus.key) + return coroutineEngine.withDefaultContext(AppLog.T.API, this, "updateBooking") { + val response = bookingsRestClient.updateBooking(site, bookingId, bookingUpdatePayload) when { response.isError -> WooResult(response.error) response.result != null -> {