diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Colors.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Colors.kt index 3ad46e935..e60bb74d4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Colors.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Colors.kt @@ -7,6 +7,7 @@ import androidx.core.graphics.ColorUtils import com.google.android.material.R import com.google.android.material.color.MaterialColors import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.parsers.model.Manga import kotlin.math.absoluteValue object Colors { @@ -20,11 +21,24 @@ object Colors { return MaterialColors.harmonize(color, backgroundColor) } + @ColorInt fun random(seed: Any): Int { val hue = (seed.hashCode() % 360).absoluteValue.toFloat() return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) } + @ColorInt + fun of(context: Context, manga: Manga?): Int { + val color = if (manga != null) { + val hue = (manga.id.absoluteValue % 360).toFloat() + ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) + } else { + context.getThemeColor(R.attr.colorSurface) + } + val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh) + return MaterialColors.harmonize(color, backgroundColor) + } + private fun getHue(hex: String): Float { val r = (hex.substring(0, 2).toInt(16)).toFloat() val g = (hex.substring(2, 4).toInt(16)).toFloat() 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 696faf6f0..a968c41f3 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 @@ -1,10 +1,14 @@ package org.koitharu.kotatsu.stats.data +import androidx.collection.LongIntMap +import androidx.collection.MutableLongIntMap import androidx.room.withTransaction import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.stats.domain.StatsPeriod import org.koitharu.kotatsu.stats.domain.StatsRecord +import java.util.NavigableMap +import java.util.TreeMap import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -19,13 +23,14 @@ class StatsRepository @Inject constructor( System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong()) } val stats = db.getStatsDao().getDurationStats(fromDate) - val minute = TimeUnit.MINUTES.toMillis(1) val mangaDao = db.getMangaDao() 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() - if (manga == null || duration < minute) { + val percent = duration.toDouble() / total + if (manga == null || percent < 0.05) { other = other.copy(duration = other.duration + duration) } else { result += StatsRecord( @@ -51,6 +56,15 @@ class StatsRepository @Inject constructor( time } + suspend fun getMangaTimeline(mangaId: Long): NavigableMap { + val entities = db.getStatsDao().findAll(mangaId) + val map = TreeMap() + for (e in entities) { + map[e.startedAt] = e.pages + } + return map + } + suspend fun clearStats() { db.getStatsDao().clear() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt index 30e81a9e0..190f8efe1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt @@ -32,16 +32,4 @@ data class StatsRecord( isContinue = false, ) } - - @ColorInt - fun getColor(context: Context): Int { - val color = if (manga != null) { - val hue = (manga.id.absoluteValue % 360).toFloat() - ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) - } else { - context.getThemeColor(R.attr.colorSurface) - } - val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh) - return MaterialColors.harmonize(color, backgroundColor) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsAD.kt index d2557820b..2ad6fd752 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsAD.kt @@ -4,6 +4,7 @@ import android.content.res.ColorStateList import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.Colors import org.koitharu.kotatsu.databinding.ItemStatsBinding import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.stats.domain.StatsRecord @@ -21,7 +22,7 @@ fun statsAD( bind { binding.textViewTitle.text = item.manga?.title ?: getString(R.string.other_manga) binding.textViewSummary.text = item.time.format(context.resources) - binding.imageViewBadge.imageTintList = ColorStateList.valueOf(item.getColor(context)) + binding.imageViewBadge.imageTintList = ColorStateList.valueOf(Colors.of(context, item.manga)) binding.root.isClickable = item.manga != null } } 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 967f6f7ca..1c5ac57c5 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 @@ -32,6 +32,7 @@ import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver +import org.koitharu.kotatsu.core.util.Colors 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.getThemeColor @@ -47,6 +48,7 @@ import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.stats.domain.StatsPeriod import org.koitharu.kotatsu.stats.domain.StatsRecord +import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import org.koitharu.kotatsu.stats.ui.views.PieChartView import javax.inject.Inject @@ -88,7 +90,7 @@ class StatsActivity : BaseActivity(), value = (v.duration / 1000).toInt(), label = v.manga?.title ?: getString(R.string.other_manga), percent = (v.duration.toDouble() / sum).toFloat(), - color = v.getColor(this), + color = Colors.of(this, v.manga), ) }, ) @@ -99,7 +101,7 @@ class StatsActivity : BaseActivity(), override fun onWindowInsetsChanged(insets: Insets) = Unit override fun onItemClick(item: Manga, view: View) { - startActivity(DetailsActivity.newIntent(view.context, item)) + MangaStatsSheet.show(supportFragmentManager, item) } override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt new file mode 100644 index 000000000..3cdf8a700 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt @@ -0,0 +1,73 @@ +package org.koitharu.kotatsu.stats.ui.sheet + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.collection.IntList +import androidx.collection.LongIntMap +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.util.Colors +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.showDistinct +import org.koitharu.kotatsu.core.util.ext.textAndVisible +import org.koitharu.kotatsu.core.util.ext.withArgs +import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.stats.ui.views.BarChartView +import java.util.concurrent.TimeUnit + +@AndroidEntryPoint +class MangaStatsSheet : BaseAdaptiveSheet() { + + private val viewModel: MangaStatsViewModel by viewModels() + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetStatsMangaBinding { + return SheetStatsMangaBinding.inflate(inflater, container, false) + } + + override fun onViewBindingCreated(binding: SheetStatsMangaBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + binding.textViewTitle.text = viewModel.manga.title + binding.chartView.barColor = Colors.of(binding.root.context, viewModel.manga) + viewModel.stats.observe(viewLifecycleOwner, ::onStatsChanged) + viewModel.startDate.observe(viewLifecycleOwner) { + binding.textViewStart.textAndVisible = it?.format(resources) + } + } + + private fun onStatsChanged(stats: IntList) { + val chartView = viewBinding?.chartView ?: return + if (stats.isEmpty()) { + chartView.setData(emptyList()) + return + } + val bars = ArrayList(stats.size) + stats.forEach { pages -> + bars.add( + BarChartView.Bar( + value = pages, + label = pages.toString(), + ), + ) + } + chartView.setData(bars) + } + + companion object { + + const val ARG_MANGA = "manga" + + private const val TAG = "MangaStatsSheet" + + fun show(fm: FragmentManager, manga: Manga) { + MangaStatsSheet().withArgs(1) { + putParcelable(ARG_MANGA, ParcelableManga(manga)) + }.showDistinct(fm, TAG) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt new file mode 100644 index 000000000..8272ef096 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt @@ -0,0 +1,54 @@ +package org.koitharu.kotatsu.stats.ui.sheet + +import androidx.collection.IntList +import androidx.collection.LongIntMap +import androidx.collection.MutableIntList +import androidx.collection.emptyIntList +import androidx.collection.emptyLongIntMap +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.model.DateTimeAgo +import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.stats.data.StatsRepository +import org.koitharu.kotatsu.stats.domain.StatsRecord +import java.time.Instant +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltViewModel +class MangaStatsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val repository: StatsRepository, +) : BaseViewModel() { + + val manga = savedStateHandle.require(MangaStatsSheet.ARG_MANGA).manga + + val stats = MutableStateFlow(emptyIntList()) + val startDate = MutableStateFlow(null) + + init { + launchLoadingJob(Dispatchers.Default) { + val timeline = repository.getMangaTimeline(manga.id) + if (timeline.isEmpty()) { + startDate.value = null + stats.value = emptyIntList() + } else { + val startDay = TimeUnit.MILLISECONDS.toDays(timeline.firstKey()) + val endDay = TimeUnit.MILLISECONDS.toDays(timeline.lastKey()) + val res = MutableIntList((endDay - startDay).toInt() + 1) + for (day in startDay..endDay) { + val from = TimeUnit.DAYS.toMillis(day) + val to = TimeUnit.DAYS.toMillis(day + 1) + res.add(timeline.subMap(from, true, to, false).values.sum()) + } + stats.value = res + startDate.value = calculateTimeAgo(Instant.ofEpochMilli(timeline.firstKey())) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/BarChartView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/BarChartView.kt new file mode 100644 index 000000000..9c48ec934 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/BarChartView.kt @@ -0,0 +1,127 @@ +package org.koitharu.kotatsu.stats.ui.views + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.PathEffect +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.graphics.Xfermode +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import androidx.annotation.ColorInt +import androidx.collection.MutableIntList +import androidx.core.graphics.ColorUtils +import androidx.core.graphics.minus +import androidx.core.view.GestureDetectorCompat +import androidx.core.view.setPadding +import com.google.android.material.color.MaterialColors +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.resolveDp +import org.koitharu.kotatsu.parsers.util.replaceWith +import kotlin.math.absoluteValue +import kotlin.math.max +import kotlin.math.sqrt +import com.google.android.material.R as materialR + +class BarChartView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val bars = ArrayList() + private var maxValue: Int = 0 + private var spacing = context.resources.resolveDp(2f) + private val minSpace = context.resources.resolveDp(20f) + private val outlineColor = context.getThemeColor(materialR.attr.colorOutline) + private val dottedEffect = DashPathEffect( + floatArrayOf( + context.resources.resolveDp(6f), + context.resources.resolveDp(6f), + ), + 0f, + ) + private val chartBounds = RectF() + + @ColorInt + var barColor: Int = Color.MAGENTA + set(value) { + field = value + invalidate() + } + + init { + paint.strokeWidth = context.resources.resolveDp(1f) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (bars.isEmpty() || chartBounds.isEmpty) { + return + } + val barWidth = ((chartBounds.width() + spacing) / bars.size.toFloat() - spacing) + // dashed horizontal lines + paint.color = outlineColor + paint.style = Paint.Style.STROKE + canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint) + paint.pathEffect = dottedEffect + for (i in (0..maxValue).step(computeValueStep())) { + val y = chartBounds.top + (chartBounds.height() * i / maxValue.toFloat()) + 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 + paint.color = outlineColor + paint.style = Paint.Style.STROKE + canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + invalidateBounds() + } + + fun setData(value: List) { + bars.replaceWith(value) + maxValue = if (value.isEmpty()) 0 else value.maxOf { it.value } + invalidate() + } + + private fun computeValueStep(): Int { + val h = chartBounds.height() + var step = 1 + while (h / (maxValue / step).toFloat() <= minSpace) { + step++ + } + return step + } + + private fun invalidateBounds() { + val inset = paint.strokeWidth + chartBounds.set( + paddingLeft.toFloat() + inset, + paddingTop.toFloat() + inset, + (width - paddingLeft - paddingRight).toFloat() - inset, + (height - paddingTop - paddingBottom).toFloat() - inset, + ) + } + + class Bar( + val value: Int, + val label: String, + ) +} diff --git a/app/src/main/res/layout/sheet_shelf_size.xml b/app/src/main/res/layout/sheet_shelf_size.xml deleted file mode 100644 index 9d6f52186..000000000 --- a/app/src/main/res/layout/sheet_shelf_size.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/sheet_stats_manga.xml b/app/src/main/res/layout/sheet_stats_manga.xml new file mode 100644 index 000000000..13088406f --- /dev/null +++ b/app/src/main/res/layout/sheet_stats_manga.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + +