Stats activity

This commit is contained in:
Koitharu
2024-02-29 14:01:31 +02:00
parent 11cd5609bb
commit 101d357eff
19 changed files with 213 additions and 99 deletions

View File

@@ -239,6 +239,9 @@
<data android:scheme="kotatsu+kitsu" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.stats.ui.StatsActivity"
android:label="@string/reading_stats" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"

View File

@@ -10,6 +10,7 @@ data class ReadingTime(
) {
fun format(resources: Resources): String = when {
hours == 0 && minutes == 0 -> resources.getString(R.string.less_than_minute)
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
else -> resources.getString(

View File

@@ -11,7 +11,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
@@ -27,6 +29,7 @@ class HistoryListFragment : MangaListFragment() {
super.onViewBindingCreated(binding, savedInstanceState)
RecyclerScrollKeeper(binding.recyclerView).attach()
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
}
override fun onScrolledToEnd() = Unit

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.history.ui
import android.content.Context
import android.content.Intent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
@@ -9,6 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.stats.ui.StatsActivity
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -24,6 +26,11 @@ class HistoryListMenuProvider(
menuInflater.inflate(R.menu.opt_history, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_stats)?.isVisible = viewModel.isStatsEnabled.value
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_clear_history -> {
@@ -31,6 +38,11 @@ class HistoryListMenuProvider(
true
}
R.id.action_stats -> {
context.startActivity(Intent(context, StatsActivity::class.java))
true
}
else -> false
}
}

View File

@@ -71,6 +71,12 @@ class HistoryListViewModel @Inject constructor(
g && s.isGroupingSupported()
}
val isStatsEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_STATS_ENABLED,
valueProducer = { isStatsEnabled },
)
override val content = combine(
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
isGroupingEnabled,

View File

@@ -1,14 +0,0 @@
package org.koitharu.kotatsu.settings
import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
@AndroidEntryPoint
class StatsSettingsFragment : BasePreferenceFragment(R.string.reading_stats) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_stats)
}
}

View File

@@ -29,7 +29,7 @@ interface StatsDao {
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats")
suspend fun getTotalReadingTime(): Long
@Query("SELECT manga_id, SUM(duration) AS d FROM stats GROUP BY manga_id ORDER BY d")
@Query("SELECT manga_id, SUM(duration) AS d FROM stats GROUP BY manga_id ORDER BY d DESC")
suspend fun getDurationStats(): Map<@MapColumn("manga_id") Long, @MapColumn("d") Long>
@Upsert

View File

@@ -4,6 +4,7 @@ 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.StatsRecord
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class StatsRepository @Inject constructor(
@@ -12,14 +13,23 @@ class StatsRepository @Inject constructor(
suspend fun getReadingStats(): List<StatsRecord> = db.withTransaction {
val stats = db.getStatsDao().getDurationStats()
val minute = TimeUnit.MINUTES.toMillis(1)
val mangaDao = db.getMangaDao()
val result = ArrayList<StatsRecord>(stats.size)
var other = StatsRecord(null, 0)
for ((mangaId, duration) in stats) {
val manga = mangaDao.find(mangaId)?.toManga() ?: continue
result += StatsRecord(
manga = manga,
duration = duration,
)
val manga = mangaDao.find(mangaId)?.toManga()
if (manga == null || duration < minute) {
other = other.copy(duration = other.duration + duration)
} else {
result += StatsRecord(
manga = manga,
duration = duration,
)
}
}
if (other.duration != 0L) {
result += other
}
result
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.stats.domain
import android.content.Context
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import com.google.android.material.R
import com.google.android.material.color.MaterialColors
@@ -13,7 +14,7 @@ import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue
data class StatsRecord(
val manga: Manga,
val manga: Manga?,
val duration: Long,
) : ListModel {
@@ -34,8 +35,12 @@ data class StatsRecord(
@ColorInt
fun getColor(context: Context): Int {
val hue = (manga.id.absoluteValue % 360).toFloat()
val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
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

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.stats.ui
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.databinding.ItemStatsBinding
import org.koitharu.kotatsu.parsers.model.Manga
@@ -14,12 +15,13 @@ fun statsAD(
) {
binding.root.setOnClickListener { v ->
listener.onItemClick(item.manga, v)
listener.onItemClick(item.manga ?: return@setOnClickListener, v)
}
bind {
binding.textViewTitle.text = item.manga.title
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.root.isClickable = item.manga != null
}
}

View File

@@ -4,14 +4,17 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
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.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentStatsBinding
import org.koitharu.kotatsu.databinding.ActivityStatsBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.parsers.model.Manga
@@ -19,28 +22,26 @@ import org.koitharu.kotatsu.stats.domain.StatsRecord
import org.koitharu.kotatsu.stats.ui.views.PieChartView
@AndroidEntryPoint
class StatsFragment : BaseFragment<FragmentStatsBinding>(), OnListItemClickListener<Manga> {
class StatsActivity : BaseActivity<ActivityStatsBinding>(), OnListItemClickListener<Manga> {
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)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityStatsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = BaseListAdapter<StatsRecord>()
.addDelegate(ListItemType.FEED, statsAD(this))
binding.recyclerView.adapter = adapter
viewModel.readingStats.observe(viewLifecycleOwner) {
viewBinding.recyclerView.adapter = adapter
viewModel.readingStats.observe(this) {
val sum = it.sumOf { it.duration }
binding.chart.setData(
viewBinding.chart.setData(
it.map { v ->
PieChartView.Segment(
value = (v.duration / 1000).toInt(),
label = v.manga.title,
label = v.manga?.title ?: getString(R.string.other_manga),
percent = (v.duration.toDouble() / sum).toFloat(),
color = v.getColor(binding.chart.context),
color = v.getColor(this),
)
},
)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.stats.ui.views
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
@@ -9,28 +10,37 @@ 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 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.sqrt
import com.google.android.material.R as materialR
class PieChartView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
) : View(context, attrs, defStyleAttr), GestureDetector.OnGestureListener {
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)
private val touchDetector = GestureDetectorCompat(context, this)
private var hightlightedSegment = -1
var onSegmentClickListener: OnSegmentClickListener? = null
init {
touchDetector.setIsLongpressEnabled(false)
paint.strokeWidth = context.resources.resolveDp(2f)
}
@@ -39,6 +49,9 @@ class PieChartView @JvmOverloads constructor(
var angle = 0f
for ((i, segment) in segments.withIndex()) {
paint.color = segment.color
if (i == hightlightedSegment) {
paint.color = ColorUtils.setAlphaComponent(paint.color, 180)
}
paint.style = Paint.Style.FILL
val sweepAngle = segment.percent * 360f
canvas.drawArc(
@@ -75,15 +88,80 @@ class PieChartView @JvmOverloads constructor(
)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_CANCEL || event.actionMasked == MotionEvent.ACTION_UP) {
hightlightedSegment = -1
invalidate()
}
return super.onTouchEvent(event) || touchDetector.onTouchEvent(event)
}
override fun onDown(e: MotionEvent): Boolean {
val segment = findSegmentIndex(e.x, e.y)
if (segment != hightlightedSegment) {
hightlightedSegment = segment
invalidate()
return true
} else {
return false
}
}
override fun onShowPress(e: MotionEvent) = Unit
override fun onSingleTapUp(e: MotionEvent): Boolean {
onSegmentClickListener?.run {
val segment = segments.getOrNull(findSegmentIndex(e.x, e.y))
if (segment != null) {
onSegmentClick(this@PieChartView, segment)
}
}
return true
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean = false
override fun onLongPress(e: MotionEvent) = Unit
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean = false
fun setData(value: List<Segment>) {
segments.replaceWith(value)
invalidate()
}
private fun findSegmentIndex(x: Float, y: Float): Int {
val dy = (y - chartBounds.centerY()).toDouble()
val dx = (x - chartBounds.centerX()).toDouble()
val distance = sqrt(dx * dx + dy * dy).toFloat()
if (distance < chartBounds.height() / 4f || distance > chartBounds.centerX()) {
return -1
}
var touchAngle = Math.toDegrees(Math.atan2(dy, dx)).toFloat()
if (touchAngle < 0) {
touchAngle += 360
}
var angle = 0f
for ((i, segment) in segments.withIndex()) {
val sweepAngle = segment.percent * 360f
if (touchAngle in angle..(angle + sweepAngle)) {
return i
}
angle += sweepAngle
}
return -1
}
class Segment(
val value: Int,
val label: String,
val percent: Float,
val color: Int,
)
interface OnSegmentClickListener {
fun onSegmentClick(view: PieChartView, segment: Segment)
}
}

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_scrollFlags="noScroll">
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<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_toBottomOf="@id/appbar" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:overScrollMode="ifContentScrolls"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chart"
tools:itemCount="4"
tools:listitem="@layout/item_stats" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,41 +0,0 @@
<?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"
android:background="?android:colorBackground"
android:fillViewport="true">
<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" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="24dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chart"
tools:itemCount="4"
tools:listitem="@layout/item_stats" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View File

@@ -17,6 +17,7 @@
android:id="@+id/imageView_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
app:srcCompat="@drawable/bg_rounded_square" />
<LinearLayout

View File

@@ -9,4 +9,10 @@
android:title="@string/clear_history"
app:showAsAction="never" />
<item
android:id="@+id/action_stats"
android:orderInCategory="40"
android:title="@string/statistics"
app:showAsAction="never" />
</menu>

View File

@@ -605,4 +605,7 @@
<string name="multiple_cbz_files">Multiple CBZ files</string>
<string name="stats_enabled">Enable statistics</string>
<string name="reading_stats">Reading statistics</string>
<string name="other_manga">Other manga</string>
<string name="less_than_minute">Less than a minute</string>
<string name="statistics">Statistics</string>
</resources>

View File

@@ -28,9 +28,9 @@
android:summary="@string/related_manga_summary"
android:title="@string/related_manga" />
<Preference
android:fragment="org.koitharu.kotatsu.settings.StatsSettingsFragment"
android:key="stats"
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="stats_on"
android:title="@string/reading_stats"
app:allowDividerAbove="true" />

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="stats_on"
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>