Simple reading stats display
parent
f39ccb6223
commit
35a2ac4b04
@ -1,397 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.widgets
|
|
||||||
|
|
||||||
import android.animation.ValueAnimator
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.CornerPathEffect
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.graphics.RectF
|
|
||||||
import android.graphics.Typeface
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.text.Layout
|
|
||||||
import android.text.StaticLayout
|
|
||||||
import android.text.TextDirectionHeuristic
|
|
||||||
import android.text.TextDirectionHeuristics
|
|
||||||
import android.text.TextPaint
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.View
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.draw
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.resolveSp
|
|
||||||
|
|
||||||
class PieChart @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
defStyleAttr: Int = 0
|
|
||||||
) : View(context, attrs, defStyleAttr), PieChartInterface {
|
|
||||||
|
|
||||||
private var marginTextFirst: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_1)
|
|
||||||
private var marginTextSecond: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_2)
|
|
||||||
private var marginTextThird: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_3)
|
|
||||||
private var marginSmallCircle: Float = context.resources.resolveDp(DEFAULT_MARGIN_SMALL_CIRCLE)
|
|
||||||
private val marginText: Float = marginTextFirst + marginTextSecond
|
|
||||||
private val circleRect = RectF()
|
|
||||||
private var circleStrokeWidth: Float = context.resources.resolveDp(6f)
|
|
||||||
private var circleRadius: Float = 0f
|
|
||||||
private var circlePadding: Float = context.resources.resolveDp(8f)
|
|
||||||
private var circlePaintRoundSize: Boolean = true
|
|
||||||
private var circleSectionSpace: Float = 3f
|
|
||||||
private var circleCenterX: Float = 0f
|
|
||||||
private var circleCenterY: Float = 0f
|
|
||||||
private var numberTextPaint: TextPaint = TextPaint()
|
|
||||||
private var descriptionTextPain: TextPaint = TextPaint()
|
|
||||||
private var amountTextPaint: TextPaint = TextPaint()
|
|
||||||
private var textStartX: Float = 0f
|
|
||||||
private var textStartY: Float = 0f
|
|
||||||
private var textHeight: Int = 0
|
|
||||||
private var textCircleRadius: Float = context.resources.resolveDp(4f)
|
|
||||||
private var textAmountStr: String = ""
|
|
||||||
private var textAmountY: Float = 0f
|
|
||||||
private var textAmountXNumber: Float = 0f
|
|
||||||
private var textAmountXDescription: Float = 0f
|
|
||||||
private var textAmountYDescription: Float = 0f
|
|
||||||
private var totalAmount: Int = 0
|
|
||||||
private var pieChartColors: List<String> = listOf()
|
|
||||||
private var percentageCircleList: List<PieChartModel> = listOf()
|
|
||||||
private var textRowList: MutableList<StaticLayout> = mutableListOf()
|
|
||||||
private var dataList: List<Pair<Int, String>> = listOf()
|
|
||||||
private var animationSweepAngle: Int = 0
|
|
||||||
|
|
||||||
init {
|
|
||||||
var textAmountSize: Float = context.resources.resolveSp(22f)
|
|
||||||
var textNumberSize: Float = context.resources.resolveSp(20f)
|
|
||||||
var textDescriptionSize: Float = context.resources.resolveSp(14f)
|
|
||||||
var textAmountColor: Int = Color.WHITE
|
|
||||||
var textNumberColor: Int = Color.WHITE
|
|
||||||
var textDescriptionColor: Int = Color.GRAY
|
|
||||||
|
|
||||||
if (attrs != null) {
|
|
||||||
val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart)
|
|
||||||
|
|
||||||
val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0)
|
|
||||||
pieChartColors = typeArray.resources.getStringArray(colorResId).toList()
|
|
||||||
|
|
||||||
marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst)
|
|
||||||
marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond)
|
|
||||||
marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird)
|
|
||||||
marginSmallCircle =
|
|
||||||
typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle)
|
|
||||||
|
|
||||||
circleStrokeWidth =
|
|
||||||
typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth)
|
|
||||||
circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding)
|
|
||||||
circlePaintRoundSize =
|
|
||||||
typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize)
|
|
||||||
circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace)
|
|
||||||
|
|
||||||
textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius)
|
|
||||||
textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize)
|
|
||||||
textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize)
|
|
||||||
textDescriptionSize =
|
|
||||||
typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize)
|
|
||||||
textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor)
|
|
||||||
textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor)
|
|
||||||
textDescriptionColor =
|
|
||||||
typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor)
|
|
||||||
textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: ""
|
|
||||||
|
|
||||||
typeArray.recycle()
|
|
||||||
}
|
|
||||||
|
|
||||||
circlePadding += circleStrokeWidth
|
|
||||||
|
|
||||||
// Инициализация кистей View
|
|
||||||
initPaints(amountTextPaint, textAmountSize, textAmountColor)
|
|
||||||
initPaints(numberTextPaint, textNumberSize, textNumberColor)
|
|
||||||
initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
|
||||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
||||||
textRowList.clear()
|
|
||||||
|
|
||||||
val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH)
|
|
||||||
|
|
||||||
val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT)
|
|
||||||
val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt())
|
|
||||||
|
|
||||||
textStartX = initSizeWidth - textTextWidth.toFloat()
|
|
||||||
textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2
|
|
||||||
|
|
||||||
calculateCircleRadius(initSizeWidth, initSizeHeight)
|
|
||||||
|
|
||||||
setMeasuredDimension(initSizeWidth, initSizeHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas) {
|
|
||||||
super.onDraw(canvas)
|
|
||||||
|
|
||||||
drawCircle(canvas)
|
|
||||||
drawText(canvas)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
|
||||||
val pieChartState = state as? PieChartState
|
|
||||||
super.onRestoreInstanceState(pieChartState?.superState ?: state)
|
|
||||||
|
|
||||||
dataList = pieChartState?.dataList ?: listOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSaveInstanceState(): Parcelable {
|
|
||||||
val superState = super.onSaveInstanceState()
|
|
||||||
return PieChartState(superState, dataList)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setDataChart(list: List<Pair<Int, String>>) {
|
|
||||||
dataList = list
|
|
||||||
calculatePercentageOfData()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startAnimation() {
|
|
||||||
val animator = ValueAnimator.ofInt(0, 360).apply {
|
|
||||||
duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
|
|
||||||
interpolator = FastOutSlowInInterpolator()
|
|
||||||
addUpdateListener { valueAnimator ->
|
|
||||||
animationSweepAngle = valueAnimator.animatedValue as Int
|
|
||||||
invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
animator.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun drawCircle(canvas: Canvas) {
|
|
||||||
for (percent in percentageCircleList) {
|
|
||||||
if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) {
|
|
||||||
canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint)
|
|
||||||
} else if (animationSweepAngle > percent.percentToStartAt) {
|
|
||||||
canvas.drawArc(
|
|
||||||
circleRect,
|
|
||||||
percent.percentToStartAt,
|
|
||||||
animationSweepAngle - percent.percentToStartAt,
|
|
||||||
false,
|
|
||||||
percent.paint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun drawText(canvas: Canvas) {
|
|
||||||
var textBuffY = textStartY
|
|
||||||
textRowList.forEachIndexed { index, staticLayout ->
|
|
||||||
if (index % 2 == 0) {
|
|
||||||
staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY)
|
|
||||||
canvas.drawCircle(
|
|
||||||
textStartX + marginSmallCircle / 2,
|
|
||||||
textBuffY + staticLayout.height / 2 + textCircleRadius / 2,
|
|
||||||
textCircleRadius,
|
|
||||||
Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) },
|
|
||||||
)
|
|
||||||
textBuffY += staticLayout.height + marginTextFirst
|
|
||||||
} else {
|
|
||||||
staticLayout.draw(canvas, textStartX, textBuffY)
|
|
||||||
textBuffY += staticLayout.height + marginTextSecond
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint)
|
|
||||||
canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) {
|
|
||||||
textPaint.color = textColor
|
|
||||||
textPaint.textSize = textSize
|
|
||||||
textPaint.isAntiAlias = true
|
|
||||||
|
|
||||||
if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveDefaultSize(spec: Int, defValue: Int): Int {
|
|
||||||
return when (MeasureSpec.getMode(spec)) {
|
|
||||||
MeasureSpec.UNSPECIFIED -> resources.resolveDp(defValue)
|
|
||||||
else -> MeasureSpec.getSize(spec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
|
||||||
private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int {
|
|
||||||
val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT)
|
|
||||||
textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt()
|
|
||||||
|
|
||||||
val textHeightWithPadding = textHeight + paddingTop + paddingBottom
|
|
||||||
return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateCircleRadius(width: Int, height: Int) {
|
|
||||||
val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT)
|
|
||||||
circleRadius = if (circleViewWidth > height) {
|
|
||||||
(height.toFloat() - circlePadding) / 2
|
|
||||||
} else {
|
|
||||||
circleViewWidth.toFloat() / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
with(circleRect) {
|
|
||||||
left = circlePadding
|
|
||||||
top = height / 2 - circleRadius
|
|
||||||
right = circleRadius * 2 + circlePadding
|
|
||||||
bottom = height / 2 + circleRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2
|
|
||||||
circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2
|
|
||||||
|
|
||||||
textAmountY = circleCenterY
|
|
||||||
|
|
||||||
val sizeTextAmountNumber = getWidthOfAmountText(
|
|
||||||
totalAmount.toString(),
|
|
||||||
amountTextPaint,
|
|
||||||
)
|
|
||||||
|
|
||||||
textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2
|
|
||||||
textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2
|
|
||||||
textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
|
||||||
private fun getTextViewHeight(maxWidth: Int): Int {
|
|
||||||
var textHeight = 0
|
|
||||||
dataList.forEach {
|
|
||||||
val textLayoutNumber = getMultilineText(
|
|
||||||
text = it.first.toString(),
|
|
||||||
textPaint = numberTextPaint,
|
|
||||||
width = maxWidth,
|
|
||||||
)
|
|
||||||
val textLayoutDescription = getMultilineText(
|
|
||||||
text = it.second,
|
|
||||||
textPaint = descriptionTextPain,
|
|
||||||
width = maxWidth,
|
|
||||||
)
|
|
||||||
textRowList.apply {
|
|
||||||
add(textLayoutNumber)
|
|
||||||
add(textLayoutDescription)
|
|
||||||
}
|
|
||||||
textHeight += textLayoutNumber.height + textLayoutDescription.height
|
|
||||||
}
|
|
||||||
|
|
||||||
return textHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculatePercentageOfData() {
|
|
||||||
totalAmount = dataList.fold(0) { res, value -> res + value.first }
|
|
||||||
|
|
||||||
var startAt = circleSectionSpace
|
|
||||||
percentageCircleList = dataList.mapIndexed { index, pair ->
|
|
||||||
var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace
|
|
||||||
percent = if (percent < 0f) 0f else percent
|
|
||||||
|
|
||||||
val resultModel = PieChartModel(
|
|
||||||
percentOfCircle = percent,
|
|
||||||
percentToStartAt = startAt,
|
|
||||||
colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]),
|
|
||||||
stroke = circleStrokeWidth,
|
|
||||||
paintRound = circlePaintRoundSize,
|
|
||||||
)
|
|
||||||
if (percent != 0f) startAt += percent + circleSectionSpace
|
|
||||||
resultModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect {
|
|
||||||
val bounds = Rect()
|
|
||||||
textPaint.getTextBounds(text, 0, text.length, bounds)
|
|
||||||
return bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
|
||||||
private fun getMultilineText(
|
|
||||||
text: CharSequence,
|
|
||||||
textPaint: TextPaint,
|
|
||||||
width: Int,
|
|
||||||
start: Int = 0,
|
|
||||||
end: Int = text.length,
|
|
||||||
alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
|
|
||||||
textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR,
|
|
||||||
spacingMult: Float = 1f,
|
|
||||||
spacingAdd: Float = 0f
|
|
||||||
): StaticLayout {
|
|
||||||
|
|
||||||
return StaticLayout.Builder
|
|
||||||
.obtain(text, start, end, textPaint, width)
|
|
||||||
.setAlignment(alignment)
|
|
||||||
.setTextDirection(textDir)
|
|
||||||
.setLineSpacing(spacingAdd, spacingMult)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val DEFAULT_MARGIN_TEXT_1 = 2f
|
|
||||||
private const val DEFAULT_MARGIN_TEXT_2 = 10f
|
|
||||||
private const val DEFAULT_MARGIN_TEXT_3 = 2f
|
|
||||||
private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12f
|
|
||||||
|
|
||||||
private const val TEXT_WIDTH_PERCENT = 0.40
|
|
||||||
private const val CIRCLE_WIDTH_PERCENT = 0.50
|
|
||||||
|
|
||||||
const val DEFAULT_VIEW_SIZE_HEIGHT = 150
|
|
||||||
const val DEFAULT_VIEW_SIZE_WIDTH = 250
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PieChartInterface {
|
|
||||||
|
|
||||||
fun setDataChart(list: List<Pair<Int, String>>)
|
|
||||||
|
|
||||||
fun startAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class PieChartModel(
|
|
||||||
var percentOfCircle: Float = 0f,
|
|
||||||
var percentToStartAt: Float = 0f,
|
|
||||||
var colorOfLine: Int = 0,
|
|
||||||
var stroke: Float = 0f,
|
|
||||||
var paint: Paint = Paint(),
|
|
||||||
var paintRound: Boolean = true
|
|
||||||
) {
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (percentOfCircle < 0 || percentOfCircle > 100) {
|
|
||||||
percentOfCircle = 100f
|
|
||||||
}
|
|
||||||
|
|
||||||
percentOfCircle = 360 * percentOfCircle / 100
|
|
||||||
|
|
||||||
if (percentToStartAt < 0 || percentToStartAt > 100) {
|
|
||||||
percentToStartAt = 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
percentToStartAt = 360 * percentToStartAt / 100
|
|
||||||
|
|
||||||
if (colorOfLine == 0) {
|
|
||||||
colorOfLine = Color.parseColor("#000000")
|
|
||||||
}
|
|
||||||
|
|
||||||
paint = Paint()
|
|
||||||
paint.color = colorOfLine
|
|
||||||
paint.isAntiAlias = true
|
|
||||||
paint.style = Paint.Style.STROKE
|
|
||||||
paint.strokeWidth = stroke
|
|
||||||
paint.isDither = true
|
|
||||||
|
|
||||||
if (paintRound) {
|
|
||||||
paint.strokeJoin = Paint.Join.ROUND
|
|
||||||
paint.strokeCap = Paint.Cap.ROUND
|
|
||||||
paint.pathEffect = CornerPathEffect(8f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PieChartState(
|
|
||||||
superSavedState: Parcelable?,
|
|
||||||
val dataList: List<Pair<Int, String>>
|
|
||||||
) : View.BaseSavedState(superSavedState), Parcelable
|
|
||||||
@ -1,11 +1,37 @@
|
|||||||
package org.koitharu.kotatsu.stats.data
|
package org.koitharu.kotatsu.stats.data
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
|
import androidx.room.MapColumn
|
||||||
|
import androidx.room.Query
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class StatsDao {
|
abstract class StatsDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM stats ORDER BY started_at")
|
||||||
|
abstract suspend fun findAll(): List<StatsEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at")
|
||||||
|
abstract suspend fun findAll(mangaId: Long): List<StatsEntity>
|
||||||
|
|
||||||
|
@Query("SELECT IFNULL(SUM(pages),0) FROM stats WHERE manga_id = :mangaId")
|
||||||
|
abstract suspend fun getReadPagesCount(mangaId: Long): Int
|
||||||
|
|
||||||
|
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats WHERE manga_id = :mangaId")
|
||||||
|
abstract suspend fun getAverageTimePerPage(mangaId: Long): Long
|
||||||
|
|
||||||
|
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats WHERE manga_id = :mangaId")
|
||||||
|
abstract suspend fun getReadingTime(mangaId: Long): Long
|
||||||
|
|
||||||
|
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats")
|
||||||
|
abstract suspend fun getTotalReadingTime(): Long
|
||||||
|
|
||||||
|
@Query("SELECT manga_id, SUM(duration) AS d FROM stats GROUP BY manga_id ORDER BY d")
|
||||||
|
abstract suspend fun getDurationStats(): Map<@MapColumn("manga_id") Long, @MapColumn("d") Long>
|
||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(entity: StatsEntity)
|
abstract suspend fun upsert(entity: StatsEntity)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.stats.data
|
||||||
|
|
||||||
|
import androidx.collection.ArrayMap
|
||||||
|
import androidx.collection.MutableScatterMap
|
||||||
|
import androidx.collection.ScatterMap
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||||
|
import java.util.Date
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class StatsRepository @Inject constructor(
|
||||||
|
private val db: MangaDatabase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun getReadingStats(): List<StatsRecord> = db.withTransaction {
|
||||||
|
val stats = db.getStatsDao().getDurationStats()
|
||||||
|
val mangaDao = db.getMangaDao()
|
||||||
|
val result = ArrayList<StatsRecord>(stats.size)
|
||||||
|
for ((mangaId, duration) in stats) {
|
||||||
|
val manga = mangaDao.find(mangaId)?.toManga() ?: continue
|
||||||
|
result += StatsRecord(
|
||||||
|
manga = manga,
|
||||||
|
duration = duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package org.koitharu.kotatsu.stats.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import com.google.android.material.R
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
|
import org.koitharu.kotatsu.details.data.ReadingTime
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
data class StatsRecord(
|
||||||
|
val manga: Manga,
|
||||||
|
val duration: Long,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val time: ReadingTime
|
||||||
|
|
||||||
|
init {
|
||||||
|
val minutes = TimeUnit.MILLISECONDS.toMinutes(duration).toInt()
|
||||||
|
time = ReadingTime(
|
||||||
|
minutes = minutes % 60,
|
||||||
|
hours = minutes / 60,
|
||||||
|
isContinue = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
|
fun getColor(context: Context): Int {
|
||||||
|
val hue = (manga.id.absoluteValue % 360).toFloat()
|
||||||
|
val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
||||||
|
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
|
||||||
|
return MaterialColors.harmonize(color, backgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package org.koitharu.kotatsu.stats.ui
|
||||||
|
|
||||||
|
import android.graphics.drawable.ShapeDrawable
|
||||||
|
import android.graphics.drawable.shapes.OvalShape
|
||||||
|
import android.graphics.drawable.shapes.Shape
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.style.DynamicDrawableSpan
|
||||||
|
import android.text.style.ImageSpan
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.text.buildSpannedString
|
||||||
|
import androidx.core.text.inSpans
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.databinding.FragmentStatsBinding
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.stats.domain.StatsRecord
|
||||||
|
import org.koitharu.kotatsu.stats.ui.views.PieChartView
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class StatsFragment : BaseFragment<FragmentStatsBinding>() {
|
||||||
|
|
||||||
|
private val viewModel: StatsViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentStatsBinding {
|
||||||
|
return FragmentStatsBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: FragmentStatsBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
viewModel.readingStats.observe(viewLifecycleOwner) {
|
||||||
|
val sum = it.sumOf { it.duration }
|
||||||
|
binding.chart.setData(
|
||||||
|
it.map { v ->
|
||||||
|
PieChartView.Segment(
|
||||||
|
value = (v.duration / 1000).toInt(),
|
||||||
|
label = v.manga.title,
|
||||||
|
percent = (v.duration.toDouble() / sum).toFloat(),
|
||||||
|
color = v.getColor(binding.chart.context),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
binding.textViewLegend.text = buildLegend(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||||
|
|
||||||
|
private fun buildLegend(stats: List<StatsRecord>) = buildSpannedString {
|
||||||
|
val context = context ?: return@buildSpannedString
|
||||||
|
for (item in stats) {
|
||||||
|
ContextCompat.getDrawable(context, R.drawable.bg_rounded_square)?.let { icon ->
|
||||||
|
icon.setBounds(0, 0, icon.intrinsicWidth, icon.intrinsicHeight)
|
||||||
|
icon.setTint(item.getColor(context))
|
||||||
|
inSpans(ImageSpan(icon, DynamicDrawableSpan.ALIGN_BASELINE)) {
|
||||||
|
append(' ')
|
||||||
|
}
|
||||||
|
append(' ')
|
||||||
|
}
|
||||||
|
append(item.manga.title)
|
||||||
|
append(" - ")
|
||||||
|
append(item.time.format(context.resources))
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package org.koitharu.kotatsu.stats.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class StatsViewModel @Inject constructor(
|
||||||
|
private val repository: StatsRepository,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val readingStats = flow {
|
||||||
|
emit(repository.getReadingStats())
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
package org.koitharu.kotatsu.stats.ui.views
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.graphics.PorterDuffXfermode
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.Xfermode
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.collection.MutableIntList
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.core.graphics.minus
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||||
|
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class PieChartView @JvmOverloads constructor(
|
||||||
|
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||||
|
) : View(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val segments = ArrayList<Segment>()
|
||||||
|
private val chartBounds = RectF()
|
||||||
|
private val clearColor = context.getThemeColor(android.R.attr.colorBackground)
|
||||||
|
|
||||||
|
init {
|
||||||
|
paint.strokeWidth = context.resources.resolveDp(2f)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
var angle = 0f
|
||||||
|
for ((i, segment) in segments.withIndex()) {
|
||||||
|
paint.color = segment.color
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
val sweepAngle = segment.percent * 360f
|
||||||
|
canvas.drawArc(
|
||||||
|
chartBounds,
|
||||||
|
angle,
|
||||||
|
sweepAngle,
|
||||||
|
true,
|
||||||
|
paint,
|
||||||
|
)
|
||||||
|
paint.color = clearColor
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
canvas.drawArc(
|
||||||
|
chartBounds,
|
||||||
|
angle,
|
||||||
|
sweepAngle,
|
||||||
|
true,
|
||||||
|
paint,
|
||||||
|
)
|
||||||
|
angle += sweepAngle
|
||||||
|
}
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.color = clearColor
|
||||||
|
canvas.drawCircle(chartBounds.centerX(), chartBounds.centerY(), chartBounds.height() / 4f, paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh)
|
||||||
|
val size = minOf(w, h).toFloat()
|
||||||
|
val inset = paint.strokeWidth
|
||||||
|
chartBounds.set(inset, inset, size - inset, size - inset)
|
||||||
|
chartBounds.offset(
|
||||||
|
(w - size) / 2f,
|
||||||
|
(h - size) / 2f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setData(value: List<Segment>) {
|
||||||
|
segments.replaceWith(value)
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Segment(
|
||||||
|
val value: Int,
|
||||||
|
val label: String,
|
||||||
|
val percent: Float,
|
||||||
|
val color: Int,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.stats.ui.views.PieChartView
|
||||||
|
android:id="@+id/chart"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_margin="24dp"
|
||||||
|
app:layout_constraintDimensionRatio="1"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_legend"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/chart"
|
||||||
|
tools:text="@tools:sample/lorem/random" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
Loading…
Reference in New Issue