From f0f3c2c84d0f6fbb15f20eb9b89db7990ba34269 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Tue, 21 Oct 2025 15:45:41 +0200 Subject: [PATCH 1/9] Create BookingNoteFragment and show it on note tap --- .../details/BookingDetailsFragment.kt | 8 ++++ .../bookings/details/BookingDetailsScreen.kt | 13 +++-- .../ui/bookings/note/BookingNoteFragment.kt | 30 ++++++++++++ .../ui/bookings/note/BookingNoteScreen.kt | 48 +++++++++++++++++++ .../ui/bookings/note/BookingNoteViewModel.kt | 34 +++++++++++++ .../ui/bookings/note/BookingNoteViewState.kt | 5 ++ .../res/navigation/nav_graph_bookings.xml | 12 +++++ WooCommerce/src/main/res/values/strings.xml | 9 ++-- 8 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteFragment.kt create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteScreen.kt create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModel.kt create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewState.kt 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..b2755803c2eb --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteFragment.kt @@ -0,0 +1,30 @@ +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.compose.composeView +import com.woocommerce.android.ui.main.AppBarStatus +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class BookingNoteFragment : BaseFragment() { + + 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() }, + ) + } + } +} 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..f9ad5b3e4ed6 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteScreen.kt @@ -0,0 +1,48 @@ +package com.woocommerce.android.ui.bookings.note + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.component.Toolbar + +@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, +) { + Scaffold( + topBar = { + Toolbar( + title = stringResource(R.string.booking_note_screen_title), + onNavigationButtonClick = onBack, + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) + } +} 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..7d87610a42e2 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModel.kt @@ -0,0 +1,34 @@ +package com.woocommerce.android.ui.bookings.note + +import androidx.lifecycle.LiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.asLiveData +import com.woocommerce.android.ui.bookings.BookingsRepository +import com.woocommerce.android.viewmodel.ScopedViewModel +import com.woocommerce.android.viewmodel.navArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.take +import javax.inject.Inject + +@HiltViewModel +class BookingNoteViewModel @Inject constructor( + savedState: SavedStateHandle, + bookingsRepository: BookingsRepository, +) : ScopedViewModel(savedState) { + + private val navArgs: BookingNoteFragmentArgs by savedState.navArgs() + + private val bookingFlow = bookingsRepository.observeBooking(navArgs.bookingId) + .filterNotNull() + .take(1) + + val state: LiveData = combine( + bookingFlow + ) { booking -> + BookingNoteViewState( + note = booking[0].note + ) + }.asLiveData() +} 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..e04208d717b4 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewState.kt @@ -0,0 +1,5 @@ +package com.woocommerce.android.ui.bookings.note + +data class BookingNoteViewState( + val note: String = "", +) 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..489f41a52039 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4280,6 +4280,11 @@ 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 Use password to sign in About %1$s @@ -4307,8 +4312,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 From 8a4fa2e911ee8966a11344a77184901c08b447b2 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 22 Oct 2025 13:10:46 +0200 Subject: [PATCH 2/9] Load booking and show the note --- .../android/ui/bookings/BookingsRepository.kt | 8 +++ .../ui/bookings/note/BookingNoteScreen.kt | 54 +++++++++++++++++-- .../ui/bookings/note/BookingNoteViewModel.kt | 36 ++++++++++--- .../ui/bookings/note/BookingNoteViewState.kt | 4 +- 4 files changed, 90 insertions(+), 12 deletions(-) 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..c63e4efecc4a 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 @@ -3,6 +3,7 @@ package com.woocommerce.android.ui.bookings import com.woocommerce.android.WooException import com.woocommerce.android.tools.SelectedSite import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsFilterOption import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsOrderOption @@ -62,6 +63,13 @@ class BookingsRepository @Inject constructor( bookingId = bookingId ) + suspend fun getBooking(bookingId: Long): Booking? { + return bookingsStore.observeBooking( + site = selectedSite.get(), + bookingId = bookingId + ).first() + } + suspend fun fetchBooking( bookingId: Long ): Result { 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 index f9ad5b3e4ed6..f50e63e9280d 100644 --- 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 @@ -1,14 +1,29 @@ 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.text.BasicTextField +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold 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.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 @@ -31,18 +46,51 @@ fun BookingNoteScreen( viewState: BookingNoteViewState, onBack: () -> Unit, ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Scaffold( topBar = { Toolbar( title = stringResource(R.string.booking_note_screen_title), onNavigationButtonClick = onBack, + modifier = Modifier.shadow(4.dp) ) } ) { innerPadding -> Box( modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - ) + .background(MaterialTheme.colorScheme.surface) + .padding(top = innerPadding.calculateTopPadding()) + ) { + var textFieldValueState by rememberSaveable(viewState.editedNote, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = viewState.editedNote, + selection = TextRange(viewState.editedNote.length) + ) + ) + } + + val lastValue by rememberUpdatedState(viewState.editedNote) + + BasicTextField( + value = textFieldValueState, + onValueChange = { + textFieldValueState = it + if (it.text != lastValue) { + // Update external value when changed + viewState.onNoteChange(it.text) + } + }, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp) + .focusRequester(focusRequester) + ) + } } } 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 index 7d87610a42e2..5f8b72852aac 100644 --- 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 @@ -4,12 +4,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData 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.flow.filterNotNull -import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -20,15 +21,34 @@ class BookingNoteViewModel @Inject constructor( private val navArgs: BookingNoteFragmentArgs by savedState.navArgs() - private val bookingFlow = bookingsRepository.observeBooking(navArgs.bookingId) - .filterNotNull() - .take(1) + private val initialNoteState = MutableStateFlow("") + private val editedNoteState = MutableStateFlow("") val state: LiveData = combine( - bookingFlow - ) { booking -> + initialNoteState, + editedNoteState + ) { initialNote, editedNote -> BookingNoteViewState( - note = booking[0].note + initialNote = initialNote, + editedNote = editedNote, + onNoteChange = ::onNoteChange ) }.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 + } } 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 index e04208d717b4..579718729aee 100644 --- 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 @@ -1,5 +1,7 @@ package com.woocommerce.android.ui.bookings.note data class BookingNoteViewState( - val note: String = "", + val initialNote: String = "", + val editedNote: String = "", + val onNoteChange: (String) -> Unit = {} ) From 0c8fd4ccbaa0b52a0f2c2cf0d706bdd99dc89fa0 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 22 Oct 2025 13:29:49 +0200 Subject: [PATCH 3/9] Show Done button with progress indicator --- .../ui/bookings/note/BookingNoteScreen.kt | 25 +++++++++++++++++++ .../ui/bookings/note/BookingNoteViewModel.kt | 19 +++++++++++--- .../ui/bookings/note/BookingNoteViewState.kt | 21 ++++++++++++++-- WooCommerce/src/main/res/values/strings.xml | 1 + 4 files changed, 61 insertions(+), 5 deletions(-) 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 index f50e63e9280d..c3cb72c91edc 100644 --- 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 @@ -4,9 +4,12 @@ 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 @@ -26,6 +29,7 @@ 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( @@ -57,6 +61,26 @@ fun BookingNoteScreen( 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) ) } @@ -86,6 +110,7 @@ fun BookingNoteScreen( viewState.onNoteChange(it.text) } }, + enabled = viewState.noteEditable, modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp, vertical = 12.dp) 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 index 5f8b72852aac..f91359cec19d 100644 --- 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 @@ -8,6 +8,7 @@ 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.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @@ -23,15 +24,19 @@ class BookingNoteViewModel @Inject constructor( private val initialNoteState = MutableStateFlow("") private val editedNoteState = MutableStateFlow("") + private val noteSaveStatusFlow = MutableStateFlow(NoteSaveStatus.Idle) val state: LiveData = combine( initialNoteState, - editedNoteState - ) { initialNote, editedNote -> + editedNoteState, + noteSaveStatusFlow + ) { initialNote, editedNote, noteSaveStatus -> BookingNoteViewState( initialNote = initialNote, editedNote = editedNote, - onNoteChange = ::onNoteChange + noteSaveStatus = noteSaveStatus, + onNoteChange = ::onNoteChange, + onSaveClicked = ::saveNote, ) }.asLiveData() @@ -51,4 +56,12 @@ class BookingNoteViewModel @Inject constructor( private fun onNoteChange(value: String) { editedNoteState.value = value } + + private fun saveNote() { + launch { + noteSaveStatusFlow.value = NoteSaveStatus.InProgress + delay(1000) + 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 index 579718729aee..150441ef9c0c 100644 --- 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 @@ -3,5 +3,22 @@ package com.woocommerce.android.ui.bookings.note data class BookingNoteViewState( val initialNote: String = "", val editedNote: String = "", - val onNoteChange: (String) -> Unit = {} -) + val noteSaveStatus: NoteSaveStatus = NoteSaveStatus.Idle, + val onNoteChange: (String) -> Unit = {}, + val onSaveClicked: () -> Unit = {}, +) { + + val isSaveVisible: Boolean + get() = initialNote.trim() != editedNote.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/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 489f41a52039..472487b56c57 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4285,6 +4285,7 @@ No, keep it Yes, cancel it Booking note + DONE Use password to sign in About %1$s From 300fa3cba76207b82325134499e8a8bf846d7ef3 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 22 Oct 2025 13:59:52 +0200 Subject: [PATCH 4/9] Introduce BookingUpdatePayload to update booking via API --- .../wpcom/wc/bookings/BookingUpdatePayload.kt | 8 ++++++++ .../wpcom/wc/bookings/BookingsRestClient.kt | 17 ++++++++++++++--- .../rest/wpcom/wc/bookings/BookingsStore.kt | 8 ++++---- 3 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingUpdatePayload.kt 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..d365c7dc4a70 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 @@ -144,13 +144,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 -> { From 736f555518f571cf59aad18b568b540a0183e9f1 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 22 Oct 2025 14:00:18 +0200 Subject: [PATCH 5/9] Trigger booking update on Done button tap in VM --- .../android/ui/bookings/BookingsRepository.kt | 21 ++++++++++++++-- .../ui/bookings/note/BookingNoteFragment.kt | 24 +++++++++++++++++++ .../ui/bookings/note/BookingNoteViewModel.kt | 12 +++++++--- WooCommerce/src/main/res/values/strings.xml | 1 + 4 files changed, 53 insertions(+), 5 deletions(-) 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 c63e4efecc4a..9bc755d78814 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 @@ -5,6 +5,7 @@ import com.woocommerce.android.tools.SelectedSite import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first 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 @@ -113,10 +114,26 @@ class BookingsRepository @Inject constructor( bookingId: Long, attendanceStatus: BookingEntity.AttendanceStatus, ): Result { - val result = bookingsStore.updateAttendanceStatus( + val result = bookingsStore.updateBooking( site = selectedSite.get(), bookingId = bookingId, - attendanceStatus = attendanceStatus + 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, + 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/note/BookingNoteFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteFragment.kt index b2755803c2eb..9b23ab556d08 100644 --- 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 @@ -7,13 +7,19 @@ 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 @@ -27,4 +33,22 @@ class BookingNoteFragment : BaseFragment() { ) } } + + 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/BookingNoteViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModel.kt index f91359cec19d..bd9e5eb0e463 100644 --- 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 @@ -3,12 +3,12 @@ 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.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @@ -17,7 +17,7 @@ import javax.inject.Inject @HiltViewModel class BookingNoteViewModel @Inject constructor( savedState: SavedStateHandle, - bookingsRepository: BookingsRepository, + private val bookingsRepository: BookingsRepository, ) : ScopedViewModel(savedState) { private val navArgs: BookingNoteFragmentArgs by savedState.navArgs() @@ -60,7 +60,13 @@ class BookingNoteViewModel @Inject constructor( private fun saveNote() { launch { noteSaveStatusFlow.value = NoteSaveStatus.InProgress - delay(1000) + bookingsRepository.updateNote(navArgs.bookingId, editedNoteState.value.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/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 472487b56c57..73f5817d798e 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4286,6 +4286,7 @@ Yes, cancel it Booking note DONE + Error saving booking note Use password to sign in About %1$s From c8771f899bf3c626f686418f4fb5371696927fc8 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 22 Oct 2025 15:11:35 +0200 Subject: [PATCH 6/9] Fix NoteTextField cursor position --- .../ui/bookings/note/BookingNoteScreen.kt | 73 +++++++++++++------ .../ui/bookings/note/BookingNoteViewModel.kt | 4 +- .../ui/bookings/note/BookingNoteViewState.kt | 4 +- 3 files changed, 53 insertions(+), 28 deletions(-) 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 index c3cb72c91edc..daf105c7a4b1 100644 --- 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 @@ -23,6 +23,7 @@ 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 @@ -52,7 +53,8 @@ fun BookingNoteScreen( ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { + // Request focus only after the note is available in the composition + LaunchedEffect(viewState.editedNote != null) { focusRequester.requestFocus() } @@ -90,32 +92,55 @@ fun BookingNoteScreen( .background(MaterialTheme.colorScheme.surface) .padding(top = innerPadding.calculateTopPadding()) ) { - var textFieldValueState by rememberSaveable(viewState.editedNote, stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue( - text = viewState.editedNote, - selection = TextRange(viewState.editedNote.length) - ) + viewState.editedNote?.let { + NoteTextField( + value = it, + onValueChange = viewState.onNoteChange, + enabled = viewState.noteEditable, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp) + .focusRequester(focusRequester) ) } + } + } +} - val lastValue by rememberUpdatedState(viewState.editedNote) - - BasicTextField( - value = textFieldValueState, - onValueChange = { - textFieldValueState = it - if (it.text != lastValue) { - // Update external value when changed - viewState.onNoteChange(it.text) - } - }, - 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 index bd9e5eb0e463..ad6e8e7969fb 100644 --- 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 @@ -23,7 +23,7 @@ class BookingNoteViewModel @Inject constructor( private val navArgs: BookingNoteFragmentArgs by savedState.navArgs() private val initialNoteState = MutableStateFlow("") - private val editedNoteState = MutableStateFlow("") + private val editedNoteState = MutableStateFlow(null) private val noteSaveStatusFlow = MutableStateFlow(NoteSaveStatus.Idle) val state: LiveData = combine( @@ -60,7 +60,7 @@ class BookingNoteViewModel @Inject constructor( private fun saveNote() { launch { noteSaveStatusFlow.value = NoteSaveStatus.InProgress - bookingsRepository.updateNote(navArgs.bookingId, editedNoteState.value.trim()) + bookingsRepository.updateNote(navArgs.bookingId, editedNoteState.value.orEmpty().trim()) .onFailure { triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.booking_note_screen_update_error)) } 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 index 150441ef9c0c..42c54a6d9f4f 100644 --- 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 @@ -2,14 +2,14 @@ package com.woocommerce.android.ui.bookings.note data class BookingNoteViewState( val initialNote: String = "", - val editedNote: String = "", + val editedNote: String? = null, val noteSaveStatus: NoteSaveStatus = NoteSaveStatus.Idle, val onNoteChange: (String) -> Unit = {}, val onSaveClicked: () -> Unit = {}, ) { val isSaveVisible: Boolean - get() = initialNote.trim() != editedNote.trim() + get() = initialNote.trim() != editedNote.orEmpty().trim() val isSaveEnabled: Boolean get() = noteSaveStatus == NoteSaveStatus.Idle From c558209e43c376c1b7cd6b0f794e97999fe6b694 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 22 Oct 2025 15:22:29 +0200 Subject: [PATCH 7/9] Add tests for BookingNoteViewModel --- .../bookings/note/BookingNoteViewModelTest.kt | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModelTest.kt 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..f109dc1112f3 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/note/BookingNoteViewModelTest.kt @@ -0,0 +1,199 @@ +package com.woocommerce.android.ui.bookings.note + +import androidx.lifecycle.SavedStateHandle +import com.woocommerce.android.R +import com.woocommerce.android.ui.bookings.Booking +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 initialNote = "Initial note" + + private val booking = sampleBooking(bookingId, note = initialNote) + + 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(initialNote) + assertThat(state.editedNote).isEqualTo(initialNote) + // 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(" $initialNote ") + val updated1 = viewModel.state.getOrAwaitValue() + + // Then + assertThat(updated1.editedNote).isEqualTo(" $initialNote ") + 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 { } + } + } + + private fun sampleBooking(id: Long, note: String): Booking { + return BookingEntity( + id = LocalOrRemoteId.RemoteId(id), + 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 = id, + orderItemId = 1L, + parentId = 0L, + personCounts = listOf(1L), + localTimezone = "", + attendanceStatus = BookingEntity.AttendanceStatus.Booked, + note = note, + order = BookingOrderInfo() + ) + } +} From 4689f0aefe3b95e9081ef085c9fbb78d85bd323c Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 22 Oct 2025 20:45:48 +0200 Subject: [PATCH 8/9] Use BookingDao.getBooking directly --- .../woocommerce/android/ui/bookings/BookingsRepository.kt | 5 ++--- .../fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) 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 9bc755d78814..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 @@ -3,7 +3,6 @@ package com.woocommerce.android.ui.bookings import com.woocommerce.android.WooException import com.woocommerce.android.tools.SelectedSite import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first 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 @@ -65,10 +64,10 @@ class BookingsRepository @Inject constructor( ) suspend fun getBooking(bookingId: Long): Booking? { - return bookingsStore.observeBooking( + return bookingsStore.getBooking( site = selectedSite.get(), bookingId = bookingId - ).first() + ) } suspend fun fetchBooking( 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 d365c7dc4a70..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 From d66ec63bff113cc7ada877ea101d5289616f191d Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 22 Oct 2025 20:50:53 +0200 Subject: [PATCH 9/9] Adjust BookingNoteViewModelTest --- .../bookings/note/BookingNoteViewModelTest.kt | 62 +++++++++---------- 1 file changed, 28 insertions(+), 34 deletions(-) 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 index f109dc1112f3..4524e109cc46 100644 --- 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 @@ -2,7 +2,6 @@ package com.woocommerce.android.ui.bookings.note import androidx.lifecycle.SavedStateHandle import com.woocommerce.android.R -import com.woocommerce.android.ui.bookings.Booking import com.woocommerce.android.ui.bookings.BookingsRepository import com.woocommerce.android.util.getOrAwaitValue import com.woocommerce.android.viewmodel.BaseUnitTest @@ -30,9 +29,31 @@ import java.time.Instant class BookingNoteViewModelTest : BaseUnitTest() { private val bookingId = 42L - private val initialNote = "Initial note" - private val booking = sampleBooking(bookingId, note = initialNote) + 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() @@ -48,8 +69,8 @@ class BookingNoteViewModelTest : BaseUnitTest() { // Then val state = viewModel.state.getOrAwaitValue() - assertThat(state.initialNote).isEqualTo(initialNote) - assertThat(state.editedNote).isEqualTo(initialNote) + 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() @@ -76,11 +97,11 @@ class BookingNoteViewModelTest : BaseUnitTest() { val state = viewModel.state.getOrAwaitValue() // When: whitespace-only change should not make save visible - state.onNoteChange(" $initialNote ") + state.onNoteChange(" ${booking.note} ") val updated1 = viewModel.state.getOrAwaitValue() // Then - assertThat(updated1.editedNote).isEqualTo(" $initialNote ") + assertThat(updated1.editedNote).isEqualTo(" ${booking.note} ") assertThat(updated1.isSaveVisible).isFalse() // When: real change @@ -169,31 +190,4 @@ class BookingNoteViewModelTest : BaseUnitTest() { state.observeForever { } } } - - private fun sampleBooking(id: Long, note: String): Booking { - return BookingEntity( - id = LocalOrRemoteId.RemoteId(id), - 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 = id, - orderItemId = 1L, - parentId = 0L, - personCounts = listOf(1L), - localTimezone = "", - attendanceStatus = BookingEntity.AttendanceStatus.Booked, - note = note, - order = BookingOrderInfo() - ) - } }