Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Subscribers line chart #20706

Merged
merged 26 commits into from
Apr 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c99a7c6
Add SubscribersUseCase to StatsModule
irfano Apr 23, 2024
9561165
Add SubscribersUseCase
irfano Apr 23, 2024
c37de1a
Add SubscribersChartViewHolder
irfano Apr 23, 2024
6e38e5c
Update FluxC reference to the PR
irfano Apr 23, 2024
5cde872
Replace 'Enum.values()' by 'Enum.entries' in BlockListAdapter
irfano Apr 23, 2024
b0ce23d
Update Subscribers graph view
irfano Apr 23, 2024
892a3ad
Fix detekt and checkstyle warnings
irfano Apr 23, 2024
a530e60
Adjust stats_block_line_chart_item margins
irfano Apr 24, 2024
184a5e9
Add getStatsDateFromPeriodDay() function to StatsDateFormatter
irfano Apr 24, 2024
d30bd04
Get line chart dimension from dimens.xml
irfano Apr 24, 2024
7ac6e9e
Add stats_subscribers_chart_marker layout
irfano Apr 24, 2024
f1a1ea4
Add SubscribersChartMarkerView
irfano Apr 24, 2024
beb154c
Update subscribers chart line mode
irfano Apr 24, 2024
5e62bc7
Update the min y value of subscribers chart
irfano Apr 24, 2024
2958e1a
Draw subscribers chart line even the values are zero
irfano Apr 24, 2024
9db00cd
Remove unnecessary codes from SubscribersUseCase
irfano Apr 24, 2024
55aac02
Add SubscribersChartUseCaseTest
irfano Apr 24, 2024
79b9815
Update FluxC reference to the PR
irfano Apr 24, 2024
39d117c
Track stats_subscribers_chart_tapped event
irfano Apr 24, 2024
f6f925c
Add mocks/.../stats_subscribers.json
irfano Apr 24, 2024
2cc95ff
Update tick count for x-axis on subscribers chart
irfano Apr 24, 2024
60b9408
Update the values of the y-axis for Subscribers count
irfano Apr 25, 2024
fd93f16
Refactor UiModelMapper functions
irfano Apr 25, 2024
fcc7b3a
Merge branch 'trunk' into issue/20687-subscribers-line-chart
irfano Apr 25, 2024
3fb521c
Merge branch 'trunk' into issue/20687-subscribers-line-chart
irfano Apr 25, 2024
f0ddce1
Update FluxC reference to the PR
irfano Apr 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -71,6 +72,7 @@ class BlockDiffCallback(
BAR_CHART,
PIE_CHART,
LINE_CHART,
SUBSCRIBERS_CHART,
ACTIVITY_ITEM,
LIST_ITEM -> oldItem.itemId == newItem.itemId
LINK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -300,41 +313,28 @@ 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(
statsStore: StatsStore,
@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(
bgDispatcher,
mainDispatcher,
statsSiteProvider,
useCases,
{ statsStore.getInsightTypes(it) },
uiModelMapper::mapInsights
{ statsStore.getSubscriberTypes() },
uiModelMapper::mapSubscribers
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<TextView>(R.id.marker_text1)
private val dateView = findViewById<TextView>(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
}
}
Loading
Loading