diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt index 6305687b97b..b8026fe55a3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt @@ -103,6 +103,7 @@ object AppPrefs { CARD_READER_DO_NOT_SHOW_CASH_ON_DELIVERY_DISABLED_ONBOARDING_STATE, ACTIVE_STATS_GRANULARITY, ACTIVE_TOP_PERFORMERS_GRANULARITY, + DASHBOARD_COUPONS_CARD_TAB, USE_SIMULATED_READER, UPDATE_SIMULATED_READER_OPTION, ENABLE_SIMULATED_INTERAC, @@ -865,6 +866,12 @@ object AppPrefs { setString(DeletablePrefKey.ACTIVE_TOP_PERFORMERS_GRANULARITY, selectionName) } + fun getActiveCouponsTab() = getString(DeletablePrefKey.DASHBOARD_COUPONS_CARD_TAB) + + fun setActiveCouponsTab(selectionName: String) { + setString(DeletablePrefKey.DASHBOARD_COUPONS_CARD_TAB, selectionName) + } + fun getActiveTopPerformersTab() = getString(DeletablePrefKey.ACTIVE_TOP_PERFORMERS_GRANULARITY) fun setCustomDomainsSource(source: String) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt index 8d2c1a971c4..1132315d7d6 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefsWrapper.kt @@ -209,15 +209,19 @@ class AppPrefsWrapper @Inject constructor() { AppPrefs.setActiveStatsTab(selectionName) } - fun getActiveStoreStatsTab() = - AppPrefs.getActiveStatsTab() + fun getActiveStoreStatsTab() = AppPrefs.getActiveStatsTab() fun setActiveTopPerformersTab(selectionName: String) { AppPrefs.setActiveTopPerformersTab(selectionName) } - fun getActiveTopPerformersTab() = - AppPrefs.getActiveTopPerformersTab() + fun getActiveTopPerformersTab() = AppPrefs.getActiveTopPerformersTab() + + fun getActiveCouponsTab() = AppPrefs.getActiveCouponsTab() + + fun setActiveCouponsTab(selectionName: String) { + AppPrefs.setActiveCouponsTab(selectionName) + } fun setCustomDomainsSource(source: DomainFlowSource) { AppPrefs.setCustomDomainsSource(source.name) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/datastore/DataStoreModule.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/datastore/DataStoreModule.kt index d54cf6a05fe..07fbb67bd8c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/datastore/DataStoreModule.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/datastore/DataStoreModule.kt @@ -91,4 +91,18 @@ class DataStoreModule { scope = CoroutineScope(appCoroutineScope.coroutineContext + Dispatchers.IO), serializer = CustomDateRangeSerializer ) + + @Provides + @Singleton + @DataStoreQualifier(DataStoreType.COUPONS) + fun provideCouponsCustomDateRangeDataStore( + appContext: Context, + @AppCoroutineScope appCoroutineScope: CoroutineScope + ): DataStore = DataStoreFactory.create( + produceFile = { + appContext.preferencesDataStoreFile("dashboard_coupons_custom_date_range_configuration") + }, + scope = CoroutineScope(appCoroutineScope.coroutineContext + Dispatchers.IO), + serializer = CustomDateRangeSerializer + ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/datastore/DataStoreType.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/datastore/DataStoreType.kt index 10afce40af3..ff02726ff08 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/datastore/DataStoreType.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/datastore/DataStoreType.kt @@ -5,5 +5,6 @@ enum class DataStoreType { ANALYTICS_UI_CACHE, ANALYTICS_CONFIGURATION, DASHBOARD_STATS, - TOP_PERFORMER_PRODUCTS + TOP_PERFORMER_PRODUCTS, + COUPONS } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/DashboardWidget.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/DashboardWidget.kt index 913a11cde69..2717eec1a25 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/DashboardWidget.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/DashboardWidget.kt @@ -31,6 +31,7 @@ data class DashboardWidget( BLAZE(R.string.my_store_widget_blaze_title, "blaze"), REVIEWS(R.string.my_store_widget_reviews_title, "reviews"), ORDERS(R.string.my_store_widget_orders_title, "orders"), + COUPONS(R.string.my_store_widget_coupons_title, "coupons"), } sealed interface Status : Parcelable { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/CouponRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/CouponRepository.kt index 33b2ee54c62..b3c8ba762d7 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/CouponRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/coupons/CouponRepository.kt @@ -8,9 +8,9 @@ import com.woocommerce.android.model.Coupon import com.woocommerce.android.model.CouponPerformanceReport import com.woocommerce.android.model.toAppModel import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.analytics.ranges.StatsTimeRange import com.woocommerce.android.util.DateUtils import com.woocommerce.android.util.WooLog -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map @@ -26,9 +26,16 @@ class CouponRepository @Inject constructor( ) { suspend fun fetchCoupons( page: Int, - pageSize: Int + pageSize: Int, + couponIds: List = emptyList() ): Result { - return store.fetchCoupons(selectedSite.get(), page, pageSize) + return store.fetchCoupons( + site = selectedSite.get(), + page = page, + pageSize = pageSize, + couponIds = couponIds, + deleteOldData = page == 1 && couponIds.isEmpty() + ) .let { result -> if (result.isError) { analyticsTrackerWrapper.track( @@ -84,8 +91,10 @@ class CouponRepository @Inject constructor( } } - @OptIn(ExperimentalCoroutinesApi::class) - fun observeCoupons(): Flow> = store.observeCoupons(selectedSite.get()).map { + fun observeCoupons(couponIds: List = emptyList()): Flow> = store.observeCoupons( + site = selectedSite.get(), + couponIds = couponIds + ).map { it.map { couponDataModel -> couponDataModel.toAppModel() } } @@ -110,6 +119,22 @@ class CouponRepository @Inject constructor( } } + suspend fun fetchMostActiveCoupons( + dateRange: StatsTimeRange, + limit: Int + ): Result> { + val result = store.fetchMostActiveCoupons( + site = selectedSite.get(), + dateRange = dateRange.start..dateRange.end, + limit = limit + ) + + return when { + result.isError -> Result.failure(WooException(result.error)) + else -> Result.success(result.model!!.map { it.toAppModel() }) + } + } + suspend fun deleteCoupon(couponId: Long): Result { val result = store.deleteCoupon( site = selectedSite.get(), @@ -152,6 +177,10 @@ class CouponRepository @Inject constructor( } } + suspend fun getCoupons(couponIds: List): List { + return store.getCoupons(selectedSite.get(), couponIds).map { it.toAppModel() } + } + private fun Coupon.createUpdateCouponRequest() = UpdateCouponRequest( code = code, 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 228c9ad8d5e..2bb7efb7a12 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 @@ -34,6 +34,7 @@ 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.blaze.DashboardBlazeCard +import com.woocommerce.android.ui.dashboard.coupons.DashboardCouponsCard import com.woocommerce.android.ui.dashboard.onboarding.DashboardOnboardingCard import com.woocommerce.android.ui.dashboard.orders.DashboardOrdersCard import com.woocommerce.android.ui.dashboard.reviews.DashboardReviewsCard @@ -144,6 +145,11 @@ private fun ConfigurableWidgetCard( parentViewModel = dashboardViewModel, modifier = modifier ) + + DashboardWidget.Type.COUPONS -> DashboardCouponsCard( + parentViewModel = dashboardViewModel, + modifier = modifier + ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsHeader.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardDateRangeHeader.kt similarity index 97% rename from WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsHeader.kt rename to WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardDateRangeHeader.kt index a31a92c73cd..32d5d6e73a4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsHeader.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardDateRangeHeader.kt @@ -1,4 +1,4 @@ -package com.woocommerce.android.ui.dashboard.stats +package com.woocommerce.android.ui.dashboard import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -34,10 +34,10 @@ import com.woocommerce.android.R import com.woocommerce.android.R.dimen import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType -import com.woocommerce.android.ui.dashboard.DashboardViewModel +import com.woocommerce.android.ui.dashboard.stats.DashboardStatsTestTags @Composable -fun DashboardStatsHeader( +fun DashboardDateRangeHeader( rangeSelection: StatsTimeRangeSelection, dateFormatted: String, onCustomRangeClick: () -> Unit, 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 new file mode 100644 index 00000000000..415f59b616d --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsCard.kt @@ -0,0 +1,145 @@ +package com.woocommerce.android.ui.dashboard.coupons + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +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.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +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.rememberNavController +import com.woocommerce.android.ui.compose.viewModelWithFactory +import com.woocommerce.android.ui.dashboard.DashboardDateRangeHeader +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.viewmodel.MultiLiveEvent +import java.util.Date + +@Composable +fun DashboardCouponsCard( + parentViewModel: DashboardViewModel, + modifier: Modifier = Modifier, + viewModel: DashboardCouponsViewModel = viewModelWithFactory { factory: DashboardCouponsViewModel.Factory -> + factory.create(parentViewModel) + } +) { + val viewState = viewModel.viewState.observeAsState().value ?: return + val dateRangeState = viewModel.dateRangeState.observeAsState().value ?: return + + HandleEvents( + event = viewModel.event, + openDatePicker = { fromDate, toDate -> + parentViewModel.onDashboardWidgetEvent( + DashboardViewModel.DashboardEvent.OpenRangePicker(fromDate, toDate) { from, to -> + viewModel.onCustomRangeSelected(StatsTimeRange(Date(from), Date(to))) + } + ) + } + ) + + DashboardCouponsCard( + dateRangeState = dateRangeState, + viewState = viewState, + onTabSelected = viewModel::onTabSelected, + onCustomRangeClick = viewModel::onEditCustomRangeTapped, + modifier = modifier + ) +} + +@Composable +private fun HandleEvents( + event: LiveData, + openDatePicker: (Long, Long) -> Unit, +) { + val navController = rememberNavController() + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(event, navController, lifecycleOwner) { + val observer = Observer { event: MultiLiveEvent.Event -> + when (event) { + is DashboardCouponsViewModel.OpenDatePicker -> { + openDatePicker(event.fromDate.time, event.toDate.time) + } + } + } + + event.observe(lifecycleOwner, observer) + + onDispose { + event.removeObserver(observer) + } + } +} + +@Composable +private fun DashboardCouponsCard( + dateRangeState: DateRangeState, + viewState: State, + onTabSelected: (SelectionType) -> Unit, + onCustomRangeClick: () -> Unit, + modifier: Modifier = Modifier +) { + WidgetCard( + titleResource = COUPONS.titleResource, + menu = DashboardWidgetMenu(emptyList()), + isError = viewState is Error, + modifier = modifier + ) { + Column { + DashboardDateRangeHeader( + rangeSelection = dateRangeState.rangeSelection, + dateFormatted = dateRangeState.rangeFormatted, + onCustomRangeClick = onCustomRangeClick, + onTabSelected = onTabSelected, + modifier = Modifier.fillMaxWidth() + ) + + Divider() + + when (viewState) { + is Loading -> { + CircularProgressIndicator() + } + + is Loaded -> { + DashboardCouponsList(viewState) + } + + is Error -> { + WidgetError( + onContactSupportClicked = { /*TODO*/ }, + onRetryClicked = { /*TODO*/ } + ) + } + } + } + } +} + +@Composable +private fun DashboardCouponsList( + state: Loaded, + modifier: Modifier = Modifier +) { + Column(modifier) { + state.coupons.forEach { + Text(text = it.code) + Divider() + } + } +} 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 new file mode 100644 index 00000000000..b087f339832 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/DashboardCouponsViewModel.kt @@ -0,0 +1,247 @@ +package com.woocommerce.android.ui.dashboard.coupons + +import androidx.lifecycle.SavedStateHandle +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 +import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType +import com.woocommerce.android.ui.coupons.CouponRepository +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.util.WooLog +import com.woocommerce.android.viewmodel.MultiLiveEvent +import com.woocommerce.android.viewmodel.ScopedViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel(assistedFactory = DashboardCouponsViewModel.Factory::class) +@Suppress("LongParameterList") +class DashboardCouponsViewModel @AssistedInject constructor( + savedStateHandle: SavedStateHandle, + @Assisted private val parentViewModel: DashboardViewModel, + private val couponRepository: CouponRepository, + getSelectedRange: GetSelectedRangeForCoupons, + private val customDateRangeDataStore: CouponsCustomDateRangeDataStore, + private val dateRangeFormatter: DashboardDateRangeFormatter, + private val appPrefs: AppPrefsWrapper, +) : ScopedViewModel(savedStateHandle) { + companion object { + private const val COUPONS_LIMIT = 3 + } + + private val _refreshTrigger = MutableSharedFlow(extraBufferCapacity = 1) + private val refreshTrigger = merge(parentViewModel.refreshTrigger, _refreshTrigger) + + private var couponsReportCache = mutableMapOf>() + + private val selectedDateRange = getSelectedRange() + .shareIn(viewModelScope, started = SharingStarted.WhileSubscribed(), replay = 1) + + val dateRangeState = combine( + selectedDateRange, + customDateRangeDataStore.dateRange + ) { rangeSelection, customRange -> + DateRangeState( + rangeSelection = rangeSelection, + customRange = customRange, + rangeFormatted = dateRangeFormatter.formatRangeDate(rangeSelection) + ) + }.asLiveData() + + val viewState = selectedDateRange.flatMapLatest { rangeSelection -> + refreshTrigger + .onStart { emit(RefreshEvent()) } + .transformLatest { + if (it.isForced || !couponsReportCache.containsKey(rangeSelection.currentRange)) { + emit(State.Loading) + } + emitAll( + observeCouponUiModels(rangeSelection.currentRange, it.isForced).map { result -> + result.fold( + onSuccess = { coupons -> State.Loaded(coupons) }, + onFailure = { error -> + when { + error is WooException && error.error.type == WooErrorType.API_NOT_FOUND -> + State.Error.WCAdminInactive + + else -> State.Error.Generic + } + } + ) + } + ) + } + }.asLiveData() + + fun onTabSelected(selectionType: SelectionType) { + if (selectionType != SelectionType.CUSTOM) { + appPrefs.setActiveCouponsTab(selectionType.name) + } else { + if (dateRangeState.value?.customRange == null) { + onEditCustomRangeTapped() + } else { + appPrefs.setActiveCouponsTab(SelectionType.CUSTOM.name) + } + } + } + + fun onEditCustomRangeTapped() { + triggerEvent( + OpenDatePicker( + fromDate = dateRangeState.value?.customRange?.start ?: Date(), + toDate = dateRangeState.value?.customRange?.end ?: Date() + ) + ) + } + + fun onCustomRangeSelected(range: StatsTimeRange) { + viewModelScope.launch { + customDateRangeDataStore.updateDateRange(range) + if (dateRangeState.value?.rangeSelection?.selectionType != SelectionType.CUSTOM) { + onTabSelected(SelectionType.CUSTOM) + } + } + } + + private fun observeCouponUiModels( + dateRange: StatsTimeRange, + forceRefresh: Boolean + ): Flow>> = + observeMostActiveCoupons( + dateRange = dateRange, + forceRefresh = forceRefresh + ).flatMapLatest { mostActiveCouponsResult -> + val mostActiveCoupons = mostActiveCouponsResult.getOrThrow() + + observeCoupons( + mostActiveCoupons.map { it.couponId }, + forceRefresh + ).map { couponsResult -> + 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 + ) + } + + Result.success(models) + }, + onFailure = { Result.failure(it) } + ) + } + }.catch { + WooLog.e(WooLog.T.DASHBOARD, "Failure while observing coupons", it) + emit(Result.failure(it)) + } + + private fun observeMostActiveCoupons( + dateRange: StatsTimeRange, + forceRefresh: Boolean + ) = flow { + if (!forceRefresh && couponsReportCache.containsKey(dateRange)) { + emit(Result.success(couponsReportCache.getValue(dateRange))) + } else { + emit( + couponRepository.fetchMostActiveCoupons( + dateRange = dateRange, + limit = COUPONS_LIMIT + ).onSuccess { + couponsReportCache[dateRange] = it + } + ) + } + } + + private fun observeCoupons( + couponIds: List, + forceRefresh: Boolean + ) = flow { + suspend fun fetchCoupons() = couponRepository.fetchCoupons( + page = 1, + pageSize = COUPONS_LIMIT, + couponIds = couponIds + ) + + val fetchCouponsBeforeEmitting = forceRefresh || + couponRepository.getCoupons(couponIds).size != couponIds.size + + if (fetchCouponsBeforeEmitting) { + fetchCoupons().onFailure { + emit(Result.failure(it)) + return@flow + } + } + + emitAll( + couponRepository.observeCoupons(couponIds) + .map { Result.success(it) } + .onStart { + if (!fetchCouponsBeforeEmitting) { + launch { fetchCoupons() } + } + } + ) + } + + sealed interface State { + data object Loading : State + data class Loaded(val coupons: List) : State + enum class Error : State { + Generic, + WCAdminInactive + } + } + + data class DateRangeState( + val rangeSelection: StatsTimeRangeSelection, + val customRange: StatsTimeRange?, + val rangeFormatted: String + ) + + data class CouponUiModel( + private val coupon: Coupon, + private val performanceReport: CouponPerformanceReport + ) { + val code: String = coupon.code.orEmpty() + } + + data class OpenDatePicker(val fromDate: Date, val toDate: Date) : MultiLiveEvent.Event() + + @AssistedFactory + interface Factory { + fun create(parentViewModel: DashboardViewModel): DashboardCouponsViewModel + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/GetSelectedRangeForCoupons.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/GetSelectedRangeForCoupons.kt new file mode 100644 index 00000000000..c75f042740d --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/coupons/GetSelectedRangeForCoupons.kt @@ -0,0 +1,19 @@ +package com.woocommerce.android.ui.dashboard.coupons + +import com.woocommerce.android.AppPrefsWrapper +import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType +import com.woocommerce.android.ui.dashboard.data.CouponsCustomDateRangeDataStore +import com.woocommerce.android.ui.dashboard.domain.GetSelectedDateRange +import com.woocommerce.android.util.DateUtils +import javax.inject.Inject + +class GetSelectedRangeForCoupons @Inject constructor( + private val appPrefs: AppPrefsWrapper, + customDateRangeDataStore: CouponsCustomDateRangeDataStore, + dateUtils: DateUtils +) : GetSelectedDateRange(appPrefs, customDateRangeDataStore, dateUtils) { + override fun getSelectedRange(): SelectionType = + runCatching { + SelectionType.valueOf(appPrefs.getActiveCouponsTab()) + }.getOrDefault(SelectionType.TODAY) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/CouponsCustomDateRangeDataStore.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/CouponsCustomDateRangeDataStore.kt new file mode 100644 index 00000000000..02d5df082eb --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/CouponsCustomDateRangeDataStore.kt @@ -0,0 +1,11 @@ +package com.woocommerce.android.ui.dashboard.data + +import androidx.datastore.core.DataStore +import com.woocommerce.android.datastore.DataStoreQualifier +import com.woocommerce.android.datastore.DataStoreType +import com.woocommerce.android.ui.mystore.data.CustomDateRange +import javax.inject.Inject + +class CouponsCustomDateRangeDataStore @Inject constructor( + @DataStoreQualifier(DataStoreType.COUPONS) dataStore: DataStore +) : CustomDateRangeDataStore(dataStore) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt index 44ebd38311e..bf70ac52f30 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt @@ -87,8 +87,10 @@ class DashboardDataStore @Inject constructor( // Use the feature flag [DYNAMIC_DASHBOARD_M2] to filter out unsupported widgets during development private val supportedWidgets: List = DashboardWidget.Type.entries .filter { - FeatureFlag.DYNAMIC_DASHBOARD_M2.isEnabled() || + FeatureFlag.DYNAMIC_DASHBOARD_M2.isEnabled() || ( it != DashboardWidget.Type.ORDERS && - it != DashboardWidget.Type.REVIEWS + it != DashboardWidget.Type.REVIEWS && + it != DashboardWidget.Type.COUPONS + ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsRangeFormatter.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/domain/DashboardDateRangeFormatter.kt similarity index 95% rename from WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsRangeFormatter.kt rename to WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/domain/DashboardDateRangeFormatter.kt index 5d8d867c3c1..da4c4a20491 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsRangeFormatter.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/domain/DashboardDateRangeFormatter.kt @@ -1,4 +1,4 @@ -package com.woocommerce.android.ui.dashboard.stats +package com.woocommerce.android.ui.dashboard.domain import android.icu.text.SimpleDateFormat import com.woocommerce.android.analytics.AnalyticsEvent @@ -11,7 +11,7 @@ import com.woocommerce.android.ui.dashboard.data.asRevenueRangeId import com.woocommerce.android.util.DateUtils import javax.inject.Inject -class DashboardStatsRangeFormatter @Inject constructor(private val dateUtils: DateUtils) { +class DashboardDateRangeFormatter @Inject constructor(private val dateUtils: DateUtils) { fun formatRangeDate(rangeSelection: StatsTimeRangeSelection): String { val startDate = rangeSelection.currentRange.start val endDate = rangeSelection.currentRange.end diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedDateRange.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/domain/GetSelectedDateRange.kt similarity index 97% rename from WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedDateRange.kt rename to WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/domain/GetSelectedDateRange.kt index 78c262e8cb1..edfbda7eb42 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedDateRange.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/domain/GetSelectedDateRange.kt @@ -1,4 +1,4 @@ -package com.woocommerce.android.ui.dashboard.stats +package com.woocommerce.android.ui.dashboard.domain import com.woocommerce.android.AppPrefsWrapper import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsCard.kt index 49dcd946eae..5f5858d3eab 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsCard.kt @@ -36,6 +36,7 @@ import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.Selec import com.woocommerce.android.ui.compose.component.WCOutlinedButton import com.woocommerce.android.ui.compose.rememberNavController import com.woocommerce.android.ui.compose.viewModelWithFactory +import com.woocommerce.android.ui.dashboard.DashboardDateRangeHeader import com.woocommerce.android.ui.dashboard.DashboardFragmentDirections import com.woocommerce.android.ui.dashboard.DashboardStatsUsageTracksEventEmitter import com.woocommerce.android.ui.dashboard.DashboardViewModel @@ -136,7 +137,7 @@ private fun DashboardStatsContent( ) { Column { dateRange?.let { - DashboardStatsHeader( + DashboardDateRangeHeader( rangeSelection = it.rangeSelection, dateFormatted = dateRange.selectedDateFormatted ?: dateRange.rangeFormatted, onCustomRangeClick = onAddCustomRangeClick, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsViewModel.kt index 9bee6071ed6..06466ad562d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsViewModel.kt @@ -24,6 +24,7 @@ import com.woocommerce.android.ui.dashboard.DashboardTransactionLauncher import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.DashboardViewModel.RefreshEvent import com.woocommerce.android.ui.dashboard.data.StatsCustomDateRangeDataStore +import com.woocommerce.android.ui.dashboard.domain.DashboardDateRangeFormatter import com.woocommerce.android.ui.dashboard.domain.ObserveLastUpdate import com.woocommerce.android.ui.dashboard.stats.GetStats.LoadStatsResult import com.woocommerce.android.util.CurrencyFormatter @@ -68,7 +69,7 @@ class DashboardStatsViewModel @AssistedInject constructor( private val observeLastUpdate: ObserveLastUpdate, private val wooCommerceStore: WooCommerceStore, private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, - private val dateRangeFormatter: DashboardStatsRangeFormatter, + private val dateRangeFormatter: DashboardDateRangeFormatter, val usageTracksEventEmitter: DashboardStatsUsageTracksEventEmitter, val dateUtils: DateUtils, val currencyFormatter: CurrencyFormatter diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedRangeForDashboardStats.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedRangeForDashboardStats.kt index 8ee069a2106..bf7ac2e5ec4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedRangeForDashboardStats.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedRangeForDashboardStats.kt @@ -3,6 +3,7 @@ package com.woocommerce.android.ui.dashboard.stats import com.woocommerce.android.AppPrefsWrapper import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType import com.woocommerce.android.ui.dashboard.data.StatsCustomDateRangeDataStore +import com.woocommerce.android.ui.dashboard.domain.GetSelectedDateRange import com.woocommerce.android.util.DateUtils import javax.inject.Inject diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersViewModel.kt index 3a93c7378e9..f084a397380 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersViewModel.kt @@ -27,11 +27,10 @@ import com.woocommerce.android.ui.dashboard.DashboardViewModel.RefreshEvent import com.woocommerce.android.ui.dashboard.TopPerformerProductUiModel import com.woocommerce.android.ui.dashboard.data.TopPerformersCustomDateRangeDataStore import com.woocommerce.android.ui.dashboard.defaultHideMenuEntry +import com.woocommerce.android.ui.dashboard.domain.DashboardDateRangeFormatter import com.woocommerce.android.ui.dashboard.domain.GetTopPerformers import com.woocommerce.android.ui.dashboard.domain.GetTopPerformers.TopPerformerProduct import com.woocommerce.android.ui.dashboard.domain.ObserveLastUpdate -import com.woocommerce.android.ui.dashboard.stats.DashboardStatsRangeFormatter -import com.woocommerce.android.ui.dashboard.stats.GetSelectedRangeForTopPerformers import com.woocommerce.android.util.CurrencyFormatter import com.woocommerce.android.util.DateUtils import com.woocommerce.android.viewmodel.MultiLiveEvent @@ -78,7 +77,7 @@ class DashboardTopPerformersViewModel @AssistedInject constructor( private val dateUtils: DateUtils, private val appPrefsWrapper: AppPrefsWrapper, private val customDateRangeDataStore: TopPerformersCustomDateRangeDataStore, - private val dateFormatter: DashboardStatsRangeFormatter, + private val dateFormatter: DashboardDateRangeFormatter, getSelectedDateRange: GetSelectedRangeForTopPerformers, ) : ScopedViewModel(savedState) { private val _selectedDateRange = getSelectedDateRange() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersWidgetCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersWidgetCard.kt index b433f031527..fdfa89fdb40 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersWidgetCard.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/DashboardTopPerformersWidgetCard.kt @@ -42,6 +42,7 @@ import com.woocommerce.android.ui.compose.component.ProductThumbnail import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews import com.woocommerce.android.ui.compose.rememberNavController import com.woocommerce.android.ui.compose.viewModelWithFactory +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.DashboardEvent.OpenRangePicker @@ -50,7 +51,6 @@ import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetMe import com.woocommerce.android.ui.dashboard.TopPerformerProductUiModel import com.woocommerce.android.ui.dashboard.WidgetCard import com.woocommerce.android.ui.dashboard.WidgetError -import com.woocommerce.android.ui.dashboard.stats.DashboardStatsHeader import com.woocommerce.android.ui.dashboard.stats.DashboardStatsTestTags import com.woocommerce.android.ui.dashboard.topperformers.DashboardTopPerformersViewModel.OpenAnalytics import com.woocommerce.android.ui.dashboard.topperformers.DashboardTopPerformersViewModel.OpenDatePicker @@ -125,7 +125,7 @@ fun DashboardTopPerformersContent( ) { Column { selectedDateRange?.let { - DashboardStatsHeader( + DashboardDateRangeHeader( rangeSelection = it.rangeSelection, dateFormatted = it.dateFormatted, onCustomRangeClick = onEditCustomRangeTapped, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedRangeForTopPerformers.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/GetSelectedRangeForTopPerformers.kt similarity index 85% rename from WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedRangeForTopPerformers.kt rename to WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/GetSelectedRangeForTopPerformers.kt index c37b6d62b14..c3560af0dee 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/stats/GetSelectedRangeForTopPerformers.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/topperformers/GetSelectedRangeForTopPerformers.kt @@ -1,8 +1,9 @@ -package com.woocommerce.android.ui.dashboard.stats +package com.woocommerce.android.ui.dashboard.topperformers import com.woocommerce.android.AppPrefsWrapper import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType import com.woocommerce.android.ui.dashboard.data.TopPerformersCustomDateRangeDataStore +import com.woocommerce.android.ui.dashboard.domain.GetSelectedDateRange import com.woocommerce.android.util.DateUtils import javax.inject.Inject diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/connectivitytool/useCases/StoreConnectionCheckUseCase.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/connectivitytool/useCases/StoreConnectionCheckUseCase.kt index 5439e2d3e5c..2dd527b995b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/connectivitytool/useCases/StoreConnectionCheckUseCase.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/connectivitytool/useCases/StoreConnectionCheckUseCase.kt @@ -30,7 +30,7 @@ class StoreConnectionCheckUseCase @Inject constructor( private fun WooResult.parseError() = when (error.type) { WooErrorType.TIMEOUT -> Failure(FailureType.TIMEOUT) - WooErrorType.PLUGIN_NOT_ACTIVE -> Failure(FailureType.JETPACK) + WooErrorType.API_NOT_FOUND -> Failure(FailureType.JETPACK) WooErrorType.INVALID_RESPONSE -> Failure(FailureType.PARSE) else -> Failure(FailureType.GENERIC) } diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 42550747513..a180102b550 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -413,6 +413,7 @@ Feedback Most recent orders Most recent reviews + Most active coupons Unavailable Completed diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsViewModelTest.kt index 2bc80e1a8c7..a8eac9f8c19 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/dashboard/stats/DashboardStatsViewModelTest.kt @@ -11,6 +11,7 @@ import com.woocommerce.android.ui.dashboard.DashboardTransactionLauncher import com.woocommerce.android.ui.dashboard.DashboardViewModel import com.woocommerce.android.ui.dashboard.DashboardViewModel.RefreshEvent import com.woocommerce.android.ui.dashboard.data.StatsCustomDateRangeDataStore +import com.woocommerce.android.ui.dashboard.domain.DashboardDateRangeFormatter import com.woocommerce.android.ui.dashboard.domain.ObserveLastUpdate import com.woocommerce.android.util.DateUtils import com.woocommerce.android.util.TimezoneProvider @@ -95,7 +96,7 @@ class DashboardStatsViewModelTest : BaseUnitTest() { observeLastUpdate = observeLastUpdate, timezoneProvider = timezoneProvider, wooCommerceStore = wooCommerceStore, - dateRangeFormatter = DashboardStatsRangeFormatter(dateUtils), + dateRangeFormatter = DashboardDateRangeFormatter(dateUtils), usageTracksEventEmitter = usageTracksEventEmitter, dateUtils = dateUtils, currencyFormatter = mock() diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/connectivitytool/useCases/StoreConnectionCheckUseCaseTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/connectivitytool/useCases/StoreConnectionCheckUseCaseTest.kt index ca82cc7da10..c08ba3b3102 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/connectivitytool/useCases/StoreConnectionCheckUseCaseTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/connectivitytool/useCases/StoreConnectionCheckUseCaseTest.kt @@ -62,7 +62,7 @@ class StoreConnectionCheckUseCaseTest : BaseUnitTest() { val stateEvents = mutableListOf() val response = WooResult( WooError( - type = WooErrorType.PLUGIN_NOT_ACTIVE, + type = WooErrorType.API_NOT_FOUND, original = BaseRequest.GenericErrorType.NETWORK_ERROR ) ) diff --git a/build.gradle b/build.gradle index 9216d53092c..7293de37d10 100644 --- a/build.gradle +++ b/build.gradle @@ -98,7 +98,7 @@ tasks.register("installGitHooks", Copy) { } ext { - fluxCVersion = '2.79.2' + fluxCVersion = 'trunk-09a0d45d25140a4b50095b1555ef6f1b0f7c510c' glideVersion = '4.16.0' coilVersion = '2.1.0' constraintLayoutVersion = '1.2.0'