Statistics filters

master
Koitharu 2 years ago
parent 876675445d
commit 5d1a2fcf77
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

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

@ -1,40 +1,69 @@
package org.koitharu.kotatsu.stats.data package org.koitharu.kotatsu.stats.data
import android.database.sqlite.SQLiteQueryBuilder
import androidx.room.Dao import androidx.room.Dao
import androidx.room.MapColumn import androidx.room.MapColumn
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert 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 @Dao
interface StatsDao { abstract class StatsDao {
@Query("SELECT * FROM stats ORDER BY started_at") @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") @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") @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") @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") @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") @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") @Query("SELECT IFNULL(SUM(duration), 0) FROM stats")
suspend fun getTotalReadingTime(): Long abstract 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>
@Query("DELETE FROM stats") @Query("DELETE FROM stats")
suspend fun clear() abstract suspend fun clear()
@Upsert @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, 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) { val fromDate = if (period == StatsPeriod.ALL) {
0L 0L
} else { } else {
System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong()) System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong())
} }
val stats = db.getStatsDao().getDurationStats(fromDate) val stats = db.getStatsDao().getDurationStats(fromDate, null, categories)
val mangaDao = db.getMangaDao()
val result = ArrayList<StatsRecord>(stats.size) val result = ArrayList<StatsRecord>(stats.size)
var other = StatsRecord(null, 0) var other = StatsRecord(null, 0)
val total = stats.values.sum() val total = stats.values.sum()
for ((mangaId, duration) in stats) { for ((mangaEntity, duration) in stats) {
val manga = mangaDao.find(mangaId)?.toManga() val manga = mangaEntity.toManga(emptySet())
val percent = duration.toDouble() / total val percent = duration.toDouble() / total
if (manga == null || percent < 0.05) { if (percent < 0.05) {
other = other.copy(duration = other.duration + duration) other = other.copy(duration = other.duration + duration)
} else { } else {
result += StatsRecord( result += StatsRecord(
@ -42,7 +41,7 @@ class StatsRepository @Inject constructor(
if (other.duration != 0L) { if (other.duration != 0L) {
result += other result += other
} }
result return result
} }
suspend fun getTimePerPage(mangaId: Long): Long = db.withTransaction { suspend fun getTimePerPage(mangaId: Long): Long = db.withTransaction {

@ -6,6 +6,7 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewStub import android.view.ViewStub
import android.widget.CompoundButton
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
@ -14,9 +15,12 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import coil.ImageLoader 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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R 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.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@ -44,7 +48,7 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
OnListItemClickListener<Manga>, OnListItemClickListener<Manga>,
PieChartView.OnSegmentClickListener, PieChartView.OnSegmentClickListener,
AsyncListDiffer.ListListener<StatsRecord>, AsyncListDiffer.ListListener<StatsRecord>,
ViewStub.OnInflateListener { ViewStub.OnInflateListener, View.OnClickListener, CompoundButton.OnCheckedChangeListener {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@ -61,13 +65,15 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
viewBinding.recyclerView.adapter = adapter viewBinding.recyclerView.adapter = adapter
viewBinding.chart.onSegmentClickListener = this viewBinding.chart.onSegmentClickListener = this
viewBinding.stubEmpty.setOnInflateListener(this) viewBinding.stubEmpty.setOnInflateListener(this)
viewBinding.chipPeriod.setOnClickListener(this)
viewModel.isLoading.observe(this) { viewModel.isLoading.observe(this) {
viewBinding.progressBar.showOrHide(it) viewBinding.progressBar.showOrHide(it)
} }
viewModel.period.observe(this) { 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.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView))
viewModel.readingStats.observe(this) { viewModel.readingStats.observe(this) {
val sum = it.sumOf { it.duration } val sum = it.sumOf { it.duration }
@ -88,6 +94,17 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
override fun onWindowInsetsChanged(insets: Insets) = Unit 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) { override fun onItemClick(item: Manga, view: View) {
MangaStatsSheet.show(supportFragmentManager, item) MangaStatsSheet.show(supportFragmentManager, item)
} }
@ -109,11 +126,6 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
true true
} }
R.id.action_period -> {
showPeriodSelector()
true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
@ -135,6 +147,25 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
stubBinding.buttonRetry.isVisible = false 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() { private fun showClearConfirmDialog() {
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED) MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
.setMessage(R.string.clear_stats_confirm) .setMessage(R.string.clear_stats_confirm)
@ -147,10 +178,15 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
} }
private fun showPeriodSelector() { 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()) { 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 { menu.setOnMenuItemClickListener {
StatsPeriod.entries.getOrNull(it.order)?.also { StatsPeriod.entries.getOrNull(it.order)?.also {
viewModel.period.value = it viewModel.period.value = it

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

@ -41,6 +41,34 @@
app:trackCornerRadius="0dp" app:trackCornerRadius="0dp"
tools:visibility="visible" /> 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 <org.koitharu.kotatsu.stats.ui.views.PieChartView
android:id="@+id/chart" android:id="@+id/chart"
android:layout_width="0dp" android:layout_width="0dp"
@ -49,7 +77,7 @@
app:layout_constraintDimensionRatio="1" app:layout_constraintDimensionRatio="1"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar" /> app:layout_constraintTop_toBottomOf="@id/scrollView_chips" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
@ -74,7 +102,7 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar" app:layout_constraintTop_toBottomOf="@id/scrollView_chips"
tools:visibility="visible" /> tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

@ -4,14 +4,6 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> 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 <item
android:id="@+id/action_clear" android:id="@+id/action_clear"
android:title="@string/clear_stats" android:title="@string/clear_stats"

@ -6,6 +6,7 @@
<item name="fast_scroller" type="id" /> <item name="fast_scroller" type="id" />
<item name="group_branches" type="id" /> <item name="group_branches" type="id" />
<item name="layout_tip" type="id" /> <item name="layout_tip" type="id" />
<item name="group_period" type="id" />
<!-- Navigation --> <!-- Navigation -->
<item name="nav_history" type="id" /> <item name="nav_history" type="id" />
<item name="nav_favorites" 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.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"> <style name="Widget.Kotatsu.Button.More" parent="Widget.Material3.Button.TextButton">
<item name="android:minWidth">48dp</item> <item name="android:minWidth">48dp</item>
</style> </style>

Loading…
Cancel
Save