diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/details/CouponDetailsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/details/CouponDetailsScreen.kt index fdddc8b678f..567ad7ea19e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/details/CouponDetailsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/details/CouponDetailsScreen.kt @@ -83,9 +83,9 @@ fun CouponDetailsScreen( actions = { IconButton(onClick = { showMenu = !showMenu }) { Icon( - Icons.Filled.MoreVert, + imageVector = Icons.Filled.MoreVert, contentDescription = "Coupons Menu", - tint = colorResource(id = R.color.action_menu_fg_selector) + tint = MaterialTheme.colors.primary ) } DropdownMenu( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsCard.kt index 415f59b616d..4511552ca88 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsCard.kt @@ -1,32 +1,52 @@ package com.woocommerce.android.ui.dashboard.coupons +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.CircularProgressIndicator +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material.ContentAlpha import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.lifecycle.LiveData import androidx.lifecycle.Observer +import androidx.navigation.navOptions +import com.woocommerce.android.R +import com.woocommerce.android.extensions.navigateSafely +import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.model.DashboardWidget.Type.COUPONS import com.woocommerce.android.ui.analytics.ranges.StatsTimeRange import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType +import com.woocommerce.android.ui.compose.animations.SkeletonView import com.woocommerce.android.ui.compose.rememberNavController import com.woocommerce.android.ui.compose.viewModelWithFactory +import com.woocommerce.android.ui.coupons.CouponListFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardDateRangeHeader +import com.woocommerce.android.ui.dashboard.DashboardFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetMenu import com.woocommerce.android.ui.dashboard.WidgetCard import com.woocommerce.android.ui.dashboard.WidgetError import com.woocommerce.android.ui.dashboard.coupons.DashboardCouponsViewModel.DateRangeState import com.woocommerce.android.ui.dashboard.coupons.DashboardCouponsViewModel.State -import com.woocommerce.android.ui.dashboard.coupons.DashboardCouponsViewModel.State.Error -import com.woocommerce.android.ui.dashboard.coupons.DashboardCouponsViewModel.State.Loaded -import com.woocommerce.android.ui.dashboard.coupons.DashboardCouponsViewModel.State.Loading +import com.woocommerce.android.ui.dashboard.defaultHideMenuEntry import com.woocommerce.android.viewmodel.MultiLiveEvent import java.util.Date @@ -57,6 +77,11 @@ fun DashboardCouponsCard( viewState = viewState, onTabSelected = viewModel::onTabSelected, onCustomRangeClick = viewModel::onEditCustomRangeTapped, + onViewAllClick = viewModel::onViewAllClicked, + onCouponClick = viewModel::onCouponClicked, + onHideClick = { parentViewModel.onHideWidgetClicked(DashboardWidget.Type.COUPONS) }, + onRetryClick = viewModel::onRetryClicked, + onContactSupportClick = parentViewModel::onContactSupportClicked, modifier = modifier ) } @@ -75,6 +100,33 @@ private fun HandleEvents( is DashboardCouponsViewModel.OpenDatePicker -> { openDatePicker(event.fromDate.time, event.toDate.time) } + + DashboardCouponsViewModel.ViewAllCoupons -> { + navController.navigateSafely( + DashboardFragmentDirections.actionDashboardToCouponListFragment() + ) + } + + is DashboardCouponsViewModel.ViewCouponDetails -> { + navController.navigateSafely( + DashboardFragmentDirections.actionDashboardToCouponListFragment() + ) + navController.navigateSafely( + directions = CouponListFragmentDirections.actionCouponListFragmentToCouponDetailsFragment( + couponId = event.couponId + ), + skipThrottling = true, + navOptions = navOptions { + popUpTo(R.id.dashboard) + anim { + enter = R.anim.default_enter_anim + exit = R.anim.default_exit_anim + popEnter = R.anim.default_pop_enter_anim + popExit = R.anim.default_pop_exit_anim + } + } + ) + } } } @@ -92,38 +144,62 @@ private fun DashboardCouponsCard( viewState: State, onTabSelected: (SelectionType) -> Unit, onCustomRangeClick: () -> Unit, + onViewAllClick: () -> Unit, + onCouponClick: (Long) -> Unit, + onHideClick: () -> Unit, + onRetryClick: () -> Unit, + onContactSupportClick: () -> Unit, modifier: Modifier = Modifier ) { WidgetCard( titleResource = COUPONS.titleResource, - menu = DashboardWidgetMenu(emptyList()), + menu = DashboardWidgetMenu( + listOf( + DashboardWidget.Type.COUPONS.defaultHideMenuEntry { + onHideClick() + } + ) + ), + button = DashboardViewModel.DashboardWidgetAction( + titleResource = R.string.dashboard_coupons_view_all_button, + action = onViewAllClick + ), isError = viewState is Error, modifier = modifier ) { Column { - DashboardDateRangeHeader( - rangeSelection = dateRangeState.rangeSelection, - dateFormatted = dateRangeState.rangeFormatted, - onCustomRangeClick = onCustomRangeClick, - onTabSelected = onTabSelected, - modifier = Modifier.fillMaxWidth() - ) + if (viewState !is Error) { + DashboardDateRangeHeader( + rangeSelection = dateRangeState.rangeSelection, + dateFormatted = dateRangeState.rangeFormatted, + onCustomRangeClick = onCustomRangeClick, + onTabSelected = onTabSelected, + modifier = Modifier.fillMaxWidth() + ) - Divider() + Divider() + } when (viewState) { - is Loading -> { - CircularProgressIndicator() + is State.Loading -> { + CouponsLoading() } - is Loaded -> { - DashboardCouponsList(viewState) + is State.Loaded -> { + if (viewState.coupons.isEmpty()) { + CouponsEmptyView() + } else { + DashboardCouponsList( + state = viewState, + onCouponClick = onCouponClick + ) + } } - is Error -> { + is State.Error -> { WidgetError( - onContactSupportClicked = { /*TODO*/ }, - onRetryClicked = { /*TODO*/ } + onContactSupportClicked = onContactSupportClick, + onRetryClicked = onRetryClick ) } } @@ -133,13 +209,143 @@ private fun DashboardCouponsCard( @Composable private fun DashboardCouponsList( - state: Loaded, + state: State.Loaded, + onCouponClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier.padding(vertical = 8.dp)) { + Header( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + state.coupons.forEach { couponUiModel -> + CouponListItem( + couponUiModel = couponUiModel, + onClick = { onCouponClick(couponUiModel.id) } + ) + } + } +} + +@Composable +private fun CouponListItem( + couponUiModel: DashboardCouponsViewModel.CouponUiModel, + onClick: () -> Unit, modifier: Modifier = Modifier ) { - Column(modifier) { - state.coupons.forEach { - Text(text = it.code) - Divider() + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .clickable(onClick = onClick) + .padding(top = 8.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = couponUiModel.code, + style = MaterialTheme.typography.subtitle1 + ) + Text( + text = couponUiModel.uses.toString(), + style = MaterialTheme.typography.subtitle1 + ) } + Text( + text = couponUiModel.description, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier) + Divider(modifier = Modifier.padding(start = 16.dp)) + } +} + +@Composable +private fun CouponsLoading( + modifier: Modifier = Modifier +) { + Column(modifier.padding(vertical = 8.dp)) { + Header( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + repeat(3) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 8.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + SkeletonView(width = 260.dp, height = 16.dp) + SkeletonView(width = 40.dp, height = 16.dp) + } + SkeletonView( + modifier = Modifier + .size(width = 120.dp, height = 16.dp) + .padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier) + Divider(Modifier.padding(start = 16.dp)) + } + } + } +} + +@Composable +private fun CouponsEmptyView( + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Image( + painter = painterResource(id = R.drawable.img_empty_coupon_list), + contentDescription = null, + modifier = Modifier + .sizeIn(maxWidth = 160.dp, maxHeight = 160.dp) + .padding(vertical = 16.dp) + ) + + Text( + text = stringResource(id = R.string.dashboard_coupons_card_empty_view_message), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun Header(modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier + ) { + Text( + text = stringResource(id = R.string.dashboard_coupons_card_header_coupons), + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium) + ) + Text( + text = stringResource(id = R.string.dashboard_coupons_card_header_uses), + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium) + ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsViewModel.kt index b087f339832..2a2559ab699 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.woocommerce.android.AppPrefsWrapper import com.woocommerce.android.WooException -import com.woocommerce.android.model.Coupon import com.woocommerce.android.model.CouponPerformanceReport import com.woocommerce.android.ui.analytics.ranges.StatsTimeRange import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection @@ -15,6 +14,9 @@ import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.DashboardViewModel.RefreshEvent import com.woocommerce.android.ui.dashboard.data.CouponsCustomDateRangeDataStore import com.woocommerce.android.ui.dashboard.domain.DashboardDateRangeFormatter +import com.woocommerce.android.ui.products.ParameterRepository +import com.woocommerce.android.util.CoroutineDispatchers +import com.woocommerce.android.util.CouponUtils import com.woocommerce.android.util.WooLog import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ScopedViewModel @@ -23,6 +25,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -51,9 +54,15 @@ class DashboardCouponsViewModel @AssistedInject constructor( private val customDateRangeDataStore: CouponsCustomDateRangeDataStore, private val dateRangeFormatter: DashboardDateRangeFormatter, private val appPrefs: AppPrefsWrapper, + private val couponUtils: CouponUtils, + private val parameterRepository: ParameterRepository, + coroutineDispatchers: CoroutineDispatchers ) : ScopedViewModel(savedStateHandle) { companion object { - private const val COUPONS_LIMIT = 3 + // We store double the number of coupons to account for the possibility of some coupons being deleted + // As the report API keeps track of the deleted ones + private const val INTERNAL_COUPONS_LIMIT = 6 + private const val UI_COUPONS_LIMIT = 3 } private val _refreshTrigger = MutableSharedFlow(extraBufferCapacity = 1) @@ -64,6 +73,10 @@ class DashboardCouponsViewModel @AssistedInject constructor( private val selectedDateRange = getSelectedRange() .shareIn(viewModelScope, started = SharingStarted.WhileSubscribed(), replay = 1) + private val currencyCodeTask = async(coroutineDispatchers.io) { + parameterRepository.getParameters().currencyCode + } + val dateRangeState = combine( selectedDateRange, customDateRangeDataStore.dateRange @@ -130,6 +143,18 @@ class DashboardCouponsViewModel @AssistedInject constructor( } } + fun onViewAllClicked() { + triggerEvent(ViewAllCoupons) + } + + fun onCouponClicked(couponId: Long) { + triggerEvent(ViewCouponDetails(couponId)) + } + + fun onRetryClicked() { + _refreshTrigger.tryEmit(RefreshEvent()) + } + private fun observeCouponUiModels( dateRange: StatsTimeRange, forceRefresh: Boolean @@ -147,15 +172,25 @@ class DashboardCouponsViewModel @AssistedInject constructor( couponsResult.fold( onSuccess = { coupons -> // Map performance reports to coupons and preserve the order of mostActiveCoupons - val models = mostActiveCoupons.map { performanceReport -> - val coupon = coupons.firstOrNull { coupon -> coupon.id == performanceReport.couponId } - ?: error("Coupon not found for id: ${performanceReport.couponId}") - - CouponUiModel( - coupon = coupon, - performanceReport = performanceReport - ) - } + val models = mostActiveCoupons.mapNotNull { performanceReport -> + coupons.firstOrNull { coupon -> coupon.id == performanceReport.couponId } + ?.let { coupon -> + CouponUiModel( + id = coupon.id, + code = coupon.code.orEmpty(), + uses = performanceReport.ordersCount, + description = couponUtils.generateSummary(coupon, currencyCodeTask.await()) + ) + }.also { + if (it == null) { + WooLog.w( + WooLog.T.DASHBOARD, + "Coupon not found for performance report: $performanceReport," + + "it may have been deleted" + ) + } + } + }.take(UI_COUPONS_LIMIT) Result.success(models) }, @@ -177,7 +212,7 @@ class DashboardCouponsViewModel @AssistedInject constructor( emit( couponRepository.fetchMostActiveCoupons( dateRange = dateRange, - limit = COUPONS_LIMIT + limit = INTERNAL_COUPONS_LIMIT ).onSuccess { couponsReportCache[dateRange] = it } @@ -191,7 +226,7 @@ class DashboardCouponsViewModel @AssistedInject constructor( ) = flow { suspend fun fetchCoupons() = couponRepository.fetchCoupons( page = 1, - pageSize = COUPONS_LIMIT, + pageSize = INTERNAL_COUPONS_LIMIT, couponIds = couponIds ) @@ -232,13 +267,15 @@ class DashboardCouponsViewModel @AssistedInject constructor( ) data class CouponUiModel( - private val coupon: Coupon, - private val performanceReport: CouponPerformanceReport - ) { - val code: String = coupon.code.orEmpty() - } + val id: Long, + val code: String, + val uses: Int, + val description: String + ) data class OpenDatePicker(val fromDate: Date, val toDate: Date) : MultiLiveEvent.Event() + data object ViewAllCoupons : MultiLiveEvent.Event() + data class ViewCouponDetails(val couponId: Long) : MultiLiveEvent.Event() @AssistedFactory interface Factory { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt index ad54c9a9289..8c00c6da3fc 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt @@ -316,7 +316,7 @@ private fun Header( } @Composable -fun EmptyView( +private fun EmptyView( selectedFilter: ProductReviewStatus, modifier: Modifier = Modifier ) { diff --git a/WooCommerce/src/main/res/navigation/nav_graph_main.xml b/WooCommerce/src/main/res/navigation/nav_graph_main.xml index 1e2c5afec36..18648265266 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_main.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_main.xml @@ -90,6 +90,9 @@ + No reviews found No reviews match the selected filter, please try changing the filter View all reviews + + Coupons + Uses + View all coupons + No coupon usage during this period +