Stats chart for single manga

This commit is contained in:
Koitharu
2024-03-04 14:42:31 +02:00
parent f7a70680bd
commit 876675445d
18 changed files with 186 additions and 88 deletions

View File

@@ -12,12 +12,10 @@ import android.graphics.RectF
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.withClip import androidx.core.graphics.withClip
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.Colors import org.koitharu.kotatsu.core.util.KotatsuColors
import kotlin.math.absoluteValue
class FaviconDrawable( class FaviconDrawable(
context: Context, context: Context,
@@ -45,7 +43,7 @@ class FaviconDrawable(
} }
paint.textAlign = Paint.Align.CENTER paint.textAlign = Paint.Align.CENTER
paint.isFakeBoldText = true paint.isFakeBoldText = true
colorForeground = MaterialColors.harmonize(Colors.random(name), colorBackground) colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
} }
override fun draw(canvas: Canvas) { override fun draw(canvas: Canvas) {

View File

@@ -10,7 +10,7 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
object Colors { object KotatsuColors {
@ColorInt @ColorInt
fun segmentColor(context: Context, @AttrRes resId: Int): Int { fun segmentColor(context: Context, @AttrRes resId: Int): Int {
@@ -28,7 +28,7 @@ object Colors {
} }
@ColorInt @ColorInt
fun of(context: Context, manga: Manga?): Int { fun ofManga(context: Context, manga: Manga?): Int {
val color = if (manga != null) { val color = if (manga != null) {
val hue = (manga.id.absoluteValue % 360).toFloat() val hue = (manga.id.absoluteValue % 360).toFloat()
ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))

View File

@@ -138,7 +138,10 @@ class DetailsActivity :
}, },
), ),
) )
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom)) viewModel.onActionDone.observeEvent(
this,
ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom),
)
viewModel.onShowTip.observeEvent(this) { showTip() } viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) { viewModel.selectedBranch.observe(this) {
@@ -150,6 +153,7 @@ class DetailsActivity :
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator) viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
val menuInvalidator = MenuInvalidator(this) val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator) viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.isStatsEnabled.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator) viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) { viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1 viewBinding.buttonDropdown.isVisible = it.size > 1

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
class DetailsMenuProvider( class DetailsMenuProvider(
private val activity: FragmentActivity, private val activity: FragmentActivity,
@@ -43,6 +44,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsEnabled.value
menu.findItem(R.id.action_favourite).setIcon( menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline, if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
) )
@@ -101,6 +103,12 @@ class DetailsMenuProvider(
} }
} }
R.id.action_stats -> {
viewModel.manga.value?.let {
MangaStatsSheet.show(activity.supportFragmentManager, it)
}
}
R.id.action_scrobbling -> { R.id.action_scrobbling -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null) ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)

View File

@@ -100,6 +100,10 @@ class DetailsViewModel @Inject constructor(
val favouriteCategories = interactor.observeIsFavourite(mangaId) val favouriteCategories = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val isStatsEnabled = settings.observeAsStateFlow(viewModelScope + Dispatchers.Default, AppSettings.KEY_STATS_ENABLED) {
isStatsEnabled
}
val remoteManga = MutableStateFlow<Manga?>(null) val remoteManga = MutableStateFlow<Manga?>(null)
val newChaptersCount = details.flatMapLatest { d -> val newChaptersCount = details.flatMapLatest { d ->

View File

@@ -2,18 +2,11 @@ package org.koitharu.kotatsu.local.ui.info
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import com.google.android.material.color.MaterialColors
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 kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -21,16 +14,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.Colors import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.settings.userdata.StorageUsage
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
@@ -67,7 +57,7 @@ class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>() {
val total = size + available val total = size + available
val segment = SegmentedBarView.Segment( val segment = SegmentedBarView.Segment(
percent = (size.toDouble() / total.toDouble()).toFloat(), percent = (size.toDouble() / total.toDouble()).toFloat(),
color = Colors.segmentColor(view.context, materialR.attr.colorPrimary), color = KotatsuColors.segmentColor(view.context, materialR.attr.colorPrimary),
) )
requireViewBinding().labelUsed.text = view.context.getString( requireViewBinding().labelUsed.text = view.context.getString(
R.string.memory_usage_pattern, R.string.memory_usage_pattern,

View File

@@ -3,20 +3,15 @@ package org.koitharu.kotatsu.settings.userdata
import android.content.Context import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.util.AttributeSet import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder import androidx.preference.PreferenceViewHolder
import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.Colors import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -39,15 +34,15 @@ class StorageUsagePreference @JvmOverloads constructor(
val binding = PreferenceMemoryUsageBinding.bind(holder.itemView) val binding = PreferenceMemoryUsageBinding.bind(holder.itemView)
val storageSegment = SegmentedBarView.Segment( val storageSegment = SegmentedBarView.Segment(
usage?.savedManga?.percent ?: 0f, usage?.savedManga?.percent ?: 0f,
Colors.segmentColor(context, materialR.attr.colorPrimary), KotatsuColors.segmentColor(context, materialR.attr.colorPrimary),
) )
val pagesSegment = SegmentedBarView.Segment( val pagesSegment = SegmentedBarView.Segment(
usage?.pagesCache?.percent ?: 0f, usage?.pagesCache?.percent ?: 0f,
Colors.segmentColor(context, materialR.attr.colorSecondary), KotatsuColors.segmentColor(context, materialR.attr.colorSecondary),
) )
val otherSegment = SegmentedBarView.Segment( val otherSegment = SegmentedBarView.Segment(
usage?.otherCache?.percent ?: 0f, usage?.otherCache?.percent ?: 0f,
Colors.segmentColor(context, materialR.attr.colorTertiary), KotatsuColors.segmentColor(context, materialR.attr.colorTertiary),
) )
with(binding) { with(binding) {

View File

@@ -56,6 +56,10 @@ class StatsRepository @Inject constructor(
time time
} }
suspend fun getTotalPagesRead(mangaId: Long): Int {
return db.getStatsDao().getReadPagesCount(mangaId)
}
suspend fun getMangaTimeline(mangaId: Long): NavigableMap<Long, Int> { suspend fun getMangaTimeline(mangaId: Long): NavigableMap<Long, Int> {
val entities = db.getStatsDao().findAll(mangaId) val entities = db.getStatsDao().findAll(mangaId)
val map = TreeMap<Long, Int>() val map = TreeMap<Long, Int>()

View File

@@ -4,7 +4,7 @@ import android.content.res.ColorStateList
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.Colors import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.databinding.ItemStatsBinding import org.koitharu.kotatsu.databinding.ItemStatsBinding
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.stats.domain.StatsRecord import org.koitharu.kotatsu.stats.domain.StatsRecord
@@ -22,7 +22,7 @@ fun statsAD(
bind { bind {
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.other_manga) binding.textViewTitle.text = item.manga?.title ?: getString(R.string.other_manga)
binding.textViewSummary.text = item.time.format(context.resources) binding.textViewSummary.text = item.time.format(context.resources)
binding.imageViewBadge.imageTintList = ColorStateList.valueOf(Colors.of(context, item.manga)) binding.imageViewBadge.imageTintList = ColorStateList.valueOf(KotatsuColors.ofManga(context, item.manga))
binding.root.isClickable = item.manga != null binding.root.isClickable = item.manga != null
} }
} }

View File

@@ -1,41 +1,29 @@
package org.koitharu.kotatsu.stats.ui package org.koitharu.kotatsu.stats.ui
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.ViewStub import android.view.ViewStub
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
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import coil.ImageLoader import coil.ImageLoader
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.ui.BaseActivity 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.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.Colors import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
@@ -43,7 +31,6 @@ import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.ActivityStatsBinding import org.koitharu.kotatsu.databinding.ActivityStatsBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.stats.domain.StatsPeriod import org.koitharu.kotatsu.stats.domain.StatsPeriod
@@ -90,7 +77,8 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
value = (v.duration / 1000).toInt(), value = (v.duration / 1000).toInt(),
label = v.manga?.title ?: getString(R.string.other_manga), label = v.manga?.title ?: getString(R.string.other_manga),
percent = (v.duration.toDouble() / sum).toFloat(), percent = (v.duration.toDouble() / sum).toFloat(),
color = Colors.of(this, v.manga), color = KotatsuColors.ofManga(this, v.manga),
tag = v.manga,
) )
}, },
) )
@@ -105,9 +93,8 @@ class StatsActivity : BaseActivity<ActivityStatsBinding>(),
} }
override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) { override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) {
Toast.makeText(this, segment.label, Toast.LENGTH_SHORT).apply { val manga = segment.tag as? Manga ?: return
setGravity(Gravity.TOP, 0, view.top + view.height / 2) onItemClick(manga, view)
}.show()
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {

View File

@@ -2,27 +2,28 @@ package org.koitharu.kotatsu.stats.ui.sheet
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.collection.IntList import androidx.collection.IntList
import androidx.collection.LongIntMap
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.Colors import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.stats.ui.views.BarChartView import org.koitharu.kotatsu.stats.ui.views.BarChartView
import java.util.concurrent.TimeUnit
@AndroidEntryPoint @AndroidEntryPoint
class MangaStatsSheet : BaseAdaptiveSheet<SheetStatsMangaBinding>() { class MangaStatsSheet : BaseAdaptiveSheet<SheetStatsMangaBinding>(), View.OnClickListener {
private val viewModel: MangaStatsViewModel by viewModels() private val viewModel: MangaStatsViewModel by viewModels()
@@ -33,11 +34,19 @@ class MangaStatsSheet : BaseAdaptiveSheet<SheetStatsMangaBinding>() {
override fun onViewBindingCreated(binding: SheetStatsMangaBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: SheetStatsMangaBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewTitle.text = viewModel.manga.title binding.textViewTitle.text = viewModel.manga.title
binding.chartView.barColor = Colors.of(binding.root.context, viewModel.manga) binding.chartView.barColor = KotatsuColors.ofManga(binding.root.context, viewModel.manga)
viewModel.stats.observe(viewLifecycleOwner, ::onStatsChanged) viewModel.stats.observe(viewLifecycleOwner, ::onStatsChanged)
viewModel.startDate.observe(viewLifecycleOwner) { viewModel.startDate.observe(viewLifecycleOwner) {
binding.textViewStart.textAndVisible = it?.format(resources) binding.textViewStart.textAndVisible = it?.format(resources)
} }
viewModel.totalPagesRead.observe(viewLifecycleOwner) {
binding.textViewPages.text = getString(R.string.pages_read_s, it.format())
}
binding.buttonOpen.setOnClickListener(this)
}
override fun onClick(v: View) {
startActivity(DetailsActivity.newIntent(v.context, viewModel.manga))
} }
private fun onStatsChanged(stats: IntList) { private fun onStatsChanged(stats: IntList) {

View File

@@ -30,6 +30,7 @@ class MangaStatsViewModel @Inject constructor(
val stats = MutableStateFlow<IntList>(emptyIntList()) val stats = MutableStateFlow<IntList>(emptyIntList())
val startDate = MutableStateFlow<DateTimeAgo?>(null) val startDate = MutableStateFlow<DateTimeAgo?>(null)
val totalPagesRead = MutableStateFlow(0)
init { init {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
@@ -39,7 +40,7 @@ class MangaStatsViewModel @Inject constructor(
stats.value = emptyIntList() stats.value = emptyIntList()
} else { } else {
val startDay = TimeUnit.MILLISECONDS.toDays(timeline.firstKey()) val startDay = TimeUnit.MILLISECONDS.toDays(timeline.firstKey())
val endDay = TimeUnit.MILLISECONDS.toDays(timeline.lastKey()) val endDay = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis())
val res = MutableIntList((endDay - startDay).toInt() + 1) val res = MutableIntList((endDay - startDay).toInt() + 1)
for (day in startDay..endDay) { for (day in startDay..endDay) {
val from = TimeUnit.DAYS.toMillis(day) val from = TimeUnit.DAYS.toMillis(day)
@@ -50,5 +51,8 @@ class MangaStatsViewModel @Inject constructor(
startDate.value = calculateTimeAgo(Instant.ofEpochMilli(timeline.firstKey())) startDate.value = calculateTimeAgo(Instant.ofEpochMilli(timeline.firstKey()))
} }
} }
launchLoadingJob(Dispatchers.Default) {
totalPagesRead.value = repository.getTotalPagesRead(manga.id)
}
} }
} }

View File

@@ -25,9 +25,12 @@ import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.replaceWith import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.parsers.util.toIntUp
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
import kotlin.random.Random
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class BarChartView @JvmOverloads constructor( class BarChartView @JvmOverloads constructor(
@@ -35,10 +38,12 @@ class BarChartView @JvmOverloads constructor(
) : View(context, attrs, defStyleAttr) { ) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rawData = ArrayList<Bar>()
private val bars = ArrayList<Bar>() private val bars = ArrayList<Bar>()
private var maxValue: Int = 0 private var maxValue: Int = 0
private var spacing = context.resources.resolveDp(2f) private val minBarSpacing = context.resources.resolveDp(12f)
private val minSpace = context.resources.resolveDp(20f) private val minSpace = context.resources.resolveDp(20f)
private val barWidth = context.resources.resolveDp(12f)
private val outlineColor = context.getThemeColor(materialR.attr.colorOutline) private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
private val dottedEffect = DashPathEffect( private val dottedEffect = DashPathEffect(
floatArrayOf( floatArrayOf(
@@ -50,7 +55,7 @@ class BarChartView @JvmOverloads constructor(
private val chartBounds = RectF() private val chartBounds = RectF()
@ColorInt @ColorInt
var barColor: Int = Color.MAGENTA var barColor: Int = context.getThemeColor(materialR.attr.colorAccent)
set(value) { set(value) {
field = value field = value
invalidate() invalidate()
@@ -58,6 +63,16 @@ class BarChartView @JvmOverloads constructor(
init { init {
paint.strokeWidth = context.resources.resolveDp(1f) paint.strokeWidth = context.resources.resolveDp(1f)
if (isInEditMode) {
setData(
List(Random.nextInt(20, 60)) {
Bar(
value = Random.nextInt(-20, 400).coerceAtLeast(0),
label = it.toString(),
)
},
)
}
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
@@ -65,7 +80,7 @@ class BarChartView @JvmOverloads constructor(
if (bars.isEmpty() || chartBounds.isEmpty) { if (bars.isEmpty() || chartBounds.isEmpty) {
return return
} }
val barWidth = ((chartBounds.width() + spacing) / bars.size.toFloat() - spacing) val spacing = (chartBounds.width() - (barWidth * bars.size.toFloat())) / (bars.size + 1).toFloat()
// dashed horizontal lines // dashed horizontal lines
paint.color = outlineColor paint.color = outlineColor
paint.style = Paint.Style.STROKE paint.style = Paint.Style.STROKE
@@ -75,19 +90,23 @@ class BarChartView @JvmOverloads constructor(
val y = chartBounds.top + (chartBounds.height() * i / maxValue.toFloat()) val y = chartBounds.top + (chartBounds.height() * i / maxValue.toFloat())
canvas.drawLine(paddingLeft.toFloat(), y, (width - paddingLeft - paddingRight).toFloat(), y, paint) canvas.drawLine(paddingLeft.toFloat(), y, (width - paddingLeft - paddingRight).toFloat(), y, paint)
} }
// bars
paint.style = Paint.Style.FILL
paint.color = barColor
paint.pathEffect = null
for ((i, bar) in bars.withIndex()) {
val h = chartBounds.height() * bar.value / maxValue.toFloat()
val x = i * (barWidth + spacing) + paddingLeft
canvas.drawRect(x, chartBounds.bottom - h, x + barWidth, chartBounds.bottom, paint)
}
// bottom line // bottom line
paint.color = outlineColor paint.color = outlineColor
paint.style = Paint.Style.STROKE paint.style = Paint.Style.STROKE
canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint) canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint)
// bars
paint.style = Paint.Style.FILL
paint.color = barColor
paint.pathEffect = null
val corner = barWidth / 2f
for ((i, bar) in bars.withIndex()) {
if (bar.value == 0) {
continue
}
val h = (chartBounds.height() * bar.value / maxValue.toFloat()).coerceAtLeast(barWidth)
val x = spacing + i * (barWidth + spacing) + paddingLeft
canvas.drawRoundRect(x, chartBounds.bottom - h, x + barWidth, chartBounds.bottom, corner, corner, paint)
}
} }
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
@@ -96,11 +115,25 @@ class BarChartView @JvmOverloads constructor(
} }
fun setData(value: List<Bar>) { fun setData(value: List<Bar>) {
bars.replaceWith(value) rawData.replaceWith(value)
maxValue = if (value.isEmpty()) 0 else value.maxOf { it.value } compressBars()
invalidate() invalidate()
} }
private fun compressBars() {
if (rawData.isEmpty() || width <= 0) {
maxValue = 0
bars.clear()
return
}
var fullWidth = rawData.size * (barWidth + minBarSpacing) + minBarSpacing
val windowSize = (fullWidth / width.toFloat()).toIntUp()
bars.replaceWith(
rawData.chunked(windowSize) { it.average() },
)
maxValue = bars.maxOf { it.value }
}
private fun computeValueStep(): Int { private fun computeValueStep(): Int {
val h = chartBounds.height() val h = chartBounds.height()
var step = 1 var step = 1
@@ -118,6 +151,18 @@ class BarChartView @JvmOverloads constructor(
(width - paddingLeft - paddingRight).toFloat() - inset, (width - paddingLeft - paddingRight).toFloat() - inset,
(height - paddingTop - paddingBottom).toFloat() - inset, (height - paddingTop - paddingBottom).toFloat() - inset,
) )
compressBars()
}
private fun Collection<Bar>.average(): Bar {
return when (size) {
0 -> Bar(0, "")
1 -> first()
else -> Bar(
value = (sumOf { it.value } / size.toFloat()).roundToInt(),
label = "%s - %s".format(first().label, last().label),
)
}
} }
class Bar( class Bar(

View File

@@ -161,6 +161,7 @@ class PieChartView @JvmOverloads constructor(
val label: String, val label: String,
val percent: Float, val percent: Float,
val color: Int, val color: Int,
val tag: Any?,
) )
interface OnSegmentClickListener { interface OnSegmentClickListener {

View File

@@ -14,28 +14,69 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:title="@string/reading_stats" /> app:title="@string/reading_stats" />
<TextView <ScrollView
android:id="@+id/textView_title" android:id="@+id/scrollView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/screen_padding" android:scrollIndicators="top">
android:textAppearance="?textAppearanceTitleMedium"
tools:text="@tools:sample/lorem[4]" />
<org.koitharu.kotatsu.stats.ui.views.BarChartView <LinearLayout
android:id="@+id/chartView" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="240dp" android:orientation="vertical">
android:layout_marginTop="12dp"
android:paddingHorizontal="@dimen/screen_padding" />
<TextView <LinearLayout
android:id="@+id/textView_start" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:gravity="center_vertical"
android:layout_marginTop="2dp" android:orientation="horizontal"
android:paddingHorizontal="@dimen/screen_padding" android:paddingHorizontal="@dimen/screen_padding">
android:textAppearance="?textAppearanceLabelSmall"
tools:text="Week ago" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?textAppearanceTitleMedium"
tools:text="@tools:sample/lorem[4]" />
<ImageButton
android:id="@+id/button_open"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:minWidth="?minTouchTargetSize"
android:minHeight="?minTouchTargetSize"
app:srcCompat="@drawable/ic_open_external" />
</LinearLayout>
<org.koitharu.kotatsu.stats.ui.views.BarChartView
android:id="@+id/chartView"
android:layout_width="match_parent"
android:layout_height="240dp"
android:layout_marginTop="12dp"
android:paddingHorizontal="@dimen/screen_padding" />
<TextView
android:id="@+id/textView_start"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:paddingHorizontal="@dimen/screen_padding"
android:textAppearance="?textAppearanceLabelSmall"
tools:text="Week ago" />
<TextView
android:id="@+id/textView_pages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:paddingHorizontal="@dimen/screen_padding"
android:textAppearance="?textAppearanceBodyMedium"
tools:text="Total pages read: 250" />
</LinearLayout>
</ScrollView>
</LinearLayout> </LinearLayout>

View File

@@ -37,6 +37,12 @@
android:title="@string/tracking" android:title="@string/tracking"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/action_stats"
android:orderInCategory="50"
android:title="@string/statistics"
app:showAsAction="never" />
<item <item
android:id="@+id/action_related" android:id="@+id/action_related"
android:orderInCategory="50" android:orderInCategory="50"

View File

@@ -617,4 +617,5 @@
<string name="day">Day</string> <string name="day">Day</string>
<string name="three_months">Three months</string> <string name="three_months">Three months</string>
<string name="empty_stats_text">There are no statistics for the selected period</string> <string name="empty_stats_text">There are no statistics for the selected period</string>
<string name="pages_read_s">Pages read: %s</string>
</resources> </resources>

View File

@@ -73,6 +73,7 @@ class JsonSerializerTest {
scroll = 24.0f, scroll = 24.0f,
percent = 0.6f, percent = 0.6f,
deletedAt = 0L, deletedAt = 0L,
chaptersCount = 12,
) )
val json = JsonSerializer(entity).toJson() val json = JsonSerializer(entity).toJson()
val result = JsonDeserializer(json).toHistoryEntity() val result = JsonDeserializer(json).toHistoryEntity()