diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index 812e56285..f2dc11325 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt index c9638d38d..5e144a769 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt @@ -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 + abstract suspend fun findAll(): List @Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at") - suspend fun findAll(mangaId: Long): List + abstract suspend fun findAll(mangaId: Long): List @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): Map { + val conditions = ArrayList() + 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> } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt index b2737ff31..70bb61f81 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt @@ -16,21 +16,20 @@ class StatsRepository @Inject constructor( private val db: MangaDatabase, ) { - suspend fun getReadingStats(period: StatsPeriod): List = db.withTransaction { + suspend fun getReadingStats(period: StatsPeriod, categories: Set): List { 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(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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt index ddb4f8d5f..4a2b3d271 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt @@ -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(), OnListItemClickListener, PieChartView.OnSegmentClickListener, AsyncListDiffer.ListListener, - ViewStub.OnInflateListener { + ViewStub.OnInflateListener, View.OnClickListener, CompoundButton.OnCheckedChangeListener { @Inject lateinit var coil: ImageLoader @@ -61,13 +65,15 @@ class StatsActivity : BaseActivity(), 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(), 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(), true } - R.id.action_period -> { - showPeriodSelector() - true - } - else -> super.onOptionsItemSelected(item) } } @@ -135,6 +147,25 @@ class StatsActivity : BaseActivity(), stubBinding.buttonRetry.isVisible = false } + private fun createCategoriesChips(categories: List) { + 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(), } 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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt index c7abf27be..541b3449d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt @@ -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() + val selectedCategories = MutableStateFlow>(emptySet()) + val favoriteCategories = favouritesRepository.observeCategories() + .take(1) + val readingStats = MutableStateFlow>(emptyList()) init { launchJob(Dispatchers.Default) { - period.collectLatest { p -> + combine, Pair>>( + 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() diff --git a/app/src/main/res/layout/activity_stats.xml b/app/src/main/res/layout/activity_stats.xml index 2fbf80a3f..2a2c97c90 100644 --- a/app/src/main/res/layout/activity_stats.xml +++ b/app/src/main/res/layout/activity_stats.xml @@ -41,6 +41,34 @@ app:trackCornerRadius="0dp" tools:visibility="visible" /> + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/scrollView_chips" /> diff --git a/app/src/main/res/menu/opt_stats.xml b/app/src/main/res/menu/opt_stats.xml index 8fba95597..4072c40b4 100644 --- a/app/src/main/res/menu/opt_stats.xml +++ b/app/src/main/res/menu/opt_stats.xml @@ -4,14 +4,6 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> - - + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 74e229471..8ca3c1769 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -124,6 +124,12 @@ +