diff --git a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/mystore/StatsComponent.kt b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/mystore/StatsComponent.kt index d7a302085a1..234ab82a149 100644 --- a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/mystore/StatsComponent.kt +++ b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/mystore/StatsComponent.kt @@ -6,7 +6,7 @@ import com.woocommerce.android.R import com.woocommerce.android.e2e.helpers.util.Screen import com.woocommerce.android.ui.dashboard.stats.DashboardStatsTestTags -class StatsComponent : Screen(R.id.dashboardStats_root) { +class StatsComponent : Screen(R.id.my_store_stats_container) { override fun recover() { super.recover() clickOn(R.id.dashboard) diff --git a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/mystore/TopPerformersComponent.kt b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/mystore/TopPerformersComponent.kt index c3e5abc2d91..2df6cfc3903 100644 --- a/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/mystore/TopPerformersComponent.kt +++ b/WooCommerce/src/androidTest/kotlin/com/woocommerce/android/e2e/screens/mystore/TopPerformersComponent.kt @@ -6,7 +6,7 @@ import com.woocommerce.android.R import com.woocommerce.android.e2e.helpers.util.Screen import com.woocommerce.android.ui.dashboard.stats.DashboardStatsTestTags -class TopPerformersComponent : Screen(R.id.dashboardStats_root) { +class TopPerformersComponent : Screen(R.id.my_store_stats_container) { override fun recover() { super.recover() clickOn(R.id.dashboard) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardContainer.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardContainer.kt index b2834fbd1c4..73219c6e2e5 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardContainer.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardContainer.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,14 +13,25 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource @@ -28,11 +40,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.woocommerce.android.R +import com.woocommerce.android.extensions.WindowSizeClass import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.ui.blaze.creation.BlazeCampaignCreationDispatcher import com.woocommerce.android.ui.compose.component.WCColoredButton import com.woocommerce.android.ui.compose.component.WCOutlinedButton import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.OpenRangePicker +import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetUiModel +import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetUiModel.ConfigurableWidget +import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetUiModel.FeedbackWidget +import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetUiModel.NewWidgetsCard +import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetUiModel.ShareStoreWidget import com.woocommerce.android.ui.dashboard.blaze.DashboardBlazeCard import com.woocommerce.android.ui.dashboard.coupons.DashboardCouponsCard import com.woocommerce.android.ui.dashboard.google.DashboardGoogleAdsCard @@ -45,75 +63,132 @@ import com.woocommerce.android.ui.dashboard.stock.DashboardProductStockCard import com.woocommerce.android.ui.dashboard.topperformers.DashboardTopPerformersWidgetCard import com.woocommerce.android.ui.main.MainActivityViewModel +@OptIn(ExperimentalMaterialApi::class) @Composable fun DashboardContainer( mainActivityViewModel: MainActivityViewModel, dashboardViewModel: DashboardViewModel, - blazeCampaignCreationDispatcher: BlazeCampaignCreationDispatcher + blazeCampaignCreationDispatcher: BlazeCampaignCreationDispatcher, + windowSizeClass: WindowSizeClass, ) { - dashboardViewModel.dashboardWidgets.observeAsState().value?.let { widgets -> - WidgetList( - widgetUiModels = widgets, - mainActivityViewModel = mainActivityViewModel, - dashboardViewModel = dashboardViewModel, - blazeCampaignCreationDispatcher = blazeCampaignCreationDispatcher - ) + dashboardViewModel.dashboardCardsState.observeAsState().value?.let { state -> + + val pullRefreshState = rememberPullRefreshState(state.isRefreshing, dashboardViewModel::onPullToRefresh) + Box(Modifier.pullRefresh(pullRefreshState)) { + DashboardWidgets( + widgetUiModels = state.widgets, + mainActivityViewModel = mainActivityViewModel, + dashboardViewModel = dashboardViewModel, + blazeCampaignCreationDispatcher = blazeCampaignCreationDispatcher, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.surface) + .padding(vertical = dimensionResource(id = R.dimen.major_100)), + numberOfColumns = if (windowSizeClass != WindowSizeClass.Compact) 2 else 1 + ) + + PullRefreshIndicator( + refreshing = state.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + contentColor = MaterialTheme.colors.primary, + ) + } } } @Composable -private fun WidgetList( +private fun DashboardWidgets( widgetUiModels: List, mainActivityViewModel: MainActivityViewModel, dashboardViewModel: DashboardViewModel, - blazeCampaignCreationDispatcher: BlazeCampaignCreationDispatcher + blazeCampaignCreationDispatcher: BlazeCampaignCreationDispatcher, + modifier: Modifier = Modifier, + numberOfColumns: Int = 1 ) { - Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.major_100)), - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colors.surface) - .padding(vertical = dimensionResource(id = R.dimen.major_100)) - ) { - val widgetModifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - widgetUiModels.forEach { - AnimatedVisibility(it.isVisible) { - when (it) { - is DashboardViewModel.DashboardWidgetUiModel.ConfigurableWidget -> { - ConfigurableWidgetCard( - widgetUiModel = it, - mainActivityViewModel = mainActivityViewModel, - dashboardViewModel = dashboardViewModel, - blazeCampaignCreationDispatcher = blazeCampaignCreationDispatcher, - modifier = widgetModifier - ) - } - - is DashboardViewModel.DashboardWidgetUiModel.ShareStoreWidget -> { - ShareStoreCard( - onShareClicked = it.onShareClicked, - modifier = widgetModifier - ) - } - - is DashboardViewModel.DashboardWidgetUiModel.FeedbackWidget -> { - FeedbackCard( - widget = it, - modifier = widgetModifier - ) - } + val widgetModifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + val nestedScrollInterop = rememberNestedScrollInteropConnection() - is DashboardViewModel.DashboardWidgetUiModel.NewWidgetsCard -> { - NewWidgetsCard( - state = it, - modifier = widgetModifier - ) - } + if (numberOfColumns == 1) { + LazyColumn( + modifier = modifier.nestedScroll(nestedScrollInterop), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(widgetUiModels) { widget -> + AnimatedVisibility(widget.isVisible) { + DashboardWidgetCard( + widget, + mainActivityViewModel, + dashboardViewModel, + blazeCampaignCreationDispatcher, + widgetModifier + ) } } } + } else { + LazyVerticalStaggeredGrid( + modifier = modifier.nestedScroll(nestedScrollInterop), + columns = StaggeredGridCells.Adaptive(400.dp), + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + items(widgetUiModels) { widget -> + AnimatedVisibility(widget.isVisible) { + DashboardWidgetCard( + widget, + mainActivityViewModel, + dashboardViewModel, + blazeCampaignCreationDispatcher, + widgetModifier + ) + } + } + } + } +} + +@Composable +private fun DashboardWidgetCard( + it: DashboardWidgetUiModel, + mainActivityViewModel: MainActivityViewModel, + dashboardViewModel: DashboardViewModel, + blazeCampaignCreationDispatcher: BlazeCampaignCreationDispatcher, + widgetModifier: Modifier +) { + when (it) { + is ConfigurableWidget -> { + ConfigurableWidgetCard( + widgetUiModel = it, + mainActivityViewModel = mainActivityViewModel, + dashboardViewModel = dashboardViewModel, + blazeCampaignCreationDispatcher = blazeCampaignCreationDispatcher, + modifier = widgetModifier + ) + } + + is ShareStoreWidget -> { + ShareStoreCard( + onShareClicked = it.onShareClicked, + modifier = widgetModifier + ) + } + + is FeedbackWidget -> { + FeedbackCard( + widget = it, + modifier = widgetModifier + ) + } + + is NewWidgetsCard -> { + NewWidgetsCard( + state = it, + modifier = widgetModifier + ) + } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardFragment.kt index e9eb982ea13..83f7a06dc7b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardFragment.kt @@ -30,10 +30,10 @@ import com.woocommerce.android.extensions.getColorCompat import com.woocommerce.android.extensions.handleNotice import com.woocommerce.android.extensions.handleResult import com.woocommerce.android.extensions.navigateSafely -import com.woocommerce.android.extensions.scrollStartEvents import com.woocommerce.android.extensions.showDateRangePicker import com.woocommerce.android.extensions.startHelpActivity import com.woocommerce.android.extensions.verticalOffsetChanges +import com.woocommerce.android.extensions.windowSizeClass import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.support.help.HelpOrigin import com.woocommerce.android.tools.SelectedSite @@ -51,6 +51,7 @@ import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.Fe import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.FeedbackPositiveAction import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.OpenEditWidgets import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.OpenRangePicker +import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.RefreshJitm import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.ShareStore import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.ShowPrivacyBanner import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetUiModel @@ -137,24 +138,15 @@ class DashboardFragment : DashboardContainer( mainActivityViewModel = mainActivityViewModel, dashboardViewModel = dashboardViewModel, - blazeCampaignCreationDispatcher = blazeCampaignCreationDispatcher + blazeCampaignCreationDispatcher = blazeCampaignCreationDispatcher, + windowSizeClass = requireContext().windowSizeClass ) } } } - binding.myStoreRefreshLayout.setOnRefreshListener { - binding.myStoreRefreshLayout.isRefreshing = false - dashboardViewModel.onPullToRefresh() - refreshJitm() - } - prepareJetpackBenefitsBanner() - binding.statsScrollView.scrollStartEvents() - .onEach { usageTracksEventEmitter.interacted() } - .launchIn(viewLifecycleOwner.lifecycleScope) - setupStateObservers() setupResultHandlers() } @@ -188,6 +180,8 @@ class DashboardFragment : is ShowSnackbar -> ToastUtils.showToast(requireContext(), event.message) + is RefreshJitm -> refreshJitm() + else -> event.isHandled = false } } @@ -197,10 +191,10 @@ class DashboardFragment : dashboardViewModel.jetpackBenefitsBannerState.observe(viewLifecycleOwner) { jetpackBenefitsBanner -> onVisitorStatsUnavailable(jetpackBenefitsBanner) } - dashboardViewModel.dashboardWidgets.observe(viewLifecycleOwner) { widgets -> + dashboardViewModel.dashboardCardsState.observe(viewLifecycleOwner) { state -> // Show banners only if onboarding list is NOT displayed if ( - widgets.none { + state.widgets.none { (it as? DashboardWidgetUiModel.ConfigurableWidget)?.widget?.type == DashboardWidget.Type.ONBOARDING } ) { @@ -210,9 +204,6 @@ class DashboardFragment : dashboardViewModel.hasNewWidgets.observe(viewLifecycleOwner) { hasNewWidgets -> editButtonBadge.isVisible = hasNewWidgets } - dashboardViewModel.isRefreshingOnBackground.observe(viewLifecycleOwner) { isRefreshing -> - binding.myStoreRefreshLayout.isRefreshing = isRefreshing - } } private fun setupResultHandlers() { @@ -325,10 +316,6 @@ class DashboardFragment : override fun getFragmentSubtitle(): String = dashboardViewModel.storeName.value ?: "" - override fun scrollToTop() { - binding.statsScrollView.smoothScrollTo(0, 0) - } - private fun handleFeedbackRequestPositiveClick() { // Request a ReviewInfo object from the Google Reviews API. If this fails // we just move on as there isn't anything we can do. @@ -362,7 +349,11 @@ class DashboardFragment : ) } - override fun shouldExpandToolbar() = binding.statsScrollView.scrollY == 0 + override fun shouldExpandToolbar() = true + + override fun scrollToTop() { + return + } @OptIn(ExperimentalBadgeUtils::class) override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardViewModel.kt index 0edbf70b694..2df480a70b5 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardViewModel.kt @@ -25,6 +25,7 @@ import com.woocommerce.android.tools.SiteConnectionType import com.woocommerce.android.tools.connectionType import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.OpenEditWidgets +import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.RefreshJitm import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetUiModel.NewWidgetsCard import com.woocommerce.android.ui.dashboard.data.DashboardRepository import com.woocommerce.android.ui.prefs.privacy.banner.domain.ShouldShowPrivacyBanner @@ -105,19 +106,32 @@ class DashboardViewModel @Inject constructor( feedbackPrefs.userFeedbackIsDueObservable ) { configurableWidgets, hasNewWidgets, userFeedbackIsDue -> mapWidgetsToUiModels(configurableWidgets, hasNewWidgets, userFeedbackIsDue) - }.asLiveData() + } val hasNewWidgets = dashboardRepository.hasNewWidgets.asLiveData() private val refreshingOnBackground = MutableStateFlow(-1) val isRefreshingOnBackground = refreshingOnBackground.map { it > -1 }.asLiveData() - fun displayRefreshingIndicator() { refreshingOnBackground.value += 1 } + fun displayRefreshingIndicator() { + refreshingOnBackground.value += 1 + } + fun hideRefreshingIndicator() { val value = (refreshingOnBackground.value - 1).coerceAtLeast(-1) refreshingOnBackground.value = value } + val dashboardCardsState = combine( + dashboardWidgets, + refreshingOnBackground.map { it > -1 } + ) { widgets, isRefreshing -> + DashboardCardsState( + widgets = widgets, + isRefreshing = isRefreshing + ) + }.asLiveData() + init { ConnectionChangeReceiver.getEventBus().register(this) @@ -157,6 +171,7 @@ class DashboardViewModel @Inject constructor( usageTracksEventEmitter.interacted() analyticsTrackerWrapper.track(AnalyticsEvent.DASHBOARD_PULLED_TO_REFRESH) _refreshTrigger.tryEmit(RefreshEvent(isForced = true)) + triggerEvent(RefreshJitm) } fun onResume() { @@ -342,6 +357,8 @@ class DashboardViewModel @Inject constructor( data object FeedbackPositiveAction : DashboardEvent() data object FeedbackNegativeAction : DashboardEvent() + + data object RefreshJitm : DashboardEvent() } data class RefreshEvent(val isForced: Boolean = false) @@ -364,4 +381,9 @@ class DashboardViewModel @Inject constructor( action = action ) } + + data class DashboardCardsState( + val widgets: List, + val isRefreshing: Boolean + ) } diff --git a/WooCommerce/src/main/res/layout/fragment_dashboard.xml b/WooCommerce/src/main/res/layout/fragment_dashboard.xml index e846b7f3bc6..922c6a98a93 100644 --- a/WooCommerce/src/main/res/layout/fragment_dashboard.xml +++ b/WooCommerce/src/main/res/layout/fragment_dashboard.xml @@ -1,57 +1,29 @@ - - - - - - - - + android:layout_marginBottom="@dimen/minor_100" /> - - - - - - - - + + - - - + +