Timeline stats per manga

This commit is contained in:
Koitharu
2024-03-01 15:00:38 +02:00
parent 8e82db441c
commit f7a70680bd
10 changed files with 331 additions and 100 deletions

View File

@@ -7,6 +7,7 @@ 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.parsers.model.Manga
import kotlin.math.absoluteValue
object Colors {
@@ -20,11 +21,24 @@ object Colors {
return MaterialColors.harmonize(color, backgroundColor)
}
@ColorInt
fun random(seed: Any): Int {
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
}
@ColorInt
fun of(context: Context, manga: Manga?): Int {
val color = if (manga != null) {
val hue = (manga.id.absoluteValue % 360).toFloat()
ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
} else {
context.getThemeColor(R.attr.colorSurface)
}
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
private fun getHue(hex: String): Float {
val r = (hex.substring(0, 2).toInt(16)).toFloat()
val g = (hex.substring(2, 4).toInt(16)).toFloat()

View File

@@ -1,10 +1,14 @@
package org.koitharu.kotatsu.stats.data
import androidx.collection.LongIntMap
import androidx.collection.MutableLongIntMap
import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.stats.domain.StatsPeriod
import org.koitharu.kotatsu.stats.domain.StatsRecord
import java.util.NavigableMap
import java.util.TreeMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@@ -19,13 +23,14 @@ class StatsRepository @Inject constructor(
System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong())
}
val stats = db.getStatsDao().getDurationStats(fromDate)
val minute = TimeUnit.MINUTES.toMillis(1)
val mangaDao = db.getMangaDao()
val result = ArrayList<StatsRecord>(stats.size)
var other = StatsRecord(null, 0)
val total = stats.values.sum()
for ((mangaId, duration) in stats) {
val manga = mangaDao.find(mangaId)?.toManga()
if (manga == null || duration < minute) {
val percent = duration.toDouble() / total
if (manga == null || percent < 0.05) {
other = other.copy(duration = other.duration + duration)
} else {
result += StatsRecord(
@@ -51,6 +56,15 @@ class StatsRepository @Inject constructor(
time
}
suspend fun getMangaTimeline(mangaId: Long): NavigableMap<Long, Int> {
val entities = db.getStatsDao().findAll(mangaId)
val map = TreeMap<Long, Int>()
for (e in entities) {
map[e.startedAt] = e.pages
}
return map
}
suspend fun clearStats() {
db.getStatsDao().clear()
}

View File

@@ -32,16 +32,4 @@ data class StatsRecord(
isContinue = false,
)
}
@ColorInt
fun getColor(context: Context): Int {
val color = if (manga != null) {
val hue = (manga.id.absoluteValue % 360).toFloat()
ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
} else {
context.getThemeColor(R.attr.colorSurface)
}
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
}

View File

@@ -4,6 +4,7 @@ import android.content.res.ColorStateList
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.Colors
import org.koitharu.kotatsu.databinding.ItemStatsBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.stats.domain.StatsRecord
@@ -21,7 +22,7 @@ fun statsAD(
bind {
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.other_manga)
binding.textViewSummary.text = item.time.format(context.resources)
binding.imageViewBadge.imageTintList = ColorStateList.valueOf(item.getColor(context))
binding.imageViewBadge.imageTintList = ColorStateList.valueOf(Colors.of(context, item.manga))
binding.root.isClickable = item.manga != null
}
}

View File

@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.Colors
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
@@ -47,6 +48,7 @@ import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.stats.domain.StatsPeriod
import org.koitharu.kotatsu.stats.domain.StatsRecord
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import org.koitharu.kotatsu.stats.ui.views.PieChartView
import javax.inject.Inject
@@ -88,7 +90,7 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
value = (v.duration / 1000).toInt(),
label = v.manga?.title ?: getString(R.string.other_manga),
percent = (v.duration.toDouble() / sum).toFloat(),
color = v.getColor(this),
color = Colors.of(this, v.manga),
)
},
)
@@ -99,7 +101,7 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onItemClick(item: Manga, view: View) {
startActivity(DetailsActivity.newIntent(view.context, item))
MangaStatsSheet.show(supportFragmentManager, item)
}
override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) {

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.stats.ui.sheet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.collection.IntList
import androidx.collection.LongIntMap
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.Colors
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.stats.ui.views.BarChartView
import java.util.concurrent.TimeUnit
@AndroidEntryPoint
class MangaStatsSheet : BaseAdaptiveSheet<SheetStatsMangaBinding>() {
private val viewModel: MangaStatsViewModel by viewModels()
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetStatsMangaBinding {
return SheetStatsMangaBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetStatsMangaBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewTitle.text = viewModel.manga.title
binding.chartView.barColor = Colors.of(binding.root.context, viewModel.manga)
viewModel.stats.observe(viewLifecycleOwner, ::onStatsChanged)
viewModel.startDate.observe(viewLifecycleOwner) {
binding.textViewStart.textAndVisible = it?.format(resources)
}
}
private fun onStatsChanged(stats: IntList) {
val chartView = viewBinding?.chartView ?: return
if (stats.isEmpty()) {
chartView.setData(emptyList())
return
}
val bars = ArrayList<BarChartView.Bar>(stats.size)
stats.forEach { pages ->
bars.add(
BarChartView.Bar(
value = pages,
label = pages.toString(),
),
)
}
chartView.setData(bars)
}
companion object {
const val ARG_MANGA = "manga"
private const val TAG = "MangaStatsSheet"
fun show(fm: FragmentManager, manga: Manga) {
MangaStatsSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG)
}
}
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.stats.ui.sheet
import androidx.collection.IntList
import androidx.collection.LongIntMap
import androidx.collection.MutableIntList
import androidx.collection.emptyIntList
import androidx.collection.emptyLongIntMap
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.stats.data.StatsRepository
import org.koitharu.kotatsu.stats.domain.StatsRecord
import java.time.Instant
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class MangaStatsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: StatsRepository,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaStatsSheet.ARG_MANGA).manga
val stats = MutableStateFlow<IntList>(emptyIntList())
val startDate = MutableStateFlow<DateTimeAgo?>(null)
init {
launchLoadingJob(Dispatchers.Default) {
val timeline = repository.getMangaTimeline(manga.id)
if (timeline.isEmpty()) {
startDate.value = null
stats.value = emptyIntList()
} else {
val startDay = TimeUnit.MILLISECONDS.toDays(timeline.firstKey())
val endDay = TimeUnit.MILLISECONDS.toDays(timeline.lastKey())
val res = MutableIntList((endDay - startDay).toInt() + 1)
for (day in startDay..endDay) {
val from = TimeUnit.DAYS.toMillis(day)
val to = TimeUnit.DAYS.toMillis(day + 1)
res.add(timeline.subMap(from, true, to, false).values.sum())
}
stats.value = res
startDate.value = calculateTimeAgo(Instant.ofEpochMilli(timeline.firstKey()))
}
}
}
}

View File

@@ -0,0 +1,127 @@
package org.koitharu.kotatsu.stats.ui.views
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.PathEffect
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.graphics.Xfermode
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.annotation.ColorInt
import androidx.collection.MutableIntList
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.minus
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.setPadding
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 kotlin.math.max
import kotlin.math.sqrt
import com.google.android.material.R as materialR
class BarChartView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val bars = ArrayList<Bar>()
private var maxValue: Int = 0
private var spacing = context.resources.resolveDp(2f)
private val minSpace = context.resources.resolveDp(20f)
private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
private val dottedEffect = DashPathEffect(
floatArrayOf(
context.resources.resolveDp(6f),
context.resources.resolveDp(6f),
),
0f,
)
private val chartBounds = RectF()
@ColorInt
var barColor: Int = Color.MAGENTA
set(value) {
field = value
invalidate()
}
init {
paint.strokeWidth = context.resources.resolveDp(1f)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (bars.isEmpty() || chartBounds.isEmpty) {
return
}
val barWidth = ((chartBounds.width() + spacing) / bars.size.toFloat() - spacing)
// dashed horizontal lines
paint.color = outlineColor
paint.style = Paint.Style.STROKE
canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint)
paint.pathEffect = dottedEffect
for (i in (0..maxValue).step(computeValueStep())) {
val y = chartBounds.top + (chartBounds.height() * i / maxValue.toFloat())
canvas.drawLine(paddingLeft.toFloat(), y, (width - paddingLeft - paddingRight).toFloat(), y, paint)
}
// bars
paint.style = Paint.Style.FILL
paint.color = barColor
paint.pathEffect = null
for ((i, bar) in bars.withIndex()) {
val h = chartBounds.height() * bar.value / maxValue.toFloat()
val x = i * (barWidth + spacing) + paddingLeft
canvas.drawRect(x, chartBounds.bottom - h, x + barWidth, chartBounds.bottom, paint)
}
// bottom line
paint.color = outlineColor
paint.style = Paint.Style.STROKE
canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
invalidateBounds()
}
fun setData(value: List<Bar>) {
bars.replaceWith(value)
maxValue = if (value.isEmpty()) 0 else value.maxOf { it.value }
invalidate()
}
private fun computeValueStep(): Int {
val h = chartBounds.height()
var step = 1
while (h / (maxValue / step).toFloat() <= minSpace) {
step++
}
return step
}
private fun invalidateBounds() {
val inset = paint.strokeWidth
chartBounds.set(
paddingLeft.toFloat() + inset,
paddingTop.toFloat() + inset,
(width - paddingLeft - paddingRight).toFloat() - inset,
(height - paddingTop - paddingBottom).toFloat() - inset,
)
}
class Bar(
val value: Int,
val label: String,
)
}

View File

@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
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="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/margin_small"
android:paddingBottom="@dimen/margin_normal">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/dragHandle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true"
android:layout_below="@id/dragHandle"
android:layout_alignParentStart="true"
android:layout_toStartOf="@id/textView_label"
android:paddingHorizontal="@dimen/margin_small"
android:paddingBottom="@dimen/margin_small"
android:singleLine="true"
android:text="@string/grid_size"
android:textAppearance="?textAppearanceTitleMedium" />
<TextView
android:id="@+id/textView_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@id/textView_title"
android:layout_alignParentEnd="true"
android:paddingHorizontal="@dimen/margin_small"
android:singleLine="true"
android:textAppearance="?textAppearanceLabelLarge"
tools:text="100%" />
<ImageView
android:id="@+id/button_small"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_alignTop="@id/slider_grid"
android:layout_alignBottom="@id/slider_grid"
android:layout_alignParentStart="true"
android:background="?selectableItemBackgroundBorderless"
android:padding="8dp"
android:src="@drawable/ic_size_small"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar" />
<ImageView
android:id="@+id/button_large"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_alignTop="@id/slider_grid"
android:layout_alignBottom="@id/slider_grid"
android:layout_alignParentEnd="true"
android:background="?selectableItemBackgroundBorderless"
android:padding="8dp"
android:src="@drawable/ic_size_large"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_grid"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_below="@id/textView_title"
android:layout_toStartOf="@id/button_large"
android:layout_toEndOf="@id/button_small"
android:stepSize="5"
android:valueFrom="50"
android:valueTo="150"
app:labelBehavior="gone"
app:tickVisible="false"
tools:value="100" />
</RelativeLayout>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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"
android:orientation="vertical"
android:paddingBottom="@dimen/screen_padding">
<org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetHeaderBar
android:id="@+id/headerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/reading_stats" />
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/screen_padding"
android:textAppearance="?textAppearanceTitleMedium"
tools:text="@tools:sample/lorem[4]" />
<org.koitharu.kotatsu.stats.ui.views.BarChartView
android:id="@+id/chartView"
android:layout_width="match_parent"
android:layout_height="240dp"
android:layout_marginTop="12dp"
android:paddingHorizontal="@dimen/screen_padding" />
<TextView
android:id="@+id/textView_start"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:paddingHorizontal="@dimen/screen_padding"
android:textAppearance="?textAppearanceLabelSmall"
tools:text="Week ago" />
</LinearLayout>