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 5c8957bef26..78698f0350f 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 @@ -88,12 +88,13 @@ class BookingsRepository @Inject constructor( } } - suspend fun fetchResources(): Result { + suspend fun fetchResources(): Result> { val result = bookingsStore.fetchResources(site = selectedSite.get()) - return if (result.isError) { + val model = result.model + return if (result.isError || model == null) { Result.failure(WooException(result.error)) } else { - Result.success(Unit) + Result.success(model) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterPage.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterPage.kt index fa877d58bdc..e0d514b05cf 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterPage.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterPage.kt @@ -1,10 +1,27 @@ package com.woocommerce.android.ui.bookings.filter.teammember +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.woocommerce.android.ui.bookings.filter.BookingsFilterSelectionPage +import com.woocommerce.android.ui.compose.animations.SkeletonView +import com.woocommerce.android.viewmodel.MultiLiveEvent import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsFilterOption @Composable @@ -17,10 +34,65 @@ fun BookingTeamMemberFilterRoute( factory.create(initialTeamMembers, onTeamMembersFilterChanged) } val uiState by viewModel.uiState.collectAsState() - BookingTeamMemberFilterPage(uiState) + val event by viewModel.event.observeAsState() + BookingTeamMemberFilterPage(uiState, event) } @Composable -fun BookingTeamMemberFilterPage(state: BookingTeamMemberFilterUiState) { - BookingsFilterSelectionPage(items = state.items) +fun BookingTeamMemberFilterPage(state: BookingTeamMemberFilterUiState, event: MultiLiveEvent.Event?) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + LaunchedEffect(event) { + if (event is MultiLiveEvent.Event.ShowSnackbar) { + snackbarHostState.showSnackbar(context.getString(event.message)) + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) { paddingValues -> + if (state.isLoading) { + BookingTeamMemberFilterPageLoading(modifier = Modifier.padding(paddingValues)) + } else { + BookingsFilterSelectionPage(items = state.items, modifier = Modifier.padding(paddingValues)) + } + } +} + +@Composable +fun BookingTeamMemberFilterPageLoading(modifier: Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + SkeletonView( + modifier = Modifier + .size(62.dp, 64.dp) + .padding(horizontal = 16.dp, vertical = 24.dp) + ) + HorizontalDivider(thickness = 0.5.dp) + SkeletonView( + modifier = Modifier + .size(175.dp, 64.dp) + .padding(horizontal = 16.dp, vertical = 24.dp) + ) + HorizontalDivider(thickness = 0.5.dp) + SkeletonView( + modifier = Modifier + .size(130.dp, 64.dp) + .padding(horizontal = 16.dp, vertical = 24.dp) + ) + HorizontalDivider(thickness = 0.5.dp) + SkeletonView( + modifier = Modifier + .size(167.dp, 64.dp) + .padding(horizontal = 16.dp, vertical = 24.dp) + ) + HorizontalDivider(thickness = 0.5.dp) + SkeletonView( + modifier = Modifier + .size(166.dp, 64.dp) + .padding(horizontal = 16.dp, vertical = 24.dp) + ) + HorizontalDivider(thickness = 0.5.dp) + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterViewModel.kt index b064ff571c7..9c9dd6fb301 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterViewModel.kt @@ -1,7 +1,9 @@ package com.woocommerce.android.ui.bookings.filter.teammember import androidx.lifecycle.SavedStateHandle +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 dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -30,12 +32,26 @@ class BookingTeamMemberFilterViewModel @AssistedInject constructor( val uiState: StateFlow = _uiState init { + // First getting from database launch { bookingsRepository.observeResources().distinctUntilChanged().collect { resources -> - _uiState.update { current -> current.copy(teamMembers = listOf(TeamMember.any) + resources) } + _uiState.update { current -> + current.copy(isLoading = resources.isEmpty(), teamMembers = listOf(TeamMember.any) + resources) + } } } - launch { bookingsRepository.fetchResources() } + + // Then fetching from server + launch { + bookingsRepository.fetchResources() + .onSuccess { + _uiState.update { current -> current.copy(isLoading = false) } + } + .onFailure { + _uiState.update { current -> current.copy(isLoading = false) } + triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.bookings_resources_fetch_error)) + } + } } private fun onTeamMemberSelected(member: TeamMember?) { diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index c1a0270ff85..6e65c28ce06 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4193,6 +4193,7 @@ --> Bookings Error fetching bookings + Error fetching team members Today Upcoming All diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterViewModelTest.kt index 4e4f8c40ab2..5b578e28c15 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/filter/teammember/BookingTeamMemberFilterViewModelTest.kt @@ -4,9 +4,12 @@ import androidx.lifecycle.SavedStateHandle import com.woocommerce.android.ui.bookings.BookingsRepository import com.woocommerce.android.viewmodel.BaseUnitTest import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert.assertTrue import org.junit.Test +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.wordpress.android.fluxc.model.LocalOrRemoteId @@ -18,16 +21,17 @@ class BookingTeamMemberFilterViewModelTest : BaseUnitTest() { private fun createViewModel( initialMembers: BookingsFilterOption.TeamMembers? = null, + bookingsRepository: BookingsRepository? = null ): BookingTeamMemberFilterViewModel { - val bookingsRepository = mock { + val repository = bookingsRepository ?: mock { on { observeResources() } doReturn flowOf(emptyList()) - onBlocking { fetchResources() } doReturn Result.success(Unit) + onBlocking { fetchResources() } doReturn Result.success(emptyArray()) } return BookingTeamMemberFilterViewModel( initialMembers = initialMembers, onFilterChanged = {}, - bookingsRepository = bookingsRepository, + bookingsRepository = repository, savedStateHandle = SavedStateHandle() ) } @@ -88,4 +92,41 @@ class BookingTeamMemberFilterViewModelTest : BaseUnitTest() { // Then assertThat(vm.uiState.value.selectedMembers).isEqualTo(BookingsFilterOption.TeamMembers.DEFAULT) } + + @Test + fun `when screen opens, then loading is true and after first non-empty data loading becomes false`() = + testBlocking { + // Given a repository that first emits empty list (loading), then we push non-empty + val resourcesFlow = MutableStateFlow>(emptyList()) + val bookingsRepository = mock { + on { observeResources() } doReturn resourcesFlow + onBlocking { fetchResources() } doAnswer { + Thread.sleep(50) + Result.success(emptyArray()) + } + } + + val vm = createViewModel(BookingsFilterOption.TeamMembers.DEFAULT, bookingsRepository) + + assertTrue(vm.uiState.value.isLoading) + + resourcesFlow.value = listOf(member(1)) + + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `when resources fetch returns error, then snackbar event is emitted`() = testBlocking { + // Given an error from REST + val resourcesFlow = MutableStateFlow>(emptyList()) + val bookingsRepository = mock { + on { observeResources() } doReturn resourcesFlow + onBlocking { fetchResources() } doReturn Result.failure(Exception("boom")) + } + + val vm = createViewModel(BookingsFilterOption.TeamMembers.DEFAULT, bookingsRepository) + + val event = vm.event.value + assertThat(event).isInstanceOf(com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ShowSnackbar::class.java) + } }