Statistics filters

This commit is contained in:
Koitharu
2024-03-04 16:31:39 +02:00
parent 876675445d
commit 5d1a2fcf77
9 changed files with 157 additions and 42 deletions

View File

@@ -110,7 +110,7 @@ class ChipsView @JvmOverloads constructor(
chip.isChipIconVisible = false
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
chip.setEnsureMinTouchTargetSize(false) // TODO remove
chip.setOnClickListener(chipOnClickListener)
addView(chip)
return chip

View File

@@ -1,40 +1,69 @@
package org.koitharu.kotatsu.stats.data
import android.database.sqlite.SQLiteQueryBuilder
import androidx.room.Dao
import androidx.room.MapColumn
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.HistoryWithManga
@Dao
interface StatsDao {
abstract class StatsDao {
@Query("SELECT * FROM stats ORDER BY started_at")
suspend fun findAll(): List<StatsEntity>
abstract suspend fun findAll(): List<StatsEntity>
@Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at")
suspend fun findAll(mangaId: Long): List<StatsEntity>
abstract suspend fun findAll(mangaId: Long): List<StatsEntity>
@Query("SELECT IFNULL(SUM(pages),0) FROM stats WHERE manga_id = :mangaId")
suspend fun getReadPagesCount(mangaId: Long): Int
abstract suspend fun getReadPagesCount(mangaId: Long): Int
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats WHERE manga_id = :mangaId")
suspend fun getAverageTimePerPage(mangaId: Long): Long
abstract suspend fun getAverageTimePerPage(mangaId: Long): Long
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats")
suspend fun getAverageTimePerPage(): Long
abstract suspend fun getAverageTimePerPage(): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats WHERE manga_id = :mangaId")
suspend fun getReadingTime(mangaId: Long): Long
abstract suspend fun getReadingTime(mangaId: Long): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats")
suspend fun getTotalReadingTime(): Long
@Query("SELECT manga_id, SUM(duration) AS d FROM stats WHERE started_at >= :fromDate GROUP BY manga_id ORDER BY d DESC")
suspend fun getDurationStats(fromDate: Long): Map<@MapColumn("manga_id") Long, @MapColumn("d") Long>
abstract suspend fun getTotalReadingTime(): Long
@Query("DELETE FROM stats")
suspend fun clear()
abstract suspend fun clear()
@Upsert
suspend fun upsert(entity: StatsEntity)
abstract suspend fun upsert(entity: StatsEntity)
suspend fun getDurationStats(fromDate: Long, isNsfw: Boolean?, favouriteCategories: Set<Long>): Map<MangaEntity, Long> {
val conditions = ArrayList<String>()
conditions.add("stats.started_at >= $fromDate")
if (favouriteCategories.isNotEmpty()) {
val ids = favouriteCategories.joinToString(",")
conditions.add("stats.manga_id IN (SELECT manga_id FROM favourites WHERE category_id IN ($ids))")
}
if (isNsfw != null) {
val flag = if (isNsfw) 1 else 0
conditions.add("manga.nsfw = $flag")
}
val where = conditions.joinToString(separator = " AND ")
val query = SimpleSQLiteQuery(
"SELECT manga.*, SUM(duration) AS d FROM stats LEFT JOIN manga ON manga.manga_id = stats.manga_id WHERE $where GROUP BY manga.manga_id ORDER BY d DESC",
)
return getDurationStatsImpl(query)
}
@RawQuery
protected abstract fun getDurationStatsImpl(
query: SupportSQLiteQuery
): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long>
}

View File

@@ -16,21 +16,20 @@ class StatsRepository @Inject constructor(
private val db: MangaDatabase,
) {
suspend fun getReadingStats(period: StatsPeriod): List<StatsRecord> = db.withTransaction {
suspend fun getReadingStats(period: StatsPeriod, categories: Set<Long>): List<StatsRecord> {
val fromDate = if (period == StatsPeriod.ALL) {
0L
} else {
System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong())
}
val stats = db.getStatsDao().getDurationStats(fromDate)
val mangaDao = db.getMangaDao()
val stats = db.getStatsDao().getDurationStats(fromDate, null, categories)
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()
for ((mangaEntity, duration) in stats) {
val manga = mangaEntity.toManga(emptySet())
val percent = duration.toDouble() / total
if (manga == null || percent < 0.05) {
if (percent < 0.05) {
other = other.copy(duration = other.duration + duration)
} else {
result += StatsRecord(
@@ -42,7 +41,7 @@ class StatsRepository @Inject constructor(
if (other.duration != 0L) {
result += other
}
result
return result
}
suspend fun getTimePerPage(mangaId: Long): Long = db.withTransaction {

View File

@@ -6,6 +6,7 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewStub
import android.widget.CompoundButton
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
@@ -14,9 +15,12 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.AsyncListDiffer
import coil.ImageLoader
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -44,7 +48,7 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
OnListItemClickListener<Manga>,
PieChartView.OnSegmentClickListener,
AsyncListDiffer.ListListener<StatsRecord>,
ViewStub.OnInflateListener {
ViewStub.OnInflateListener, View.OnClickListener, CompoundButton.OnCheckedChangeListener {
@Inject
lateinit var coil: ImageLoader
@@ -61,13 +65,15 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
viewBinding.recyclerView.adapter = adapter
viewBinding.chart.onSegmentClickListener = this
viewBinding.stubEmpty.setOnInflateListener(this)
viewBinding.chipPeriod.setOnClickListener(this)
viewModel.isLoading.observe(this) {
viewBinding.progressBar.showOrHide(it)
}
viewModel.period.observe(this) {
supportActionBar?.setSubtitle(it.titleResId)
viewBinding.chipPeriod.setText(it.titleResId)
}
viewModel.favoriteCategories.observe(this, ::createCategoriesChips)
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView))
viewModel.readingStats.observe(this) {
val sum = it.sumOf { it.duration }
@@ -88,6 +94,17 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onClick(v: View) {
when (v.id) {
R.id.chip_period -> showPeriodSelector()
}
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
val category = buttonView?.tag as? FavouriteCategory ?: return
viewModel.setCategoryChecked(category, isChecked)
}
override fun onItemClick(item: Manga, view: View) {
MangaStatsSheet.show(supportFragmentManager, item)
}
@@ -109,11 +126,6 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
true
}
R.id.action_period -> {
showPeriodSelector()
true
}
else -> super.onOptionsItemSelected(item)
}
}
@@ -135,6 +147,25 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
stubBinding.buttonRetry.isVisible = false
}
private fun createCategoriesChips(categories: List<FavouriteCategory>) {
val container = viewBinding.layoutChips
if (container.childCount > 1) {
// avoid duplication
return
}
val checkedIds = viewModel.selectedCategories.value
for (category in categories) {
val chip = Chip(this)
val drawable = ChipDrawable.createFromAttributes(this, null, 0, R.style.Widget_Kotatsu_Chip_Filter)
chip.setChipDrawable(drawable)
chip.text = category.title
chip.tag = category
chip.isChecked = category.id in checkedIds
chip.setOnCheckedChangeListener(this)
container.addView(chip)
}
}
private fun showClearConfirmDialog() {
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
.setMessage(R.string.clear_stats_confirm)
@@ -147,10 +178,15 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
}
private fun showPeriodSelector() {
val menu = PopupMenu(this, viewBinding.toolbar)
val menu = PopupMenu(this, viewBinding.chipPeriod)
val selected = viewModel.period.value
for ((i, branch) in StatsPeriod.entries.withIndex()) {
menu.menu.add(Menu.NONE, Menu.NONE, i, branch.titleResId)
val item = menu.menu.add(R.id.group_period, Menu.NONE, i, branch.titleResId)
item.isCheckable = true
item.isChecked = selected.ordinal == i
}
menu.menu.setGroupCheckable(R.id.group_period, true, true)
menu.setOnMenuItemClickListener {
StatsPeriod.entries.getOrNull(it.order)?.also {
viewModel.period.value = it

View File

@@ -6,15 +6,20 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.stats.data.StatsRepository
import org.koitharu.kotatsu.stats.domain.StatsPeriod
import org.koitharu.kotatsu.stats.domain.StatsRecord
@@ -23,22 +28,41 @@ import javax.inject.Inject
@HiltViewModel
class StatsViewModel @Inject constructor(
private val repository: StatsRepository,
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val period = MutableStateFlow(StatsPeriod.WEEK)
val onActionDone = MutableEventFlow<ReversibleAction>()
val selectedCategories = MutableStateFlow<Set<Long>>(emptySet())
val favoriteCategories = favouritesRepository.observeCategories()
.take(1)
val readingStats = MutableStateFlow<List<StatsRecord>>(emptyList())
init {
launchJob(Dispatchers.Default) {
period.collectLatest { p ->
combine<StatsPeriod, Set<Long>, Pair<StatsPeriod, Set<Long>>>(
period,
selectedCategories,
::Pair,
).collectLatest { p ->
readingStats.value = withLoading {
repository.getReadingStats(p)
repository.getReadingStats(p.first, p.second)
}
}
}
}
fun setCategoryChecked(category: FavouriteCategory, checked: Boolean) {
val snapshot = selectedCategories.value.toMutableSet()
if (checked) {
snapshot.add(category.id)
} else {
snapshot.remove(category.id)
}
selectedCategories.value = snapshot
}
fun clear() {
launchLoadingJob(Dispatchers.Default) {
repository.clearStats()

View File

@@ -41,6 +41,34 @@
app:trackCornerRadius="0dp"
tools:visibility="visible" />
<HorizontalScrollView
android:id="@+id/scrollView_chips"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar">
<com.google.android.material.chip.ChipGroup
android:id="@+id/layout_chips"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.google.android.material.chip.Chip
android:id="@+id/chip_period"
style="@style/Widget.Kotatsu.Chip.Dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/week"
app:chipIcon="@drawable/ic_history" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
<org.koitharu.kotatsu.stats.ui.views.PieChartView
android:id="@+id/chart"
android:layout_width="0dp"
@@ -49,7 +77,7 @@
app:layout_constraintDimensionRatio="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar" />
app:layout_constraintTop_toBottomOf="@id/scrollView_chips" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
@@ -74,7 +102,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar"
app:layout_constraintTop_toBottomOf="@id/scrollView_chips"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -4,14 +4,6 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<item
android:id="@+id/action_period"
android:icon="@drawable/ic_expand_more"
android:orderInCategory="0"
android:title="@string/chapters"
app:showAsAction="always"
tools:ignore="AlwaysShowAction" />
<item
android:id="@+id/action_clear"
android:title="@string/clear_stats"

View File

@@ -6,6 +6,7 @@
<item name="fast_scroller" type="id" />
<item name="group_branches" type="id" />
<item name="layout_tip" type="id" />
<item name="group_period" type="id" />
<!-- Navigation -->
<item name="nav_history" type="id" />
<item name="nav_favorites" type="id" />

View File

@@ -124,6 +124,12 @@
<style name="Widget.Kotatsu.Chip.Assist" parent="Widget.Material3.Chip.Assist" />
<style name="Widget.Kotatsu.Chip.Dropdown" parent="Widget.Material3.Chip.Assist">
<item name="closeIconVisible">true</item>
<item name="closeIcon">@drawable/ic_expand_more</item>
<item name="chipIconVisible">true</item>
</style>
<style name="Widget.Kotatsu.Button.More" parent="Widget.Material3.Button.TextButton">
<item name="android:minWidth">48dp</item>
</style>