diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration18To19.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration18To19.kt index 5b4b0498b..55eca18eb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration18To19.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration18To19.kt @@ -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 )") } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt deleted file mode 100644 index b5cb32206..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt +++ /dev/null @@ -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 = listOf() - private var percentageCircleList: List = listOf() - private var textRowList: MutableList = mutableListOf() - private var dataList: List> = 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>) { - 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>) - - 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> -) : View.BaseSavedState(superSavedState), Parcelable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 660e68eea..5c26eb2a7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -169,6 +169,11 @@ class ReaderActivity : idlingDetector.onUserInteraction() } + override fun onPause() { + super.onPause() + viewModel.onPause() + } + override fun onIdle() { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 42665e775..119952b73 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -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()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt index b3ddb0cb9..effc33cf2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt @@ -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 + + @Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at") + abstract suspend fun findAll(mangaId: Long): List + + @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) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt index 1807fb53c..0889b3f53 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt @@ -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, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt new file mode 100644 index 000000000..c9e829b11 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt @@ -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 = db.withTransaction { + val stats = db.getStatsDao().getDurationStats() + val mangaDao = db.getMangaDao() + val result = ArrayList(stats.size) + for ((mangaId, duration) in stats) { + val manga = mangaDao.find(mangaId)?.toManga() ?: continue + result += StatsRecord( + manga = manga, + duration = duration, + ) + } + result + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsCollector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsCollector.kt index 5970dfe74..bba53f050 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsCollector.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsCollector.kt @@ -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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt new file mode 100644 index 000000000..6f9a1e2d4 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsFragment.kt new file mode 100644 index 000000000..6b414da6d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsFragment.kt @@ -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() { + + 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) = 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() + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt new file mode 100644 index 000000000..2188dca80 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt @@ -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()) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt new file mode 100644 index 000000000..4cd1fde54 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt @@ -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() + 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) { + segments.replaceWith(value) + invalidate() + } + + class Segment( + val value: Int, + val label: String, + val percent: Float, + val color: Int, + ) +} diff --git a/app/src/main/res/layout/fragment_stats.xml b/app/src/main/res/layout/fragment_stats.xml new file mode 100644 index 000000000..7380c3a10 --- /dev/null +++ b/app/src/main/res/layout/fragment_stats.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index e1cf01d12..ef95edffb 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -134,31 +134,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/pref_stats.xml b/app/src/main/res/xml/pref_stats.xml index 8748a57f9..9f164bc46 100644 --- a/app/src/main/res/xml/pref_stats.xml +++ b/app/src/main/res/xml/pref_stats.xml @@ -8,4 +8,9 @@ android:layout="@layout/preference_toggle_header" android:title="@string/stats_enabled" /> + +