Statistics filters
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user