Simple reading stats display
This commit is contained in:
@@ -7,6 +7,6 @@ class Migration18To19 : Migration(18, 19) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -169,6 +169,11 @@ class ReaderActivity :
|
||||
idlingDetector.onUserInteraction()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
viewModel.onPause()
|
||||
}
|
||||
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
}
|
||||
|
||||
@@ -191,6 +191,12 @@ class ReaderViewModel @Inject constructor(
|
||||
loadImpl()
|
||||
}
|
||||
|
||||
fun onPause() {
|
||||
manga?.let {
|
||||
statsCollector.onPause(it.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun switchMode(newMode: ReaderMode) {
|
||||
launchJob {
|
||||
val manga = checkNotNull(mangaData.value?.toManga())
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
package org.koitharu.kotatsu.stats.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.Query
|
||||
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
|
||||
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
|
||||
abstract suspend fun upsert(entity: StatsEntity)
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
|
||||
@Entity(
|
||||
tableName = "stats",
|
||||
primaryKeys = ["manga_id", "started_at"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
entity = HistoryEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,11 @@ class StatsCollector @Inject constructor(
|
||||
commit(newEntry.stats)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onPause(mangaId: Long) {
|
||||
stats.remove(mangaId)
|
||||
}
|
||||
|
||||
private fun commit(entity: StatsEntity) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
db.getStatsDao().upsert(entity)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
37
app/src/main/res/layout/fragment_stats.xml
Normal file
37
app/src/main/res/layout/fragment_stats.xml
Normal file
@@ -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>
|
||||
@@ -134,31 +134,6 @@
|
||||
<attr name="cornerSize" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="PieChart">
|
||||
<attr name="pieChartColors" format="reference" />
|
||||
|
||||
<attr name="pieChartMarginTextFirst" format="dimension" />
|
||||
<attr name="pieChartMarginTextSecond" format="dimension" />
|
||||
<attr name="pieChartMarginTextThird" format="dimension" />
|
||||
<attr name="pieChartMarginSmallCircle" format="dimension" />
|
||||
|
||||
<attr name="pieChartCircleStrokeWidth" format="dimension" />
|
||||
<attr name="pieChartCirclePadding" format="dimension" />
|
||||
<attr name="pieChartCirclePaintRoundSize" format="boolean" />
|
||||
<attr name="pieChartCircleSectionSpace" format="float" />
|
||||
|
||||
<attr name="pieChartTextCircleRadius" format="dimension" />
|
||||
<attr name="pieChartTextAmountSize" format="dimension" />
|
||||
<attr name="pieChartTextNumberSize" format="dimension" />
|
||||
<attr name="pieChartTextDescriptionSize" format="dimension" />
|
||||
|
||||
<attr name="pieChartTextAmountColor" format="color" />
|
||||
<attr name="pieChartTextNumberColor" format="color" />
|
||||
<attr name="pieChartTextDescriptionColor" format="color" />
|
||||
|
||||
<attr name="pieChartTextAmount" format="string" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="NestedRecyclerView">
|
||||
<attr name="maxHeight" />
|
||||
</declare-styleable>
|
||||
|
||||
@@ -8,4 +8,9 @@
|
||||
android:layout="@layout/preference_toggle_header"
|
||||
android:title="@string/stats_enabled" />
|
||||
|
||||
<Preference
|
||||
android:dependency="stats_on"
|
||||
android:fragment="org.koitharu.kotatsu.stats.ui.StatsFragment"
|
||||
android:title="@string/reading_stats" />
|
||||
|
||||
</androidx.preference.PreferenceScreen>
|
||||
|
||||
Reference in New Issue
Block a user