From c99a7c69f0744cab8ca38edf66bcc95ad7da9ce0 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Tue, 23 Apr 2024 21:27:23 +0300 Subject: [PATCH 01/24] Add SubscribersUseCase to StatsModule --- .../android/ui/stats/refresh/StatsModule.kt | 38 +++--- .../ui/stats/refresh/lists/UiModelMapper.kt | 121 ++++++++++++------ 2 files changed, 103 insertions(+), 56 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/StatsModule.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/StatsModule.kt index 5ddc800c1c37..bfe9f5a57d69 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/StatsModule.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/StatsModule.kt @@ -62,6 +62,7 @@ import org.wordpress.android.ui.stats.refresh.lists.sections.insights.usecases.T import org.wordpress.android.ui.stats.refresh.lists.sections.insights.usecases.TotalFollowersUseCase.TotalFollowersUseCaseFactory import org.wordpress.android.ui.stats.refresh.lists.sections.insights.usecases.TotalLikesUseCase.TotalLikesUseCaseFactory import org.wordpress.android.ui.stats.refresh.lists.sections.insights.usecases.ViewsAndVisitorsUseCase.ViewsAndVisitorsUseCaseFactory +import org.wordpress.android.ui.stats.refresh.lists.sections.subscribers.usecases.SubscribersUseCase import org.wordpress.android.ui.stats.refresh.utils.SelectedTrafficGranularityManager import org.wordpress.android.ui.stats.refresh.utils.StatsSiteProvider import org.wordpress.android.util.config.StatsTrafficSubscribersTabFeatureConfig @@ -85,7 +86,7 @@ const val LIST_STATS_USE_CASES = "ListStatsUseCases" const val BLOCK_INSIGHTS_USE_CASES = "BlockInsightsUseCases" const val VIEW_ALL_INSIGHTS_USE_CASES = "ViewAllInsightsUseCases" const val GRANULAR_USE_CASE_FACTORIES = "GranularUseCaseFactories" -const val SUBSCRIBER_USE_CASE_FACTORIES = "SubscriberUseCaseFactories" +const val BLOCK_SUBSCRIBERS_USE_CASES = "BlockSubscribersUseCases" // These are injected only internally private const val BLOCK_DETAIL_USE_CASES = "BlockDetailUseCases" @@ -245,6 +246,18 @@ class StatsModule { ) } + /** + * Provides a list of use cases for the Subscribers screen based in Stats. Modify this method when you want to add + * more blocks to the Insights screen. + */ + @Provides + @Singleton + @Named(BLOCK_SUBSCRIBERS_USE_CASES) + @Suppress("LongParameterList") + fun provideBlockSubscribersUseCases( + subscribersUseCase: SubscribersUseCase + ): List<@JvmSuppressWildcards BaseStatsUseCase<*, *>> = listOf(subscribersUseCase) + /** * Provides a singleton usecase that represents the Insights screen. It consists of list of use cases that build * the insights blocks. @@ -300,24 +313,11 @@ class StatsModule { } /** - * Provides a list of use case factories that build use cases for the Subscribers stats screen based on the given - * granularity (Day, Week, Month, Year). + * Provides a singleton use case that represents the Subscribers Stats screen. It consists of list of use cases + * that build the subscribers blocks. */ @Provides @Singleton - @Named(SUBSCRIBER_USE_CASE_FACTORIES) - @Suppress("LongParameterList") - fun provideSubscriberUseCaseFactories( - ): List<@JvmSuppressWildcards BaseStatsUseCase<*, *>> { - return listOf( - ) - } - - /** - * Provides a singleton use case that represents the Subscribers Stats screen. - * @param useCasesFactories build the use cases for the DAYS granularity - */ - @Provides @Named(SUBSCRIBERS_USE_CASE) @Suppress("LongParameterList") fun provideSubscribersUseCase( @@ -325,7 +325,7 @@ class StatsModule { @Named(BG_THREAD) bgDispatcher: CoroutineDispatcher, @Named(UI_THREAD) mainDispatcher: CoroutineDispatcher, statsSiteProvider: StatsSiteProvider, - @Named(SUBSCRIBER_USE_CASE_FACTORIES) useCases: List<@JvmSuppressWildcards BaseStatsUseCase<*, *>>, + @Named(BLOCK_SUBSCRIBERS_USE_CASES) useCases: List<@JvmSuppressWildcards BaseStatsUseCase<*, *>>, uiModelMapper: UiModelMapper ): BaseListUseCase { return BaseListUseCase( @@ -333,8 +333,8 @@ class StatsModule { mainDispatcher, statsSiteProvider, useCases, - { statsStore.getInsightTypes(it) }, - uiModelMapper::mapInsights + { statsStore.getSubscriberTypes() }, + uiModelMapper::mapSubscribers ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt index 4eefe9cab16c..3d0e35292f1f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt @@ -50,12 +50,14 @@ class UiModelMapper UiModel.Success(data) } else if (!allFailingWithoutData) { showError(getErrorMessage()) - UiModel.Success(useCaseModels.map { useCaseModel -> - StatsBlock.Error( - useCaseModel.type, - useCaseModel.data ?: useCaseModel.stateData ?: listOf() - ) - }) + UiModel.Success( + useCaseModels.map { useCaseModel -> + StatsBlock.Error( + useCaseModel.type, + useCaseModel.data ?: useCaseModel.stateData ?: listOf() + ) + } + ) } else { UiModel.Error(getErrorMessage()) } @@ -76,6 +78,47 @@ class UiModelMapper return mapStatsWithOverview(TimeStatsType.OVERVIEW, useCaseModels, showError) } + fun mapSubscribers(useCaseModels: List, showError: (Int) -> Unit): UiModel { + val allFailing = useCaseModels.isNotEmpty() && useCaseModels.fold(true) { acc, useCaseModel -> + acc && useCaseModel.state == ERROR + } + val allFailingWithoutData = useCaseModels.fold(true) { acc, useCaseModel -> + acc && useCaseModel.state == ERROR && useCaseModel.data == null + } + return if (useCaseModels.isEmpty()) { + UiModel.Empty(R.string.loading) + } else if (!allFailing && !allFailingWithoutData) { + val data = useCaseModels.mapNotNull { useCaseModel -> + when (useCaseModel.state) { + LOADING -> useCaseModel.stateData?.let { + StatsBlock.Loading(useCaseModel.type, useCaseModel.stateData) + } + + SUCCESS -> StatsBlock.Success(useCaseModel.type, useCaseModel.data ?: listOf()) + ERROR -> useCaseModel.stateData?.let { + StatsBlock.Error(useCaseModel.type, useCaseModel.stateData) + } + + EMPTY -> useCaseModel.stateData?.let { + StatsBlock.EmptyBlock(useCaseModel.type, useCaseModel.stateData) + } + } + } + UiModel.Success(data) + } else if (!allFailingWithoutData) { + showError(getErrorMessage()) + val data = useCaseModels.map { useCaseModel -> + StatsBlock.Error( + useCaseModel.type, + useCaseModel.data ?: useCaseModel.stateData ?: listOf() + ) + } + UiModel.Success(data) + } else { + UiModel.Error(getErrorMessage()) + } + } + fun mapDetailStats( useCaseModels: List, showError: (Int) -> Unit @@ -97,50 +140,54 @@ class UiModelMapper val overviewHasData = useCaseModels.any { it.type == overViewType && it.data != null } return if (!allFailing && (overviewHasData || !overviewIsFailing)) { if (useCaseModels.isNotEmpty()) { - UiModel.Success(useCaseModels.mapNotNull { useCaseModel -> - when { - useCaseModel.state == LOADING -> useCaseModel.stateData?.let { - StatsBlock.Loading(useCaseModel.type, useCaseModel.stateData) - } + UiModel.Success( + useCaseModels.mapNotNull { useCaseModel -> + when { + useCaseModel.state == LOADING -> useCaseModel.stateData?.let { + StatsBlock.Loading(useCaseModel.type, useCaseModel.stateData) + } - useCaseModel.type == overViewType && useCaseModel.data != null -> StatsBlock.Success( - useCaseModel.type, - useCaseModel.data - ) + useCaseModel.type == overViewType && useCaseModel.data != null -> StatsBlock.Success( + useCaseModel.type, + useCaseModel.data + ) - useCaseModel.state == SUCCESS -> StatsBlock.Success( - useCaseModel.type, - useCaseModel.data ?: listOf() - ) + useCaseModel.state == SUCCESS -> StatsBlock.Success( + useCaseModel.type, + useCaseModel.data ?: listOf() + ) - useCaseModel.state == ERROR -> useCaseModel.stateData?.let { - StatsBlock.Error(useCaseModel.type, useCaseModel.stateData) - } + useCaseModel.state == ERROR -> useCaseModel.stateData?.let { + StatsBlock.Error(useCaseModel.type, useCaseModel.stateData) + } - useCaseModel.state == EMPTY -> useCaseModel.stateData?.let { - StatsBlock.EmptyBlock(useCaseModel.type, useCaseModel.stateData) - } + useCaseModel.state == EMPTY -> useCaseModel.stateData?.let { + StatsBlock.EmptyBlock(useCaseModel.type, useCaseModel.stateData) + } - else -> null + else -> null + } } - }) + ) } else { UiModel.Empty(R.string.loading) } } else if (overviewHasData) { showError(getErrorMessage()) - UiModel.Success(useCaseModels.mapNotNull { useCaseModel -> - if ((useCaseModel.type == overViewType) && useCaseModel.data != null) { - StatsBlock.Success(useCaseModel.type, useCaseModel.data) - } else { - useCaseModel.stateData?.let { - StatsBlock.Error( - useCaseModel.type, - useCaseModel.stateData - ) + UiModel.Success( + useCaseModels.mapNotNull { useCaseModel -> + if ((useCaseModel.type == overViewType) && useCaseModel.data != null) { + StatsBlock.Success(useCaseModel.type, useCaseModel.data) + } else { + useCaseModel.stateData?.let { + StatsBlock.Error( + useCaseModel.type, + useCaseModel.stateData + ) + } } } - }) + ) } else { UiModel.Error(getErrorMessage()) } From 9561165e2691448769bfd0b0855f87fc4dab7aa8 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Tue, 23 Apr 2024 21:29:48 +0300 Subject: [PATCH 02/24] Add SubscribersUseCase --- .../subscribers/usecases/SubscribersMapper.kt | 52 +++++++ .../usecases/SubscribersUseCase.kt | 145 ++++++++++++++++++ WordPress/src/main/res/values/strings.xml | 1 + 3 files changed, 198 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt new file mode 100644 index 000000000000..0bc97e8000e1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt @@ -0,0 +1,52 @@ +package org.wordpress.android.ui.stats.refresh.lists.sections.subscribers.usecases + +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel.PeriodData +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem.Line +import org.wordpress.android.ui.stats.refresh.utils.StatsDateFormatter +import org.wordpress.android.ui.stats.refresh.utils.StatsUtils +import org.wordpress.android.util.extensions.enforceWesternArabicNumerals +import javax.inject.Inject + +class SubscribersMapper @Inject constructor( + private val statsDateFormatter: StatsDateFormatter, + private val statsUtils: StatsUtils +) { + + fun buildChart( + dates: List, + onLineSelected: (String?) -> Unit, + onLineChartDrawn: (visibleBarCount: Int) -> Unit, + selectedItemPeriod: String + ): List { + val chartItems = dates.reversed().map { + val date = statsDateFormatter.parseStatsDate(StatsGranularity.DAYS, it.period) + Line( + statsDateFormatter.printDayWithoutYear(date).enforceWesternArabicNumerals() as String, + it.period, + it.subscribers.toInt() + ) + } + + val result = mutableListOf() + + val contentDescriptions = statsUtils.getSubscribersChartEntryContentDescriptions( + R.string.stats_subscribers_subscribers, + chartItems + ) + + result.add( + SubscribersChartItem( + entries = chartItems, + selectedItemPeriod = selectedItemPeriod, + onLineSelected = onLineSelected, + onLineChartDrawn = onLineChartDrawn, + entryContentDescriptions = contentDescriptions + ) + ) + return result + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt new file mode 100644 index 000000000000..5dc8e022a554 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt @@ -0,0 +1,145 @@ +package org.wordpress.android.ui.stats.refresh.lists.sections.subscribers.usecases + +import kotlinx.coroutines.CoroutineDispatcher +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.store.StatsStore.SubscriberType.SUBSCRIBERS +import org.wordpress.android.fluxc.store.stats.subscribers.SubscribersStore +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.modules.UI_THREAD +import org.wordpress.android.ui.stats.refresh.lists.sections.BaseStatsUseCase +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Title +import org.wordpress.android.ui.stats.refresh.lists.sections.granular.SelectedDateProvider +import org.wordpress.android.ui.stats.refresh.lists.sections.insights.InsightUseCaseFactory +import org.wordpress.android.ui.stats.refresh.lists.sections.subscribers.usecases.SubscribersUseCase.UiState +import org.wordpress.android.ui.stats.refresh.utils.StatsDateFormatter +import org.wordpress.android.ui.stats.refresh.utils.StatsSiteProvider +import org.wordpress.android.ui.stats.refresh.utils.trackGranular +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import javax.inject.Inject +import javax.inject.Named + +const val SUBSCRIBERS_ITEMS_TO_LOAD = 30 + +class SubscribersUseCase @Inject constructor( + private val subscribersStore: SubscribersStore, + private val selectedDateProvider: SelectedDateProvider, + private val statsSiteProvider: StatsSiteProvider, + private val statsDateFormatter: StatsDateFormatter, + private val subscribersMapper: SubscribersMapper, + @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, + @Named(BG_THREAD) private val backgroundDispatcher: CoroutineDispatcher, + private val analyticsTracker: AnalyticsTrackerWrapper +) : BaseStatsUseCase( + SUBSCRIBERS, + mainDispatcher, + backgroundDispatcher, + UiState(), + uiUpdateParams = listOf(UseCaseParam.SelectedDateParam(DAYS)) +) { + override fun buildLoadingItem(): List = listOf() + + override suspend fun loadCachedData() = subscribersStore.getSubscribers( + statsSiteProvider.siteModel, + DAYS, + LimitMode.Top(SUBSCRIBERS_ITEMS_TO_LOAD) + ) + + override suspend fun fetchRemoteData(forced: Boolean): State { + val response = subscribersStore.fetchSubscribers( + statsSiteProvider.siteModel, + DAYS, + LimitMode.Top(SUBSCRIBERS_ITEMS_TO_LOAD), + forced + ) + val model = response.model + val error = response.error + + return when { + error != null -> State.Error(error.message ?: error.type.name) + model != null && model.dates.isNotEmpty() -> State.Data(model) + else -> State.Empty() + } + } + + override fun buildUiModel(domainModel: SubscribersModel, uiState: UiState): List { + val items = mutableListOf() + if (domainModel.dates.isNotEmpty()) { + items.add(buildTitle()) + + val dateFromProvider = selectedDateProvider.getSelectedDate(DAYS) + val visibleLineCount = uiState.visibleLineCount ?: domainModel.dates.size + val availableDates = domainModel.dates.map { statsDateFormatter.parseStatsDate(DAYS, it.period) } + val selectedDate = dateFromProvider ?: availableDates.last() + val index = availableDates.indexOf(selectedDate) + + selectedDateProvider.selectDate(selectedDate, availableDates.takeLast(visibleLineCount), DAYS) + + val selectedItem = domainModel.dates.getOrNull(index) ?: domainModel.dates.last() + + items.addAll( + subscribersMapper.buildChart( + domainModel.dates.reversed(), + this::onLineSelected, + this::onLineChartDrawn, + selectedItem.period + ) + ) + } else { + selectedDateProvider.onDateLoadingFailed(DAYS) + AppLog.e(T.STATS, "There is no data to be shown in the subscribers block") + } + return items + } + + private fun buildTitle() = Title(R.string.stats_subscribers_subscribers) + + private fun onLineSelected(period: String?) { + analyticsTracker.trackGranular( + AnalyticsTracker.Stat.STATS_VIEWS_AND_VISITORS_LINE_CHART_TAPPED, + DAYS + ) + if (period != null && period != "empty") { + val selectedDate = statsDateFormatter.parseStatsDate(DAYS, period) + selectedDateProvider.selectDate( + selectedDate, + DAYS + ) + } + } + + private fun onLineChartDrawn(visibleLineCount: Int) { + updateUiState { it.copy(visibleLineCount = visibleLineCount) } + } + + data class UiState(val selectedPosition: Int = 0, val visibleLineCount: Int? = null) + + class SubscribersUseCaseFactory @Inject constructor( + @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, + @Named(BG_THREAD) private val backgroundDispatcher: CoroutineDispatcher, + private val statsSiteProvider: StatsSiteProvider, + private val selectedDateProvider: SelectedDateProvider, + private val statsDateFormatter: StatsDateFormatter, + private val subscribersMapper: SubscribersMapper, + private val subscribersStore: SubscribersStore, + private val analyticsTracker: AnalyticsTrackerWrapper + ) : InsightUseCaseFactory { + override fun build(useCaseMode: UseCaseMode) = + SubscribersUseCase( + subscribersStore, + selectedDateProvider, + statsSiteProvider, + statsDateFormatter, + subscribersMapper, + mainDispatcher, + backgroundDispatcher, + analyticsTracker + ) + } +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 58ff7d6dcf52..2c68bed47170 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1324,6 +1324,7 @@ Total Followers + Subscribers seconds ago a minute ago %1$d minutes From c37de1acb364d2f9f8009119c8e54b15123ce731 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Tue, 23 Apr 2024 21:33:29 +0300 Subject: [PATCH 03/24] Add SubscribersChartViewHolder --- .../ui/stats/refresh/BlockDiffCallback.kt | 2 + .../lists/sections/BlockListAdapter.kt | 5 + .../refresh/lists/sections/BlockListItem.kt | 15 + .../viewholders/SubscribersChartViewHolder.kt | 301 ++++++++++++++++++ .../ui/stats/refresh/utils/StatsUtils.kt | 18 ++ .../utils/SubscribersChartLabelFormatter.kt | 19 ++ 6 files changed, 360 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/SubscribersChartLabelFormatter.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/BlockDiffCallback.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/BlockDiffCallback.kt index 136e2ebf0af9..e75b3f41934e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/BlockDiffCallback.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/BlockDiffCallback.kt @@ -40,6 +40,7 @@ import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type. import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.PIE_CHART import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.QUICK_SCAN_ITEM import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.REFERRED_ITEM +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.SUBSCRIBERS_CHART import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.TABS import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.TAG_ITEM import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.TEXT @@ -71,6 +72,7 @@ class BlockDiffCallback( BAR_CHART, PIE_CHART, LINE_CHART, + SUBSCRIBERS_CHART, ACTIVITY_ITEM, LIST_ITEM -> oldItem.itemId == newItem.itemId LINK, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListAdapter.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListAdapter.kt index 3acdb7c217e4..c23893ad8dae 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListAdapter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListAdapter.kt @@ -33,6 +33,7 @@ import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.MapLe import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.PieChartItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.QuickScanItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.ReferredItem +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.TabsItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Tag import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Text @@ -66,6 +67,7 @@ import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type. import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.PIE_CHART import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.QUICK_SCAN_ITEM import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.REFERRED_ITEM +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.SUBSCRIBERS_CHART import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.TABS import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.TAG_ITEM import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.TEXT @@ -107,6 +109,7 @@ import org.wordpress.android.ui.stats.refresh.lists.sections.viewholders.MapView import org.wordpress.android.ui.stats.refresh.lists.sections.viewholders.PieChartViewHolder import org.wordpress.android.ui.stats.refresh.lists.sections.viewholders.QuickScanItemViewHolder import org.wordpress.android.ui.stats.refresh.lists.sections.viewholders.ReferredItemViewHolder +import org.wordpress.android.ui.stats.refresh.lists.sections.viewholders.SubscribersChartViewHolder import org.wordpress.android.ui.stats.refresh.lists.sections.viewholders.TabsViewHolder import org.wordpress.android.ui.stats.refresh.lists.sections.viewholders.TagViewHolder import org.wordpress.android.ui.stats.refresh.lists.sections.viewholders.TextViewHolder @@ -148,6 +151,7 @@ class BlockListAdapter(val imageManager: ImageManager) : Adapter BarChartViewHolder(parent) PIE_CHART -> PieChartViewHolder(parent) LINE_CHART -> LineChartViewHolder(parent) + SUBSCRIBERS_CHART -> SubscribersChartViewHolder(parent) CHART_LEGEND -> ChartLegendViewHolder(parent) CHART_LEGENDS_BLUE -> ChartLegendsBlueViewHolder(parent) CHART_LEGENDS_PURPLE -> ChartLegendsPurpleViewHolder(parent) @@ -198,6 +202,7 @@ class BlockListAdapter(val imageManager: ImageManager) : Adapter holder.bind(item as BarChartItem) is PieChartViewHolder -> holder.bind(item as PieChartItem) is LineChartViewHolder -> holder.bind(item as LineChartItem) + is SubscribersChartViewHolder -> holder.bind(item as SubscribersChartItem) is ChartLegendViewHolder -> holder.bind(item as ChartLegend) is ChartLegendsBlueViewHolder -> holder.bind(item as ChartLegendsBlue) is ChartLegendsPurpleViewHolder -> holder.bind(item as ChartLegendsPurple) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListItem.kt index 222fc9b6bb50..860f91512305 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListItem.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListItem.kt @@ -34,6 +34,7 @@ import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type. import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.PIE_CHART import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.QUICK_SCAN_ITEM import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.REFERRED_ITEM +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.SUBSCRIBERS_CHART import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.TABS import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.TAG_ITEM import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.TEXT @@ -73,6 +74,7 @@ sealed class BlockListItem(val type: Type) { BAR_CHART, PIE_CHART, LINE_CHART, + SUBSCRIBERS_CHART, CHART_LEGEND, CHART_LEGENDS_BLUE, CHART_LEGENDS_PURPLE, @@ -300,6 +302,19 @@ sealed class BlockListItem(val type: Type) { get() = entries.hashCode() } + data class SubscribersChartItem( + val entries: List, + val selectedItemPeriod: String? = null, + val onLineSelected: ((period: String?) -> Unit)? = null, + val onLineChartDrawn: ((visibleLineCount: Int) -> Unit)? = null, + val entryContentDescriptions: List + ) : BlockListItem(SUBSCRIBERS_CHART) { + data class Line(val label: String, val id: String, val value: Int) + + override val itemId: Int + get() = entries.hashCode() + } + data class ChartLegend(@StringRes val text: Int) : BlockListItem(CHART_LEGEND) data class ChartLegendsBlue( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt new file mode 100644 index 000000000000..ebd8a9340a01 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -0,0 +1,301 @@ +package org.wordpress.android.ui.stats.refresh.lists.sections.viewholders + +import android.graphics.DashPathEffect +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import com.github.mikephil.charting.animation.Easing +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.Description +import com.github.mikephil.charting.components.XAxis.XAxisPosition.BOTTOM +import com.github.mikephil.charting.components.YAxis.AxisDependency.LEFT +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.data.LineDataSet.Mode.CUBIC_BEZIER +import com.github.mikephil.charting.highlight.Highlight +import com.github.mikephil.charting.interfaces.datasets.ILineDataSet +import com.github.mikephil.charting.listener.OnChartValueSelectedListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.ui.stats.refresh.LineChartMarkerView +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem.Line +import org.wordpress.android.ui.stats.refresh.utils.LargeValueFormatter +import org.wordpress.android.ui.stats.refresh.utils.LineChartAccessibilityHelper +import org.wordpress.android.ui.stats.refresh.utils.LineChartAccessibilityHelper.LineChartAccessibilityEvent +import org.wordpress.android.ui.stats.refresh.utils.SubscribersChartLabelFormatter +import java.lang.Integer.max + +@Suppress("MagicNumber") +class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( + parent, + R.layout.stats_block_line_chart_item +) { + private val coroutineScope = CoroutineScope(Dispatchers.Main) + private val chart = itemView.findViewById(R.id.line_chart) + private lateinit var accessibilityHelper: LineChartAccessibilityHelper + + fun bind(item: SubscribersChartItem) { + chart.setNoDataText("") + + coroutineScope.launch { + delay(50) + chart.draw(item) + if (hasData(item.entries)) { + chart.post { + val accessibilityEvent = object : LineChartAccessibilityEvent { + override fun onHighlight( + entry: Entry, + index: Int + ) { + drawChartMarker(Highlight(entry.x, entry.y, 0)) + val value = entry.data as? String + value?.let { + item.onLineSelected?.invoke(it) + } + } + } + + val cutContentDescriptions = takeEntriesWithinGraphWidth(item.entryContentDescriptions) + accessibilityHelper = LineChartAccessibilityHelper( + chart, + contentDescriptions = cutContentDescriptions, + accessibilityEvent = accessibilityEvent + ) + + ViewCompat.setAccessibilityDelegate(chart, accessibilityHelper) + } + } + } + } + + private fun LineChart.draw(item: SubscribersChartItem) { + resetChart() + + data = LineData(getData(item)) + + configureChartView(item) + configureYAxis(item) + configureXAxis(item) + configureDataSets(data.dataSets) + + invalidate() + } + + private fun getData(item: SubscribersChartItem): List { + val data = if (hasData(item.entries)) { + val mappedEntries = item.entries.mapIndexed { index, pair -> toLineEntry(pair, index) } + LineDataSet(mappedEntries, null) + } else { + buildEmptyDataSet(item.entries.size) + } + item.onLineChartDrawn?.invoke(data.entryCount) + + return listOf(data) + } + + private fun hasData(entries: List) = entries.isNotEmpty() && entries.any { it.value > 0 } + + private fun configureChartView(item: SubscribersChartItem) { + chart.apply { + setPinchZoom(false) + setScaleEnabled(false) + legend.isEnabled = false + setDrawBorders(false) + extraLeftOffset = 16f + axisRight.isEnabled = false + + isHighlightPerDragEnabled = false + val description = Description() + description.text = "" + this.description = description + + animateX(1000, Easing.EaseInSine) + + val isClickable = item.onLineSelected != null + if (isClickable) { + setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onNothingSelected() { + item.onLineSelected?.invoke(item.selectedItemPeriod) + } + + override fun onValueSelected(e: Entry, h: Highlight) { + drawChartMarker(h) + item.onLineSelected?.invoke(e.data as? String) + } + }) + } else { + setOnChartValueSelectedListener(null) + } + isHighlightPerTapEnabled = isClickable + } + } + + private fun configureYAxis(item: SubscribersChartItem) { + val minYValue = 6f + val maxYValue = item.entries.maxByOrNull { it.value }!!.value + + chart.axisLeft.apply { + valueFormatter = LargeValueFormatter() + setDrawGridLines(true) + setDrawTopYLabelEntry(true) + setDrawZeroLine(true) + setDrawAxisLine(false) + granularity = 1F + axisMinimum = 0F + axisMaximum = if (maxYValue < minYValue) { + minYValue + } else { + roundUp(maxYValue.toFloat()) + } + setLabelCount(5, true) + textColor = ContextCompat.getColor(chart.context, R.color.neutral_30) + gridColor = ContextCompat.getColor(chart.context, R.color.stats_bar_chart_gridline) + textSize = 10f + gridLineWidth = 1f + } + } + + private fun configureXAxis(item: SubscribersChartItem) { + chart.xAxis.apply { + granularity = 1f + setDrawAxisLine(true) + setDrawGridLines(false) + + if (chart.contentRect.width() > 0) { + axisLineWidth = 4.0F + + val count = max(item.entries.count(), 7) + val tickWidth = 4.0F + val contentWidthMinusTicks = chart.contentRect.width() - (tickWidth * count.toFloat()) + setAxisLineDashedLine( + DashPathEffect( + floatArrayOf(tickWidth, (contentWidthMinusTicks / (count - 1).toFloat())), + 0f + ) + ) + } + + setDrawLabels(true) + setLabelCount(3, true) + setAvoidFirstLastClipping(true) + position = BOTTOM + valueFormatter = SubscribersChartLabelFormatter(item.entries) + textColor = ContextCompat.getColor(chart.context, R.color.neutral_30) + + removeAllLimitLines() + } + } + + private fun drawChartMarker(h: Highlight) { + if (chart.marker == null) { + val markerView = LineChartMarkerView(chart.context, 0) + markerView.chartView = chart + chart.marker = markerView + } + chart.highlightValue(h) + } + + private fun configureDataSets(dataSets: MutableList) { + val thisWeekDataSet = dataSets.first() as? LineDataSet + thisWeekDataSet?.apply { + axisDependency = LEFT + + lineWidth = 2f + formLineWidth = 0f + + setDrawValues(false) + setDrawCircles(false) + + highLightColor = ContextCompat.getColor(chart.context, R.color.gray_10) + highlightLineWidth = 1F + setDrawVerticalHighlightIndicator(true) + setDrawHorizontalHighlightIndicator(false) + enableDashedHighlightLine(4.4F, 1F, 0F) + isHighlightEnabled = true + + mode = CUBIC_BEZIER + cubicIntensity = 0.2f + color = ContextCompat.getColor(chart.context, R.color.blue_50) + + setDrawFilled(true) + fillDrawable = + ContextCompat.getDrawable( + chart.context, + R.drawable.bg_rectangle_stats_line_chart_blue_gradient + )?.apply { alpha = 26 } + } + + val lastWeekDataSet = dataSets.last() as? LineDataSet + lastWeekDataSet?.apply { + axisDependency = LEFT + + lineWidth = 2f + + mode = CUBIC_BEZIER + cubicIntensity = 0.2f + + color = ContextCompat.getColor(chart.context, R.color.gray_10) + + setDrawValues(false) + setDrawCircles(false) + isHighlightEnabled = false + } + } + + private fun buildEmptyDataSet(count: Int): LineDataSet { + val emptyValues = (0 until count).map { index -> Entry(index.toFloat(), 0f, "empty") } + val dataSet = LineDataSet(emptyValues, "Empty") + + dataSet.setDrawCircles(false) + dataSet.setDrawValues(false) + dataSet.isHighlightEnabled = false + dataSet.fillAlpha = 80 + dataSet.setDrawHighlightIndicators(false) + + return dataSet + } + + private fun takeEntriesWithinGraphWidth(entries: List): List { + return if (8 < entries.size) { + entries.subList(entries.size - 8, entries.size) + } else { + entries + } + } + + private fun LineChart.resetChart() { + fitScreen() + data?.clearValues() + xAxis.valueFormatter = null + notifyDataSetChanged() + clear() + invalidate() + } + + private fun toLineEntry(line: Line, index: Int): Entry { + return Entry( + index.toFloat(), + line.value.toFloat(), + line.id + ) + } + + private fun roundUp(input: Float): Float { + return if (input > 100) { + roundUp(input / 10) * 10 + } else { + for (i in 1..25) { + val limit = 4 * i + if (input < limit) { + return limit.toFloat() + } + } + 100F + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsUtils.kt index b22225ca4415..05119782c8d7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsUtils.kt @@ -2,6 +2,7 @@ package org.wordpress.android.ui.stats.refresh.utils import androidx.annotation.StringRes import org.wordpress.android.R +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.BarChartItem.Bar import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.LineChartItem.Line import org.wordpress.android.util.LocaleManagerWrapper @@ -185,6 +186,23 @@ class StatsUtils @Inject constructor( return contentDescriptions } + fun getSubscribersChartEntryContentDescriptions( + @StringRes entryType: Int, + entries: List + ): List { + val contentDescriptions = mutableListOf() + entries.forEach { bar -> + val contentDescription = resourceProvider.getString( + R.string.stats_bar_chart_accessibility_entry, + bar.label, + bar.value, + resourceProvider.getString(entryType) + ) + contentDescriptions.add(contentDescription) + } + return contentDescriptions + } + fun buildChange( previousValue: Long?, value: Long, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/SubscribersChartLabelFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/SubscribersChartLabelFormatter.kt new file mode 100644 index 000000000000..6a153a7708bc --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/SubscribersChartLabelFormatter.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.ui.stats.refresh.utils + +import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.formatter.ValueFormatter +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem.Line +import javax.inject.Inject + +class SubscribersChartLabelFormatter @Inject constructor( + val entries: List +) : ValueFormatter() { + override fun getAxisLabel(value: Float, axis: AxisBase?): String { + val index = value.toInt() + return if (entries.isNotEmpty() && index in 0..entries.size) { + entries[index].label + } else { + "" + } + } +} From 6e38e5cc86ec5d6f80981cfdc4a7c1fe9ffbc729 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Tue, 23 Apr 2024 21:42:27 +0300 Subject: [PATCH 04/24] Update FluxC reference to the PR --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f2d79e648369..993088b6ca1f 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ ext { automatticTracksVersion = '4.0.2' gutenbergMobileVersion = 'v1.117.0' wordPressAztecVersion = 'v2.1.1' - wordPressFluxCVersion = 'trunk-b5daa1e612ae7ce5a248c5975f90b0e0093378b1' + wordPressFluxCVersion = '2993-f0292783f4b0a98ae1251eaae0a202fba71accd3' wordPressLoginVersion = '1.15.0' wordPressPersistentEditTextVersion = '1.0.2' wordPressUtilsVersion = '3.14.0' From 5cde87231cf4b32394cd2f7241a3289ca63a762c Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Tue, 23 Apr 2024 23:01:18 +0300 Subject: [PATCH 05/24] Replace 'Enum.values()' by 'Enum.entries' in BlockListAdapter --- .../ui/stats/refresh/lists/sections/BlockListAdapter.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListAdapter.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListAdapter.kt index c23893ad8dae..6dfe06dafc3f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListAdapter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListAdapter.kt @@ -76,7 +76,6 @@ import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type. import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.VALUES_ITEM import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.VALUE_ITEM import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.VALUE_WITH_CHART_ITEM -import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Type.values import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.ValueItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.ValueWithChartItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.ValuesItem @@ -134,7 +133,7 @@ class BlockListAdapter(val imageManager: ImageManager) : Adapter TitleViewHolder(parent) TITLE_WITH_MORE -> TitleWithMoreViewHolder(parent) BIG_TITLE -> BigTitleViewHolder(parent) From b0ce23d003c446781cbd33df6b2157d31fef9ea2 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Tue, 23 Apr 2024 23:01:50 +0300 Subject: [PATCH 06/24] Update Subscribers graph view --- .../subscribers/usecases/SubscribersMapper.kt | 21 +++--- .../usecases/SubscribersUseCase.kt | 2 +- .../viewholders/SubscribersChartViewHolder.kt | 65 ++++--------------- 3 files changed, 23 insertions(+), 65 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt index 0bc97e8000e1..b356ece22f80 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt @@ -21,8 +21,8 @@ class SubscribersMapper @Inject constructor( onLineSelected: (String?) -> Unit, onLineChartDrawn: (visibleBarCount: Int) -> Unit, selectedItemPeriod: String - ): List { - val chartItems = dates.reversed().map { + ): BlockListItem { + val chartItems = dates.map { val date = statsDateFormatter.parseStatsDate(StatsGranularity.DAYS, it.period) Line( statsDateFormatter.printDayWithoutYear(date).enforceWesternArabicNumerals() as String, @@ -31,22 +31,17 @@ class SubscribersMapper @Inject constructor( ) } - val result = mutableListOf() - val contentDescriptions = statsUtils.getSubscribersChartEntryContentDescriptions( R.string.stats_subscribers_subscribers, chartItems ) - result.add( - SubscribersChartItem( - entries = chartItems, - selectedItemPeriod = selectedItemPeriod, - onLineSelected = onLineSelected, - onLineChartDrawn = onLineChartDrawn, - entryContentDescriptions = contentDescriptions - ) + return SubscribersChartItem( + entries = chartItems, + selectedItemPeriod = selectedItemPeriod, + onLineSelected = onLineSelected, + onLineChartDrawn = onLineChartDrawn, + entryContentDescriptions = contentDescriptions ) - return result } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt index 5dc8e022a554..112d56f0d7fb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt @@ -83,7 +83,7 @@ class SubscribersUseCase @Inject constructor( val selectedItem = domainModel.dates.getOrNull(index) ?: domainModel.dates.last() - items.addAll( + items.add( subscribersMapper.buildChart( domainModel.dates.reversed(), this::onLineSelected, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt index ebd8a9340a01..378c588a51b6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -1,6 +1,5 @@ package org.wordpress.android.ui.stats.refresh.lists.sections.viewholders -import android.graphics.DashPathEffect import android.view.ViewGroup import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat @@ -28,7 +27,6 @@ import org.wordpress.android.ui.stats.refresh.utils.LargeValueFormatter import org.wordpress.android.ui.stats.refresh.utils.LineChartAccessibilityHelper import org.wordpress.android.ui.stats.refresh.utils.LineChartAccessibilityHelper.LineChartAccessibilityEvent import org.wordpress.android.ui.stats.refresh.utils.SubscribersChartLabelFormatter -import java.lang.Integer.max @Suppress("MagicNumber") class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( @@ -48,10 +46,7 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( if (hasData(item.entries)) { chart.post { val accessibilityEvent = object : LineChartAccessibilityEvent { - override fun onHighlight( - entry: Entry, - index: Int - ) { + override fun onHighlight(entry: Entry, index: Int) { drawChartMarker(Highlight(entry.x, entry.y, 0)) val value = entry.data as? String value?.let { @@ -137,7 +132,7 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( private fun configureYAxis(item: SubscribersChartItem) { val minYValue = 6f - val maxYValue = item.entries.maxByOrNull { it.value }!!.value + val maxYValue = item.entries.maxByOrNull { it.value }?.value ?: 7 chart.axisLeft.apply { valueFormatter = LargeValueFormatter() @@ -169,15 +164,9 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( if (chart.contentRect.width() > 0) { axisLineWidth = 4.0F - val count = max(item.entries.count(), 7) val tickWidth = 4.0F - val contentWidthMinusTicks = chart.contentRect.width() - (tickWidth * count.toFloat()) - setAxisLineDashedLine( - DashPathEffect( - floatArrayOf(tickWidth, (contentWidthMinusTicks / (count - 1).toFloat())), - 0f - ) - ) + val contentWidthMinusTicks = chart.contentRect.width() - (tickWidth * 3f) + enableAxisLineDashedLine(tickWidth, contentWidthMinusTicks / 29f, 0f) } setDrawLabels(true) @@ -201,8 +190,7 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( } private fun configureDataSets(dataSets: MutableList) { - val thisWeekDataSet = dataSets.first() as? LineDataSet - thisWeekDataSet?.apply { + (dataSets.first() as? LineDataSet)?.apply { axisDependency = LEFT lineWidth = 2f @@ -223,27 +211,10 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( color = ContextCompat.getColor(chart.context, R.color.blue_50) setDrawFilled(true) - fillDrawable = - ContextCompat.getDrawable( - chart.context, - R.drawable.bg_rectangle_stats_line_chart_blue_gradient - )?.apply { alpha = 26 } - } - - val lastWeekDataSet = dataSets.last() as? LineDataSet - lastWeekDataSet?.apply { - axisDependency = LEFT - - lineWidth = 2f - - mode = CUBIC_BEZIER - cubicIntensity = 0.2f - - color = ContextCompat.getColor(chart.context, R.color.gray_10) - - setDrawValues(false) - setDrawCircles(false) - isHighlightEnabled = false + fillDrawable = ContextCompat.getDrawable( + chart.context, + R.drawable.bg_rectangle_stats_line_chart_blue_gradient + )?.apply { alpha = 26 } } } @@ -260,12 +231,10 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( return dataSet } - private fun takeEntriesWithinGraphWidth(entries: List): List { - return if (8 < entries.size) { - entries.subList(entries.size - 8, entries.size) - } else { - entries - } + private fun takeEntriesWithinGraphWidth(entries: List) = if (8 < entries.size) { + entries.subList(entries.size - 8, entries.size) + } else { + entries } private fun LineChart.resetChart() { @@ -277,13 +246,7 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( invalidate() } - private fun toLineEntry(line: Line, index: Int): Entry { - return Entry( - index.toFloat(), - line.value.toFloat(), - line.id - ) - } + private fun toLineEntry(line: Line, index: Int) = Entry(index.toFloat(), line.value.toFloat(), line.id) private fun roundUp(input: Float): Float { return if (input > 100) { From 892a3ad4a23b05ac7825360e98910761ce362f5f Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Tue, 23 Apr 2024 23:19:21 +0300 Subject: [PATCH 07/24] Fix detekt and checkstyle warnings --- .../wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt | 1 + .../lists/sections/subscribers/usecases/SubscribersMapper.kt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt index 3d0e35292f1f..7313118de49a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt @@ -78,6 +78,7 @@ class UiModelMapper return mapStatsWithOverview(TimeStatsType.OVERVIEW, useCaseModels, showError) } + @Suppress("CyclomaticComplexMethod") fun mapSubscribers(useCaseModels: List, showError: (Int) -> Unit): UiModel { val allFailing = useCaseModels.isNotEmpty() && useCaseModels.fold(true) { acc, useCaseModel -> acc && useCaseModel.state == ERROR diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt index b356ece22f80..cc142495a47c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt @@ -15,7 +15,6 @@ class SubscribersMapper @Inject constructor( private val statsDateFormatter: StatsDateFormatter, private val statsUtils: StatsUtils ) { - fun buildChart( dates: List, onLineSelected: (String?) -> Unit, From a530e6058bb1b1f7f1cba816a6250f8c89fabe87 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 11:26:58 +0300 Subject: [PATCH 08/24] Adjust stats_block_line_chart_item margins --- WordPress/src/main/res/layout/stats_block_line_chart_item.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/res/layout/stats_block_line_chart_item.xml b/WordPress/src/main/res/layout/stats_block_line_chart_item.xml index a99e3fa8ad82..be65bb4ed043 100644 --- a/WordPress/src/main/res/layout/stats_block_line_chart_item.xml +++ b/WordPress/src/main/res/layout/stats_block_line_chart_item.xml @@ -3,7 +3,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_margin="@dimen/margin_medium" + android:layout_marginBottom="@dimen/margin_extra_large" + android:layout_marginHorizontal="@dimen/margin_medium" android:importantForAccessibility="no"> Date: Wed, 24 Apr 2024 15:02:46 +0300 Subject: [PATCH 09/24] Add getStatsDateFromPeriodDay() function to StatsDateFormatter --- .../sections/subscribers/usecases/SubscribersMapper.kt | 9 +-------- .../ui/stats/refresh/utils/StatsDateFormatter.kt | 10 +++++++++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt index cc142495a47c..3ea5e28c9545 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt @@ -2,13 +2,11 @@ package org.wordpress.android.ui.stats.refresh.lists.sections.subscribers.usecas import org.wordpress.android.R import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel.PeriodData -import org.wordpress.android.fluxc.network.utils.StatsGranularity import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem.Line import org.wordpress.android.ui.stats.refresh.utils.StatsDateFormatter import org.wordpress.android.ui.stats.refresh.utils.StatsUtils -import org.wordpress.android.util.extensions.enforceWesternArabicNumerals import javax.inject.Inject class SubscribersMapper @Inject constructor( @@ -22,12 +20,7 @@ class SubscribersMapper @Inject constructor( selectedItemPeriod: String ): BlockListItem { val chartItems = dates.map { - val date = statsDateFormatter.parseStatsDate(StatsGranularity.DAYS, it.period) - Line( - statsDateFormatter.printDayWithoutYear(date).enforceWesternArabicNumerals() as String, - it.period, - it.subscribers.toInt() - ) + Line(statsDateFormatter.getStatsDateFromPeriodDay(it.period), it.period, it.subscribers.toInt()) } val contentDescriptions = statsUtils.getSubscribersChartEntryContentDescriptions( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsDateFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsDateFormatter.kt index 0c6122bb6b8e..81637114fee8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsDateFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsDateFormatter.kt @@ -10,6 +10,7 @@ import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS import org.wordpress.android.fluxc.utils.SiteUtils import org.wordpress.android.util.LocaleManagerWrapper import org.wordpress.android.util.config.StatsTrafficSubscribersTabFeatureConfig +import org.wordpress.android.util.extensions.enforceWesternArabicNumerals import org.wordpress.android.viewmodel.ResourceProvider import java.text.DateFormat import java.text.ParseException @@ -314,6 +315,13 @@ class StatsDateFormatter timeZoneResource, utcTime ) - } else null + } else { + null + } + } + + fun getStatsDateFromPeriodDay(period: String): String { + val date = parseStatsDate(DAYS, period) + return printDayWithoutYear(date).enforceWesternArabicNumerals() as String } } From d30bd0485b45b3f0a70005f6be900ec69f1a2be9 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 15:04:33 +0300 Subject: [PATCH 10/24] Get line chart dimension from dimens.xml --- .../viewholders/LineChartViewHolder.kt | 20 +++++++++---------- .../viewholders/SubscribersChartViewHolder.kt | 10 +++++----- WordPress/src/main/res/values/dimens.xml | 2 ++ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/LineChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/LineChartViewHolder.kt index 18991a875950..6ab41972bf9a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/LineChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/LineChartViewHolder.kt @@ -183,16 +183,14 @@ class LineChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( setDrawGridLines(false) if (chart.contentRect.width() > 0) { - axisLineWidth = 4.0F + axisLineWidth = chart.resources.getDimensionPixelSize(R.dimen.stats_line_chart_tick_height) / + chart.resources.displayMetrics.density val count = max(thisWeekData.count(), 7) - val tickWidth = 4.0F + val tickWidth = chart.resources.getDimension(R.dimen.stats_line_chart_tick_width) val contentWidthMinusTicks = chart.contentRect.width() - (tickWidth * count.toFloat()) setAxisLineDashedLine( - DashPathEffect( - floatArrayOf(tickWidth, (contentWidthMinusTicks / (count - 1).toFloat())), - 0f - ) + DashPathEffect(floatArrayOf(tickWidth, (contentWidthMinusTicks / (count - 1).toFloat())), 0f) ) } @@ -289,10 +287,12 @@ class LineChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( } private fun takeEntriesWithinGraphWidth(entries: List): List { - return if (8 < entries.size) entries.subList( - entries.size - 8, - entries.size - ) else { + return if (8 < entries.size) { + entries.subList( + entries.size - 8, + entries.size + ) + } else { entries } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt index 378c588a51b6..d2068d636a4f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -162,11 +162,11 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( setDrawGridLines(false) if (chart.contentRect.width() > 0) { - axisLineWidth = 4.0F - - val tickWidth = 4.0F - val contentWidthMinusTicks = chart.contentRect.width() - (tickWidth * 3f) - enableAxisLineDashedLine(tickWidth, contentWidthMinusTicks / 29f, 0f) + axisLineWidth = chart.resources.getDimensionPixelSize(R.dimen.stats_line_chart_tick_height) / + chart.resources.displayMetrics.density + val tickWidth = chart.resources.getDimension(R.dimen.stats_line_chart_tick_width) + val contentWidthMinusTicks = chart.contentRect.width() - tickWidth * 30 + enableAxisLineDashedLine(tickWidth, contentWidthMinusTicks / 29, 0f) } setDrawLabels(true) diff --git a/WordPress/src/main/res/values/dimens.xml b/WordPress/src/main/res/values/dimens.xml index fc0ffbbda68e..7f2071eeed0d 100644 --- a/WordPress/src/main/res/values/dimens.xml +++ b/WordPress/src/main/res/values/dimens.xml @@ -551,6 +551,8 @@ 32dp + 2dp + 4dp 180dp 16dp 24dp From 7ac6e9ec74613d2a827727d0599775ac3cf06598 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 15:05:25 +0300 Subject: [PATCH 11/24] Add stats_subscribers_chart_marker layout --- .../layout/stats_subscribers_chart_marker.xml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 WordPress/src/main/res/layout/stats_subscribers_chart_marker.xml diff --git a/WordPress/src/main/res/layout/stats_subscribers_chart_marker.xml b/WordPress/src/main/res/layout/stats_subscribers_chart_marker.xml new file mode 100644 index 000000000000..570603a21492 --- /dev/null +++ b/WordPress/src/main/res/layout/stats_subscribers_chart_marker.xml @@ -0,0 +1,37 @@ + + + + + + + + + From f1a1ea42b606d0a47646f6da3e76e73af0deb969 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 15:07:13 +0300 Subject: [PATCH 12/24] Add SubscribersChartMarkerView --- .../refresh/SubscribersChartMarkerView.kt | 239 ++++++++++++++++++ .../viewholders/SubscribersChartViewHolder.kt | 4 +- 2 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/SubscribersChartMarkerView.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/SubscribersChartMarkerView.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/SubscribersChartMarkerView.kt new file mode 100644 index 000000000000..e884490654d5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/SubscribersChartMarkerView.kt @@ -0,0 +1,239 @@ +package org.wordpress.android.ui.stats.refresh + +import android.content.Context +import android.graphics.BlurMaskFilter +import android.graphics.BlurMaskFilter.Blur.NORMAL +import android.graphics.Canvas +import android.graphics.CornerPathEffect +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Path.Direction.CW +import android.graphics.RectF +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.MarkerView +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.highlight.Highlight +import com.github.mikephil.charting.utils.MPPointF +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.ui.stats.refresh.utils.StatsDateFormatter +import org.wordpress.android.ui.stats.refresh.utils.StatsUtils +import javax.inject.Inject + +@AndroidEntryPoint +class SubscribersChartMarkerView @Inject constructor( + context: Context +) : MarkerView(context, R.layout.stats_subscribers_chart_marker) { + @Inject + lateinit var statsUtils: StatsUtils + + @Inject + lateinit var statsDateFormatter: StatsDateFormatter + private val countView = findViewById(R.id.marker_text1) + private val dateView = findViewById(R.id.marker_text2) + + override fun refreshContent(e: Entry?, highlight: Highlight?) { + val lineChart = chartView as? LineChart ?: return + val xValue = e?.x?.toInt() ?: return + + val dataSet = lineChart.lineData.dataSets.first() as LineDataSet + // get the corresponding Y axis value according to the current X axis position + val index = if (xValue < dataSet.values.size) xValue else 0 + val yValue = dataSet.values[index].y + + val count = yValue.toLong() + countView.text = count.toString() + val date = statsDateFormatter.getStatsDateFromPeriodDay(e.data.toString()) + dateView.text = date + + super.refreshContent(e, highlight) + } + + override fun getOffsetForDrawingAtPoint(posX: Float, posY: Float): MPPointF { + // posY posX refers to the position of the upper left corner of the markerView on the chart + val width = width.toFloat() + val height = height.toFloat() + + // If the y coordinate of the point is less than the height of the markerView, + // if it is not processed, it will exceed the upper boundary. After processing, + // the arrow is up at this time, and we need to move the icon down by the size of the arrow + if (posY <= height + ARROW_SIZE) { + offset.y = ARROW_SIZE + } else { + // Otherwise, it is normal, because our default is that the arrow is facing downwards, + // and then the normal offset is that you need to offset the height of the markerView and the arrow size, + // plus a stroke width, because you need to see the upper border of the dialog box + offset.y = -height - ARROW_SIZE - STROKE_WIDTH + } + + // handle X direction, left, middle, and right side of the chart + if (posX > chartView.width - width) { // If it exceeds the right boundary, offset the view width to the left + offset.x = -width + } else { // by default, no offset (because the point is in the upper left corner) + offset.x = 0F + // If it is greater than half of the markerView, the arrow is in the middle, + // so it is offset by half the width to the right + if (posX > width / 2) { + offset.x = -width / 2 + } + } + + return offset + } + + override fun draw(canvas: Canvas, posX: Float, posY: Float) { + super.draw(canvas, posX, posY) + + val saveId = canvas.save() + + drawToolTip(canvas, posX, posY) + draw(canvas) + + canvas.restoreToCount(saveId) + } + + @Suppress("LongMethod") + private fun drawToolTip(canvas: Canvas?, posX: Float, posY: Float) { + val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + strokeWidth = STROKE_WIDTH + style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + pathEffect = CornerPathEffect(CORNER_RADIUS) + color = context.getColor(R.color.blue_100) + } + + val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + strokeCap = Paint.Cap.ROUND + pathEffect = CornerPathEffect(CORNER_RADIUS) + color = context.getColor(R.color.blue_100) + } + + val chart = chartView + val width = width.toFloat() + val height = height.toFloat() + + val offset = getOffsetForDrawingAtPoint(posX, posY) + + val path = Path() + + if (posY < height + ARROW_SIZE) { // Processing exceeds the upper boundary + path.moveTo(0f, 0f) + if (posX > chart.width - width) { // Exceed the right boundary + path.lineTo(width - ARROW_SIZE, 0f) + path.lineTo(width, -ARROW_SIZE + CIRCLE_OFFSET) + path.lineTo(width, 0f) + } else { + if (posX > width / 2) { // In the middle of the chart + path.lineTo(width / 2 - ARROW_SIZE / 2, 0f) + path.lineTo(width / 2, -ARROW_SIZE + CIRCLE_OFFSET) + path.lineTo(width / 2 + ARROW_SIZE / 2, 0f) + } else { // Exceed the left margin + path.lineTo(0f, -ARROW_SIZE + CIRCLE_OFFSET) + path.lineTo(0 + ARROW_SIZE, 0f) + } + } + path.lineTo(0 + width, 0f) + path.lineTo(0 + width, 0 + height) + path.lineTo(0f, 0 + height) + path.lineTo(0f, 0f) + path.offset(posX + offset.x, posY + offset.y) + } else { // Does not exceed the upper boundary + path.moveTo(0f, 0f) + path.lineTo(0 + width, 0f) + path.lineTo(0 + width, 0 + height) + if (posX > chart.width - width) { + path.lineTo(width, height + ARROW_SIZE - CIRCLE_OFFSET) + path.lineTo(width - ARROW_SIZE, 0 + height) + path.lineTo(0f, 0 + height) + } else { + if (posX > width / 2) { + path.lineTo(width / 2 + ARROW_SIZE / 2, 0 + height) + path.lineTo(width / 2, height + ARROW_SIZE - CIRCLE_OFFSET) + path.lineTo(width / 2 - ARROW_SIZE / 2, 0 + height) + path.lineTo(0f, 0 + height) + } else { + path.lineTo(0 + ARROW_SIZE, 0 + height) + path.lineTo(0f, height + ARROW_SIZE - CIRCLE_OFFSET) + path.lineTo(0f, 0 + height) + } + } + path.lineTo(0f, 0f) + path.offset(posX + offset.x, posY + offset.y) + } + path.close() + + // translate to the correct position and draw + canvas?.apply { + drawPath(path, bgPaint) + drawPath(path, borderPaint) + drawDataPoint(canvas, posX, posY) + translate(posX + offset.x, posY + offset.y) + } + } + + private fun drawDataPoint(canvas: Canvas?, posX: Float, posY: Float) { + val circleShadowPaint = Paint().apply { + style = Paint.Style.FILL + color = ContextCompat.getColor(context, R.color.gray_10) + maskFilter = BlurMaskFilter(MASK_FILTER_RADIUS, NORMAL) + } + + val circleBorderPaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = CIRCLE_STROKE_WIDTH + isAntiAlias = true + isDither = true + color = ContextCompat.getColor(context, R.color.blue_0) + } + + val circleFillPaint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + isDither = true + color = ContextCompat.getColor(context, R.color.blue_50) + } + + val circleShadowPath = Path().apply { + addCircle(posX, posY, CIRCLE_SHADOW_RADIUS, CW) + } + + val circleFillPath = Path().apply { + addCircle(posX, posY, CIRCLE_RADIUS, CW) + } + + val circleBorderPath = Path().apply { + addCircle(posX, posY, CIRCLE_RADIUS, CW) + fillType = Path.FillType.EVEN_ODD + } + + val innerCircle = RectF().apply { + inset(CIRCLE_STROKE_WIDTH, CIRCLE_STROKE_WIDTH) + } + if (innerCircle.width() > 0 && innerCircle.height() > 0) { + circleBorderPath.addCircle(posX, posY, CIRCLE_RADIUS, CW) + } + + canvas?.apply { + drawPath(circleShadowPath, circleShadowPaint) + drawPath(circleFillPath, circleFillPaint) + drawPath(circleBorderPath, circleBorderPaint) + } + } + + companion object { + const val CORNER_RADIUS = 10F + const val ARROW_SIZE = 40F + const val STROKE_WIDTH = 5F + const val CIRCLE_OFFSET = 14F + + const val CIRCLE_RADIUS = 12F + const val CIRCLE_SHADOW_RADIUS = CIRCLE_RADIUS + 2F + const val CIRCLE_STROKE_WIDTH = 4F + const val MASK_FILTER_RADIUS = 5F + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt index d2068d636a4f..68b2daf1fa2d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.wordpress.android.R -import org.wordpress.android.ui.stats.refresh.LineChartMarkerView +import org.wordpress.android.ui.stats.refresh.SubscribersChartMarkerView import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem.Line import org.wordpress.android.ui.stats.refresh.utils.LargeValueFormatter @@ -182,7 +182,7 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( private fun drawChartMarker(h: Highlight) { if (chart.marker == null) { - val markerView = LineChartMarkerView(chart.context, 0) + val markerView = SubscribersChartMarkerView(chart.context) markerView.chartView = chart chart.marker = markerView } From beb154c9be7c0b37fea862eb68537baf0803cedd Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 15:08:34 +0300 Subject: [PATCH 13/24] Update subscribers chart line mode --- .../lists/sections/viewholders/SubscribersChartViewHolder.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt index 68b2daf1fa2d..ea7503343146 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -11,7 +11,7 @@ import com.github.mikephil.charting.components.YAxis.AxisDependency.LEFT import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineData import com.github.mikephil.charting.data.LineDataSet -import com.github.mikephil.charting.data.LineDataSet.Mode.CUBIC_BEZIER +import com.github.mikephil.charting.data.LineDataSet.Mode.HORIZONTAL_BEZIER import com.github.mikephil.charting.highlight.Highlight import com.github.mikephil.charting.interfaces.datasets.ILineDataSet import com.github.mikephil.charting.listener.OnChartValueSelectedListener @@ -206,8 +206,7 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( enableDashedHighlightLine(4.4F, 1F, 0F) isHighlightEnabled = true - mode = CUBIC_BEZIER - cubicIntensity = 0.2f + mode = HORIZONTAL_BEZIER color = ContextCompat.getColor(chart.context, R.color.blue_50) setDrawFilled(true) From 5e62bc773462b17cb70a80d476466d0bac39d0db Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 15:09:09 +0300 Subject: [PATCH 14/24] Update the min y value of subscribers chart --- .../sections/viewholders/SubscribersChartViewHolder.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt index ea7503343146..4b6ec89110ad 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -131,7 +131,7 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( } private fun configureYAxis(item: SubscribersChartItem) { - val minYValue = 6f + val minYValue = item.entries.minByOrNull { it.value }?.value ?: 0 val maxYValue = item.entries.maxByOrNull { it.value }?.value ?: 7 chart.axisLeft.apply { @@ -141,12 +141,8 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( setDrawZeroLine(true) setDrawAxisLine(false) granularity = 1F - axisMinimum = 0F - axisMaximum = if (maxYValue < minYValue) { - minYValue - } else { - roundUp(maxYValue.toFloat()) - } + axisMinimum = minYValue.toFloat() + axisMaximum = maxYValue.toFloat() setLabelCount(5, true) textColor = ContextCompat.getColor(chart.context, R.color.neutral_30) gridColor = ContextCompat.getColor(chart.context, R.color.stats_bar_chart_gridline) From 2958e1aaf3e369ca63c3a56ce663e7847c0b1928 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 15:10:21 +0300 Subject: [PATCH 15/24] Draw subscribers chart line even the values are zero --- .../sections/viewholders/SubscribersChartViewHolder.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt index 4b6ec89110ad..c813e9bfd699 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -43,7 +43,7 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( coroutineScope.launch { delay(50) chart.draw(item) - if (hasData(item.entries)) { + if (item.entries.isNotEmpty()) { chart.post { val accessibilityEvent = object : LineChartAccessibilityEvent { override fun onHighlight(entry: Entry, index: Int) { @@ -82,19 +82,17 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( } private fun getData(item: SubscribersChartItem): List { - val data = if (hasData(item.entries)) { + val data = if (item.entries.isEmpty()) { + buildEmptyDataSet(item.entries.size) + } else { val mappedEntries = item.entries.mapIndexed { index, pair -> toLineEntry(pair, index) } LineDataSet(mappedEntries, null) - } else { - buildEmptyDataSet(item.entries.size) } item.onLineChartDrawn?.invoke(data.entryCount) return listOf(data) } - private fun hasData(entries: List) = entries.isNotEmpty() && entries.any { it.value > 0 } - private fun configureChartView(item: SubscribersChartItem) { chart.apply { setPinchZoom(false) From 9db00cd8f9772225a405c62dd5d79c2b9bc44c13 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 15:50:02 +0300 Subject: [PATCH 16/24] Remove unnecessary codes from SubscribersUseCase --- .../refresh/lists/sections/BlockListItem.kt | 3 +- .../subscribers/usecases/SubscribersMapper.kt | 11 +-- .../usecases/SubscribersUseCase.kt | 67 +++---------------- .../viewholders/SubscribersChartViewHolder.kt | 34 ++-------- 4 files changed, 16 insertions(+), 99 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListItem.kt index 860f91512305..0d9852e5965b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListItem.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/BlockListItem.kt @@ -305,8 +305,7 @@ sealed class BlockListItem(val type: Type) { data class SubscribersChartItem( val entries: List, val selectedItemPeriod: String? = null, - val onLineSelected: ((period: String?) -> Unit)? = null, - val onLineChartDrawn: ((visibleLineCount: Int) -> Unit)? = null, + val onLineSelected: (() -> Unit)? = null, val entryContentDescriptions: List ) : BlockListItem(SUBSCRIBERS_CHART) { data class Line(val label: String, val id: String, val value: Int) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt index 3ea5e28c9545..74eafeaad25d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt @@ -13,13 +13,8 @@ class SubscribersMapper @Inject constructor( private val statsDateFormatter: StatsDateFormatter, private val statsUtils: StatsUtils ) { - fun buildChart( - dates: List, - onLineSelected: (String?) -> Unit, - onLineChartDrawn: (visibleBarCount: Int) -> Unit, - selectedItemPeriod: String - ): BlockListItem { - val chartItems = dates.map { + fun buildChart(dates: List, onLineSelected: () -> Unit): BlockListItem { + val chartItems = dates.reversed().map { Line(statsDateFormatter.getStatsDateFromPeriodDay(it.period), it.period, it.subscribers.toInt()) } @@ -30,9 +25,7 @@ class SubscribersMapper @Inject constructor( return SubscribersChartItem( entries = chartItems, - selectedItemPeriod = selectedItemPeriod, onLineSelected = onLineSelected, - onLineChartDrawn = onLineChartDrawn, entryContentDescriptions = contentDescriptions ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt index 112d56f0d7fb..09ec5eb3d3d2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt @@ -10,13 +10,10 @@ import org.wordpress.android.fluxc.store.StatsStore.SubscriberType.SUBSCRIBERS import org.wordpress.android.fluxc.store.stats.subscribers.SubscribersStore import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.modules.UI_THREAD -import org.wordpress.android.ui.stats.refresh.lists.sections.BaseStatsUseCase +import org.wordpress.android.ui.stats.refresh.lists.sections.BaseStatsUseCase.StatelessUseCase import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Title -import org.wordpress.android.ui.stats.refresh.lists.sections.granular.SelectedDateProvider import org.wordpress.android.ui.stats.refresh.lists.sections.insights.InsightUseCaseFactory -import org.wordpress.android.ui.stats.refresh.lists.sections.subscribers.usecases.SubscribersUseCase.UiState -import org.wordpress.android.ui.stats.refresh.utils.StatsDateFormatter import org.wordpress.android.ui.stats.refresh.utils.StatsSiteProvider import org.wordpress.android.ui.stats.refresh.utils.trackGranular import org.wordpress.android.util.AppLog @@ -29,20 +26,12 @@ const val SUBSCRIBERS_ITEMS_TO_LOAD = 30 class SubscribersUseCase @Inject constructor( private val subscribersStore: SubscribersStore, - private val selectedDateProvider: SelectedDateProvider, private val statsSiteProvider: StatsSiteProvider, - private val statsDateFormatter: StatsDateFormatter, private val subscribersMapper: SubscribersMapper, @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, @Named(BG_THREAD) private val backgroundDispatcher: CoroutineDispatcher, private val analyticsTracker: AnalyticsTrackerWrapper -) : BaseStatsUseCase( - SUBSCRIBERS, - mainDispatcher, - backgroundDispatcher, - UiState(), - uiUpdateParams = listOf(UseCaseParam.SelectedDateParam(DAYS)) -) { +) : StatelessUseCase(SUBSCRIBERS, mainDispatcher, backgroundDispatcher) { override fun buildLoadingItem(): List = listOf() override suspend fun loadCachedData() = subscribersStore.getSubscribers( @@ -68,64 +57,28 @@ class SubscribersUseCase @Inject constructor( } } - override fun buildUiModel(domainModel: SubscribersModel, uiState: UiState): List { + override fun buildUiModel(domainModel: SubscribersModel): List { val items = mutableListOf() - if (domainModel.dates.isNotEmpty()) { + if (domainModel.dates.isEmpty()) { + AppLog.e(T.STATS, "There is no data to be shown in the subscribers block") + } else { items.add(buildTitle()) - val dateFromProvider = selectedDateProvider.getSelectedDate(DAYS) - val visibleLineCount = uiState.visibleLineCount ?: domainModel.dates.size - val availableDates = domainModel.dates.map { statsDateFormatter.parseStatsDate(DAYS, it.period) } - val selectedDate = dateFromProvider ?: availableDates.last() - val index = availableDates.indexOf(selectedDate) - - selectedDateProvider.selectDate(selectedDate, availableDates.takeLast(visibleLineCount), DAYS) - - val selectedItem = domainModel.dates.getOrNull(index) ?: domainModel.dates.last() - - items.add( - subscribersMapper.buildChart( - domainModel.dates.reversed(), - this::onLineSelected, - this::onLineChartDrawn, - selectedItem.period - ) - ) - } else { - selectedDateProvider.onDateLoadingFailed(DAYS) - AppLog.e(T.STATS, "There is no data to be shown in the subscribers block") + items.add(subscribersMapper.buildChart(domainModel.dates, this::onLineSelected)) } return items } private fun buildTitle() = Title(R.string.stats_subscribers_subscribers) - private fun onLineSelected(period: String?) { - analyticsTracker.trackGranular( - AnalyticsTracker.Stat.STATS_VIEWS_AND_VISITORS_LINE_CHART_TAPPED, - DAYS - ) - if (period != null && period != "empty") { - val selectedDate = statsDateFormatter.parseStatsDate(DAYS, period) - selectedDateProvider.selectDate( - selectedDate, - DAYS - ) - } + private fun onLineSelected() { + analyticsTracker.trackGranular(AnalyticsTracker.Stat.STATS_VIEWS_AND_VISITORS_LINE_CHART_TAPPED, DAYS) } - private fun onLineChartDrawn(visibleLineCount: Int) { - updateUiState { it.copy(visibleLineCount = visibleLineCount) } - } - - data class UiState(val selectedPosition: Int = 0, val visibleLineCount: Int? = null) - class SubscribersUseCaseFactory @Inject constructor( @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, @Named(BG_THREAD) private val backgroundDispatcher: CoroutineDispatcher, private val statsSiteProvider: StatsSiteProvider, - private val selectedDateProvider: SelectedDateProvider, - private val statsDateFormatter: StatsDateFormatter, private val subscribersMapper: SubscribersMapper, private val subscribersStore: SubscribersStore, private val analyticsTracker: AnalyticsTrackerWrapper @@ -133,9 +86,7 @@ class SubscribersUseCase @Inject constructor( override fun build(useCaseMode: UseCaseMode) = SubscribersUseCase( subscribersStore, - selectedDateProvider, statsSiteProvider, - statsDateFormatter, subscribersMapper, mainDispatcher, backgroundDispatcher, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt index c813e9bfd699..0780c8c75684 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -49,16 +49,13 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( override fun onHighlight(entry: Entry, index: Int) { drawChartMarker(Highlight(entry.x, entry.y, 0)) val value = entry.data as? String - value?.let { - item.onLineSelected?.invoke(it) - } + value?.let { item.onLineSelected?.invoke() } } } - val cutContentDescriptions = takeEntriesWithinGraphWidth(item.entryContentDescriptions) accessibilityHelper = LineChartAccessibilityHelper( chart, - contentDescriptions = cutContentDescriptions, + contentDescriptions = item.entryContentDescriptions, accessibilityEvent = accessibilityEvent ) @@ -88,7 +85,6 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( val mappedEntries = item.entries.mapIndexed { index, pair -> toLineEntry(pair, index) } LineDataSet(mappedEntries, null) } - item.onLineChartDrawn?.invoke(data.entryCount) return listOf(data) } @@ -112,13 +108,11 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( val isClickable = item.onLineSelected != null if (isClickable) { setOnChartValueSelectedListener(object : OnChartValueSelectedListener { - override fun onNothingSelected() { - item.onLineSelected?.invoke(item.selectedItemPeriod) - } + override fun onNothingSelected() = Unit override fun onValueSelected(e: Entry, h: Highlight) { drawChartMarker(h) - item.onLineSelected?.invoke(e.data as? String) + item.onLineSelected?.invoke() } }) } else { @@ -224,12 +218,6 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( return dataSet } - private fun takeEntriesWithinGraphWidth(entries: List) = if (8 < entries.size) { - entries.subList(entries.size - 8, entries.size) - } else { - entries - } - private fun LineChart.resetChart() { fitScreen() data?.clearValues() @@ -240,18 +228,4 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( } private fun toLineEntry(line: Line, index: Int) = Entry(index.toFloat(), line.value.toFloat(), line.id) - - private fun roundUp(input: Float): Float { - return if (input > 100) { - roundUp(input / 10) * 10 - } else { - for (i in 1..25) { - val limit = 4 * i - if (input < limit) { - return limit.toFloat() - } - } - 100F - } - } } From 55aac02a7cda512e6d2ea3ef38a5587063937777 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 16:10:33 +0300 Subject: [PATCH 17/24] Add SubscribersChartUseCaseTest --- .../usecases/SubscribersChartUseCaseTest.kt | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/SubscribersChartUseCaseTest.kt diff --git a/WordPress/src/test/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/SubscribersChartUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/SubscribersChartUseCaseTest.kt new file mode 100644 index 000000000000..12c7d0f0198e --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/stats/refresh/lists/sections/insights/usecases/SubscribersChartUseCaseTest.kt @@ -0,0 +1,109 @@ +package org.wordpress.android.ui.stats.refresh.lists.sections.insights.usecases + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel.PeriodData +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.stats.subscribers.SubscribersStore +import org.wordpress.android.ui.stats.refresh.lists.sections.BaseStatsUseCase.UseCaseModel +import org.wordpress.android.ui.stats.refresh.lists.sections.BaseStatsUseCase.UseCaseModel.UseCaseState.ERROR +import org.wordpress.android.ui.stats.refresh.lists.sections.BaseStatsUseCase.UseCaseModel.UseCaseState.SUCCESS +import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.SubscribersChartItem +import org.wordpress.android.ui.stats.refresh.lists.sections.subscribers.usecases.SubscribersMapper +import org.wordpress.android.ui.stats.refresh.lists.sections.subscribers.usecases.SubscribersUseCase +import org.wordpress.android.ui.stats.refresh.utils.StatsDateFormatter +import org.wordpress.android.ui.stats.refresh.utils.StatsSiteProvider +import org.wordpress.android.util.LocaleManagerWrapper +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper + +@ExperimentalCoroutinesApi +class SubscribersChartUseCaseTest : BaseUnitTest() { + @Mock + lateinit var store: SubscribersStore + + @Mock + lateinit var statsDateFormatter: StatsDateFormatter + + @Mock + lateinit var subscribersMapper: SubscribersMapper + + @Mock + lateinit var statsSiteProvider: StatsSiteProvider + + @Mock + lateinit var subscribersChartItemChartItem: SubscribersChartItem + + @Mock + lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper + + @Mock + lateinit var localeManagerWrapper: LocaleManagerWrapper + + private lateinit var useCase: SubscribersUseCase + private val site = SiteModel() + private val siteId = 1L + private val periodData = PeriodData("2024-04-24", 10) + private val modelPeriod = "2024-04-24" + private val limitMode = Top(30) + private val statsGranularity = DAYS + private val model = SubscribersModel(modelPeriod, listOf(periodData)) + + @Before + fun setUp() { + useCase = SubscribersUseCase( + store, + statsSiteProvider, + subscribersMapper, + testDispatcher(), + testDispatcher(), + analyticsTrackerWrapper + ) + site.siteId = siteId + whenever(statsSiteProvider.siteModel).thenReturn(site) + whenever(subscribersMapper.buildChart(any(), any())).thenReturn(subscribersChartItemChartItem) + } + + @Test + fun `maps domain model to UI model`() = test { + val forced = false + whenever(store.getSubscribers(site, statsGranularity, limitMode)).thenReturn(model) + whenever(store.fetchSubscribers(site, statsGranularity, limitMode, forced)).thenReturn(OnStatsFetched(model)) + + val result = loadData(true, forced) + + Assertions.assertThat(result.state).isEqualTo(SUCCESS) + result.data!!.apply { Assertions.assertThat(this[1]).isEqualTo(subscribersChartItemChartItem) } + } + + @Test + fun `maps error item to UI model`() = test { + val forced = false + val message = "Generic error" + whenever(store.fetchSubscribers(site, statsGranularity, limitMode, forced)) + .thenReturn(OnStatsFetched(StatsError(GENERIC_ERROR, message))) + + val result = loadData(true, forced) + + Assertions.assertThat(result.state).isEqualTo(ERROR) + } + + private suspend fun loadData(refresh: Boolean, forced: Boolean): UseCaseModel { + var result: UseCaseModel? = null + useCase.liveData.observeForever { result = it } + useCase.fetch(refresh, forced) + advanceUntilIdle() + return checkNotNull(result) + } +} From 79b98155bdade6a6bd339ba135b3c9c3034ee624 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 16:11:46 +0300 Subject: [PATCH 18/24] Update FluxC reference to the PR --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 993088b6ca1f..dc7101f9fd6a 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ ext { automatticTracksVersion = '4.0.2' gutenbergMobileVersion = 'v1.117.0' wordPressAztecVersion = 'v2.1.1' - wordPressFluxCVersion = '2993-f0292783f4b0a98ae1251eaae0a202fba71accd3' + wordPressFluxCVersion = '2993-c1c0967223270a700fda3c7db7323a6ceeb9652d' wordPressLoginVersion = '1.15.0' wordPressPersistentEditTextVersion = '1.0.2' wordPressUtilsVersion = '3.14.0' From 39d117cb6ba08c315775d95f2520986c8c0f3b68 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 16:35:25 +0300 Subject: [PATCH 19/24] Track stats_subscribers_chart_tapped event --- .../lists/sections/subscribers/usecases/SubscribersUseCase.kt | 3 +-- .../java/org/wordpress/android/analytics/AnalyticsTracker.java | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt index 09ec5eb3d3d2..859709ee17e8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt @@ -15,7 +15,6 @@ import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem import org.wordpress.android.ui.stats.refresh.lists.sections.BlockListItem.Title import org.wordpress.android.ui.stats.refresh.lists.sections.insights.InsightUseCaseFactory import org.wordpress.android.ui.stats.refresh.utils.StatsSiteProvider -import org.wordpress.android.ui.stats.refresh.utils.trackGranular import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper @@ -72,7 +71,7 @@ class SubscribersUseCase @Inject constructor( private fun buildTitle() = Title(R.string.stats_subscribers_subscribers) private fun onLineSelected() { - analyticsTracker.trackGranular(AnalyticsTracker.Stat.STATS_VIEWS_AND_VISITORS_LINE_CHART_TAPPED, DAYS) + analyticsTracker.track(AnalyticsTracker.Stat.STATS_SUBSCRIBERS_CHART_TAPPED) } class SubscribersUseCaseFactory @Inject constructor( diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java index f9b368cc757d..ba0555b62d66 100644 --- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java +++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java @@ -125,6 +125,7 @@ public enum Stat { STATS_TAGS_AND_CATEGORIES_VIEW_MORE_TAPPED, STATS_VIEWS_AND_VISITORS_ERROR, STATS_VIEWS_AND_VISITORS_LINE_CHART_TAPPED, + STATS_SUBSCRIBERS_CHART_TAPPED, STATS_INSIGHTS_VIEWS_VISITORS_TOGGLED, STATS_PUBLICIZE_VIEW_MORE_TAPPED, STATS_POSTS_AND_PAGES_VIEW_MORE_TAPPED, From f6f925c0c4e7ca61462aec0c827aab9c4fde21c4 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 18:28:31 +0300 Subject: [PATCH 20/24] Add mocks/.../stats_subscribers.json --- .../wpcom/stats/stats_subscribers.json | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 libs/mocks/src/main/assets/mocks/mappings/wpcom/stats/stats_subscribers.json diff --git a/libs/mocks/src/main/assets/mocks/mappings/wpcom/stats/stats_subscribers.json b/libs/mocks/src/main/assets/mocks/mappings/wpcom/stats/stats_subscribers.json new file mode 100644 index 000000000000..b5e7642b6502 --- /dev/null +++ b/libs/mocks/src/main/assets/mocks/mappings/wpcom/stats/stats_subscribers.json @@ -0,0 +1,259 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/subscribers/.*" + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2024-04-24", + "unit": "day", + "fields": [ + "period", + "subscribers" + ], + "data": [ + [ + "2024-04-24", + 77, + 0, + [ + "2024-04-24" + ] + ], + [ + "2024-04-23", + 77, + 0, + [ + "2024-04-23" + ] + ], + [ + "2024-04-22", + 77, + 0, + [ + "2024-04-22" + ] + ], + [ + "2024-04-21", + 78, + 0, + [ + "2024-04-21" + ] + ], + [ + "2024-04-20", + 78, + 0, + [ + "2024-04-20" + ] + ], + [ + "2024-04-19", + 78, + 0, + [ + "2024-04-19" + ] + ], + [ + "2024-04-18", + 78, + 0, + [ + "2024-04-18" + ] + ], + [ + "2024-04-17", + 78, + 0, + [ + "2024-04-17" + ] + ], + [ + "2024-04-16", + 78, + 0, + [ + "2024-04-16" + ] + ], + [ + "2024-04-15", + 78, + 0, + [ + "2024-04-15" + ] + ], + [ + "2024-04-14", + 78, + 0, + [ + "2024-04-14" + ] + ], + [ + "2024-04-13", + 78, + 0, + [ + "2024-04-13" + ] + ], + [ + "2024-04-12", + 78, + 0, + [ + "2024-04-12" + ] + ], + [ + "2024-04-11", + 78, + 0, + [ + "2024-04-11" + ] + ], + [ + "2024-04-10", + 78, + 0, + [ + "2024-04-10" + ] + ], + [ + "2024-04-09", + 78, + 0, + [ + "2024-04-09" + ] + ], + [ + "2024-04-08", + 76, + 0, + [ + "2024-04-08" + ] + ], + [ + "2024-04-07", + 76, + 0, + [ + "2024-04-07" + ] + ], + [ + "2024-04-06", + 76, + 0, + [ + "2024-04-06" + ] + ], + [ + "2024-04-05", + 76, + 0, + [ + "2024-04-05" + ] + ], + [ + "2024-04-04", + 78, + 0, + [ + "2024-04-04" + ] + ], + [ + "2024-04-03", + 78, + 0, + [ + "2024-04-03" + ] + ], + [ + "2024-04-02", + 78, + 0, + [ + "2024-04-02" + ] + ], + [ + "2024-04-01", + 78, + 0, + [ + "2024-04-01" + ] + ], + [ + "2024-03-31", + 78, + 0, + [ + "2024-03-31" + ] + ], + [ + "2024-03-30", + 78, + 0, + [ + "2024-03-30" + ] + ], + [ + "2024-03-29", + 79, + 0, + [ + "2024-03-29" + ] + ], + [ + "2024-03-28", + 79, + 0, + [ + "2024-03-28" + ] + ], + [ + "2024-03-27", + 79, + 0, + [ + "2024-03-27" + ] + ], + [ + "2024-03-26", + 80, + 0, + [ + "2024-03-26" + ] + ] + ] + } + } +} From 2cc95ff90d930385b392371f13e871732d8553e8 Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Wed, 24 Apr 2024 20:45:10 +0300 Subject: [PATCH 21/24] Update tick count for x-axis on subscribers chart --- .../lists/sections/viewholders/SubscribersChartViewHolder.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt index 0780c8c75684..a03fe908e49e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -153,8 +153,9 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( axisLineWidth = chart.resources.getDimensionPixelSize(R.dimen.stats_line_chart_tick_height) / chart.resources.displayMetrics.density val tickWidth = chart.resources.getDimension(R.dimen.stats_line_chart_tick_width) - val contentWidthMinusTicks = chart.contentRect.width() - tickWidth * 30 - enableAxisLineDashedLine(tickWidth, contentWidthMinusTicks / 29, 0f) + val contentWidthMinusTicks = chart.contentRect.width() - tickWidth * 3 + enableAxisLineDashedLine(tickWidth, contentWidthMinusTicks / 2, 0f) + axisLineColor = ContextCompat.getColor(chart.context, R.color.stats_bar_chart_gridline) } setDrawLabels(true) From 60b9408e8b205e2969713c91c3eab81de27ca88c Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Thu, 25 Apr 2024 16:05:12 +0300 Subject: [PATCH 22/24] Update the values of the y-axis for Subscribers count --- .../sections/viewholders/SubscribersChartViewHolder.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt index a03fe908e49e..e13e7f87895b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -123,8 +123,13 @@ class SubscribersChartViewHolder(parent: ViewGroup) : BlockListItemViewHolder( } private fun configureYAxis(item: SubscribersChartItem) { - val minYValue = item.entries.minByOrNull { it.value }?.value ?: 0 - val maxYValue = item.entries.maxByOrNull { it.value }?.value ?: 7 + val hasChange = item.entries.map { it.value }.distinct().size > 1 + val minYValue = if (hasChange) (item.entries.minByOrNull { it.value }?.value ?: 0) else 0 + val maxYValue = if (hasChange) { + item.entries.maxByOrNull { it.value }?.value ?: 7 + } else { + item.entries.last().value * 2 + } chart.axisLeft.apply { valueFormatter = LargeValueFormatter() From fd93f164acf52e6f3770e15eb9ac14b76e62a7db Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Thu, 25 Apr 2024 16:53:55 +0300 Subject: [PATCH 23/24] Refactor UiModelMapper functions --- .../ui/stats/refresh/lists/UiModelMapper.kt | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt index 7313118de49a..f9b1618b0f9a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt @@ -16,19 +16,11 @@ import javax.inject.Inject class UiModelMapper @Inject constructor(private val networkUtilsWrapper: NetworkUtilsWrapper) { - @Suppress("CyclomaticComplexMethod") - fun mapInsights( - useCaseModels: List, - showError: (Int) -> Unit - ): UiModel { + fun mapInsights(useCaseModels: List, showError: (Int) -> Unit): UiModel { val insightUseCaseModels = useCaseModels.filter { it.type is InsightType } if (insightUseCaseModels.isNotEmpty()) { - val allFailing = insightUseCaseModels.fold(true) { acc, useCaseModel -> - acc && useCaseModel.state == ERROR - } - val allFailingWithoutData = insightUseCaseModels.fold(true) { acc, useCaseModel -> - acc && useCaseModel.state == ERROR && useCaseModel.data == null - } + val allFailing = allFailing(insightUseCaseModels) + val allFailingWithoutData = allFailingWithoutData(insightUseCaseModels) return if (!allFailing && !allFailingWithoutData) { val data = useCaseModels.map { useCaseModel -> when (useCaseModel.state) { @@ -50,14 +42,7 @@ class UiModelMapper UiModel.Success(data) } else if (!allFailingWithoutData) { showError(getErrorMessage()) - UiModel.Success( - useCaseModels.map { useCaseModel -> - StatsBlock.Error( - useCaseModel.type, - useCaseModel.data ?: useCaseModel.stateData ?: listOf() - ) - } - ) + getUiModelForAllFailingWithoutData(useCaseModels) } else { UiModel.Error(getErrorMessage()) } @@ -78,14 +63,9 @@ class UiModelMapper return mapStatsWithOverview(TimeStatsType.OVERVIEW, useCaseModels, showError) } - @Suppress("CyclomaticComplexMethod") fun mapSubscribers(useCaseModels: List, showError: (Int) -> Unit): UiModel { - val allFailing = useCaseModels.isNotEmpty() && useCaseModels.fold(true) { acc, useCaseModel -> - acc && useCaseModel.state == ERROR - } - val allFailingWithoutData = useCaseModels.fold(true) { acc, useCaseModel -> - acc && useCaseModel.state == ERROR && useCaseModel.data == null - } + val allFailing = useCaseModels.isNotEmpty() && allFailing(useCaseModels) + val allFailingWithoutData = allFailingWithoutData(useCaseModels) return if (useCaseModels.isEmpty()) { UiModel.Empty(R.string.loading) } else if (!allFailing && !allFailingWithoutData) { @@ -108,13 +88,7 @@ class UiModelMapper UiModel.Success(data) } else if (!allFailingWithoutData) { showError(getErrorMessage()) - val data = useCaseModels.map { useCaseModel -> - StatsBlock.Error( - useCaseModel.type, - useCaseModel.data ?: useCaseModel.stateData ?: listOf() - ) - } - UiModel.Success(data) + getUiModelForAllFailingWithoutData(useCaseModels) } else { UiModel.Error(getErrorMessage()) } @@ -133,10 +107,7 @@ class UiModelMapper useCaseModels: List, showError: (Int) -> Unit ): UiModel { - val allFailing = useCaseModels.isNotEmpty() && useCaseModels - .fold(true) { acc, useCaseModel -> - acc && useCaseModel.state == ERROR - } + val allFailing = useCaseModels.isNotEmpty() && allFailing(useCaseModels) val overviewIsFailing = useCaseModels.any { it.type == overViewType && it.state == ERROR } val overviewHasData = useCaseModels.any { it.type == overViewType && it.data != null } return if (!allFailing && (overviewHasData || !overviewIsFailing)) { @@ -194,6 +165,24 @@ class UiModelMapper } } + private fun allFailing(useCaseModels: List) = useCaseModels.fold(true) { acc, useCaseModel -> + acc && useCaseModel.state == ERROR + } + + private fun allFailingWithoutData(useCaseModels: List) = + useCaseModels.fold(true) { acc, useCaseModel -> + acc && useCaseModel.state == ERROR && useCaseModel.data == null + } + + private fun getUiModelForAllFailingWithoutData(useCaseModels: List) = UiModel.Success( + useCaseModels.map { useCaseModel -> + StatsBlock.Error( + useCaseModel.type, + useCaseModel.data ?: useCaseModel.stateData ?: listOf() + ) + } + ) + private fun getErrorMessage(): Int { return if (networkUtilsWrapper.isNetworkAvailable()) { R.string.stats_loading_error From f0ddce1dec0bf543ef818723e85a5cfc52f36c5f Mon Sep 17 00:00:00 2001 From: Irfan Omur Date: Thu, 25 Apr 2024 18:27:39 +0300 Subject: [PATCH 24/24] Update FluxC reference to the PR --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f72ee310e2bc..ce785ceee4c5 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ ext { automatticTracksVersion = '4.0.2' gutenbergMobileVersion = 'v1.117.0' wordPressAztecVersion = 'v2.1.2' - wordPressFluxCVersion = '2993-c1c0967223270a700fda3c7db7323a6ceeb9652d' + wordPressFluxCVersion = '2993-d6cb0eded15c3dbbb924172ab3d1c5d8431f1be3' wordPressLoginVersion = '1.15.0' wordPressPersistentEditTextVersion = '1.0.2' wordPressUtilsVersion = '3.14.0'