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/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/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/UiModelMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/UiModelMapper.kt index 4eefe9cab16c..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,12 +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()) } @@ -76,6 +63,37 @@ class UiModelMapper return mapStatsWithOverview(TimeStatsType.OVERVIEW, useCaseModels, showError) } + fun mapSubscribers(useCaseModels: List, showError: (Int) -> Unit): UiModel { + val allFailing = useCaseModels.isNotEmpty() && allFailing(useCaseModels) + val allFailingWithoutData = allFailingWithoutData(useCaseModels) + 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()) + getUiModelForAllFailingWithoutData(useCaseModels) + } else { + UiModel.Error(getErrorMessage()) + } + } + fun mapDetailStats( useCaseModels: List, showError: (Int) -> Unit @@ -89,63 +107,82 @@ 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)) { 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()) } } + 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 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..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 @@ -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 @@ -74,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 @@ -107,6 +108,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 @@ -131,7 +133,7 @@ class BlockListAdapter(val imageManager: ImageManager) : Adapter TitleViewHolder(parent) TITLE_WITH_MORE -> TitleWithMoreViewHolder(parent) BIG_TITLE -> BigTitleViewHolder(parent) @@ -148,6 +150,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 +201,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..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 @@ -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,18 @@ sealed class BlockListItem(val type: Type) { get() = entries.hashCode() } + data class SubscribersChartItem( + val entries: List, + val selectedItemPeriod: String? = null, + val onLineSelected: (() -> 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/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..74eafeaad25d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersMapper.kt @@ -0,0 +1,32 @@ +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.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 javax.inject.Inject + +class SubscribersMapper @Inject constructor( + private val statsDateFormatter: StatsDateFormatter, + private val statsUtils: StatsUtils +) { + fun buildChart(dates: List, onLineSelected: () -> Unit): BlockListItem { + val chartItems = dates.reversed().map { + Line(statsDateFormatter.getStatsDateFromPeriodDay(it.period), it.period, it.subscribers.toInt()) + } + + val contentDescriptions = statsUtils.getSubscribersChartEntryContentDescriptions( + R.string.stats_subscribers_subscribers, + chartItems + ) + + return SubscribersChartItem( + entries = chartItems, + onLineSelected = onLineSelected, + 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 new file mode 100644 index 000000000000..859709ee17e8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/subscribers/usecases/SubscribersUseCase.kt @@ -0,0 +1,95 @@ +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.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.insights.InsightUseCaseFactory +import org.wordpress.android.ui.stats.refresh.utils.StatsSiteProvider +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 statsSiteProvider: StatsSiteProvider, + private val subscribersMapper: SubscribersMapper, + @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, + @Named(BG_THREAD) private val backgroundDispatcher: CoroutineDispatcher, + private val analyticsTracker: AnalyticsTrackerWrapper +) : StatelessUseCase(SUBSCRIBERS, mainDispatcher, backgroundDispatcher) { + 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): List { + val items = mutableListOf() + if (domainModel.dates.isEmpty()) { + AppLog.e(T.STATS, "There is no data to be shown in the subscribers block") + } else { + items.add(buildTitle()) + + items.add(subscribersMapper.buildChart(domainModel.dates, this::onLineSelected)) + } + return items + } + + private fun buildTitle() = Title(R.string.stats_subscribers_subscribers) + + private fun onLineSelected() { + analyticsTracker.track(AnalyticsTracker.Stat.STATS_SUBSCRIBERS_CHART_TAPPED) + } + + class SubscribersUseCaseFactory @Inject constructor( + @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, + @Named(BG_THREAD) private val backgroundDispatcher: CoroutineDispatcher, + private val statsSiteProvider: StatsSiteProvider, + private val subscribersMapper: SubscribersMapper, + private val subscribersStore: SubscribersStore, + private val analyticsTracker: AnalyticsTrackerWrapper + ) : InsightUseCaseFactory { + override fun build(useCaseMode: UseCaseMode) = + SubscribersUseCase( + subscribersStore, + statsSiteProvider, + subscribersMapper, + mainDispatcher, + backgroundDispatcher, + analyticsTracker + ) + } +} 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 new file mode 100644 index 000000000000..e13e7f87895b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/sections/viewholders/SubscribersChartViewHolder.kt @@ -0,0 +1,237 @@ +package org.wordpress.android.ui.stats.refresh.lists.sections.viewholders + +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.HORIZONTAL_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.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 +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 + +@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 (item.entries.isNotEmpty()) { + 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() } + } + } + + accessibilityHelper = LineChartAccessibilityHelper( + chart, + contentDescriptions = item.entryContentDescriptions, + 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 (item.entries.isEmpty()) { + buildEmptyDataSet(item.entries.size) + } else { + val mappedEntries = item.entries.mapIndexed { index, pair -> toLineEntry(pair, index) } + LineDataSet(mappedEntries, null) + } + + return listOf(data) + } + + 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() = Unit + + override fun onValueSelected(e: Entry, h: Highlight) { + drawChartMarker(h) + item.onLineSelected?.invoke() + } + }) + } else { + setOnChartValueSelectedListener(null) + } + isHighlightPerTapEnabled = isClickable + } + } + + private fun configureYAxis(item: SubscribersChartItem) { + 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() + setDrawGridLines(true) + setDrawTopYLabelEntry(true) + setDrawZeroLine(true) + setDrawAxisLine(false) + granularity = 1F + 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) + textSize = 10f + gridLineWidth = 1f + } + } + + private fun configureXAxis(item: SubscribersChartItem) { + chart.xAxis.apply { + granularity = 1f + setDrawAxisLine(true) + setDrawGridLines(false) + + if (chart.contentRect.width() > 0) { + 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 * 3 + enableAxisLineDashedLine(tickWidth, contentWidthMinusTicks / 2, 0f) + axisLineColor = ContextCompat.getColor(chart.context, R.color.stats_bar_chart_gridline) + } + + 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 = SubscribersChartMarkerView(chart.context) + markerView.chartView = chart + chart.marker = markerView + } + chart.highlightValue(h) + } + + private fun configureDataSets(dataSets: MutableList) { + (dataSets.first() as? LineDataSet)?.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 = HORIZONTAL_BEZIER + 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 } + } + } + + 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 LineChart.resetChart() { + fitScreen() + data?.clearValues() + xAxis.valueFormatter = null + notifyDataSetChanged() + clear() + invalidate() + } + + private fun toLineEntry(line: Line, index: Int) = Entry(index.toFloat(), line.value.toFloat(), line.id) +} 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 } } 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 { + "" + } + } +} 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"> + + + + + + + + diff --git a/WordPress/src/main/res/values/dimens.xml b/WordPress/src/main/res/values/dimens.xml index 7aa11d681d84..e659b3615736 100644 --- a/WordPress/src/main/res/values/dimens.xml +++ b/WordPress/src/main/res/values/dimens.xml @@ -553,6 +553,8 @@ 32dp + 2dp + 4dp 180dp 16dp 24dp diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 824222d8d997..b5fe0db56641 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1325,6 +1325,7 @@ Total Followers + Subscribers seconds ago a minute ago %1$d minutes 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) + } +} diff --git a/build.gradle b/build.gradle index e3dc3fdb2e13..948f6471f096 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ ext { automatticTracksVersion = '4.0.2' gutenbergMobileVersion = 'v1.118.0' wordPressAztecVersion = 'v2.1.2' - wordPressFluxCVersion = 'trunk-e6bb1ab9b9b73ee49ca380e8e3b665358922e01e' + wordPressFluxCVersion = 'trunk-c237f715b11e3005c841c43b94623d7ef92720a0' wordPressLoginVersion = '1.15.0' wordPressPersistentEditTextVersion = '1.0.2' wordPressUtilsVersion = '3.14.0' 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 3de3d5649497..9a9cd2bf51b3 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, 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" + ] + ] + ] + } + } +}