diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64de70c55..70ec0d3b7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -239,6 +239,9 @@ + resources.getString(R.string.less_than_minute) hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes) minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours) else -> resources.getString( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index dd421f8a4..ba733b064 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -11,7 +11,9 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.NetworkManageIntent import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper +import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver @@ -27,6 +29,7 @@ class HistoryListFragment : MangaListFragment() { super.onViewBindingCreated(binding, savedInstanceState) RecyclerScrollKeeper(binding.recyclerView).attach() addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel)) + viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) } override fun onScrolledToEnd() = Unit diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt index d2a5758f3..ab1a49ccd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.history.ui import android.content.Context +import android.content.Intent import android.view.Menu import android.view.MenuInflater import android.view.MenuItem @@ -9,6 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED +import org.koitharu.kotatsu.stats.ui.StatsActivity import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -24,6 +26,11 @@ class HistoryListMenuProvider( menuInflater.inflate(R.menu.opt_history, menu) } + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.action_stats)?.isVisible = viewModel.isStatsEnabled.value + } + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_clear_history -> { @@ -31,6 +38,11 @@ class HistoryListMenuProvider( true } + R.id.action_stats -> { + context.startActivity(Intent(context, StatsActivity::class.java)) + true + } + else -> false } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 91c192ac4..6a6cf249b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -71,6 +71,12 @@ class HistoryListViewModel @Inject constructor( g && s.isGroupingSupported() } + val isStatsEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_STATS_ENABLED, + valueProducer = { isStatsEnabled }, + ) + override val content = combine( sortOrder.flatMapLatest { repository.observeAllWithHistory(it) }, isGroupingEnabled, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/StatsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/StatsSettingsFragment.kt deleted file mode 100644 index 3f176636d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/StatsSettingsFragment.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.os.Bundle -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment - -@AndroidEntryPoint -class StatsSettingsFragment : BasePreferenceFragment(R.string.reading_stats) { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_stats) - } -} 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 94b5ba86f..c629bbbbe 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 @@ -29,7 +29,7 @@ interface StatsDao { @Query("SELECT IFNULL(SUM(duration), 0) FROM stats") suspend fun getTotalReadingTime(): Long - @Query("SELECT manga_id, SUM(duration) AS d FROM stats GROUP BY manga_id ORDER BY d") + @Query("SELECT manga_id, SUM(duration) AS d FROM stats GROUP BY manga_id ORDER BY d DESC") suspend fun getDurationStats(): Map<@MapColumn("manga_id") Long, @MapColumn("d") Long> @Upsert 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 88f8f55f7..eaaa89b94 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 @@ -4,6 +4,7 @@ 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.StatsRecord +import java.util.concurrent.TimeUnit import javax.inject.Inject class StatsRepository @Inject constructor( @@ -12,14 +13,23 @@ class StatsRepository @Inject constructor( suspend fun getReadingStats(): List = db.withTransaction { val stats = db.getStatsDao().getDurationStats() + val minute = TimeUnit.MINUTES.toMillis(1) val mangaDao = db.getMangaDao() val result = ArrayList(stats.size) + var other = StatsRecord(null, 0) for ((mangaId, duration) in stats) { - val manga = mangaDao.find(mangaId)?.toManga() ?: continue - result += StatsRecord( - manga = manga, - duration = duration, - ) + val manga = mangaDao.find(mangaId)?.toManga() + if (manga == null || duration < minute) { + other = other.copy(duration = other.duration + duration) + } else { + result += StatsRecord( + manga = manga, + duration = duration, + ) + } + } + if (other.duration != 0L) { + result += other } result } 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 172fdc45a..30e81a9e0 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 @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.stats.domain import android.content.Context import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import com.google.android.material.R import com.google.android.material.color.MaterialColors @@ -13,7 +14,7 @@ import java.util.concurrent.TimeUnit import kotlin.math.absoluteValue data class StatsRecord( - val manga: Manga, + val manga: Manga?, val duration: Long, ) : ListModel { @@ -34,8 +35,12 @@ data class StatsRecord( @ColorInt fun getColor(context: Context): Int { - val hue = (manga.id.absoluteValue % 360).toFloat() - val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) + 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 671b12892..d2557820b 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 @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.stats.ui 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.databinding.ItemStatsBinding import org.koitharu.kotatsu.parsers.model.Manga @@ -14,12 +15,13 @@ fun statsAD( ) { binding.root.setOnClickListener { v -> - listener.onItemClick(item.manga, v) + listener.onItemClick(item.manga ?: return@setOnClickListener, v) } bind { - binding.textViewTitle.text = item.manga.title + 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.root.isClickable = item.manga != null } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt similarity index 67% rename from app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsFragment.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt index 34d80dc88..bd95442ef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt @@ -4,14 +4,17 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +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.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.databinding.FragmentStatsBinding +import org.koitharu.kotatsu.databinding.ActivityStatsBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.parsers.model.Manga @@ -19,28 +22,26 @@ import org.koitharu.kotatsu.stats.domain.StatsRecord import org.koitharu.kotatsu.stats.ui.views.PieChartView @AndroidEntryPoint -class StatsFragment : BaseFragment(), OnListItemClickListener { +class StatsActivity : BaseActivity(), OnListItemClickListener { private val viewModel: StatsViewModel by viewModels() - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentStatsBinding { - return FragmentStatsBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: FragmentStatsBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityStatsBinding.inflate(layoutInflater)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) val adapter = BaseListAdapter() .addDelegate(ListItemType.FEED, statsAD(this)) - binding.recyclerView.adapter = adapter - viewModel.readingStats.observe(viewLifecycleOwner) { + viewBinding.recyclerView.adapter = adapter + viewModel.readingStats.observe(this) { val sum = it.sumOf { it.duration } - binding.chart.setData( + viewBinding.chart.setData( it.map { v -> PieChartView.Segment( value = (v.duration / 1000).toInt(), - label = v.manga.title, + label = v.manga?.title ?: getString(R.string.other_manga), percent = (v.duration.toDouble() / sum).toFloat(), - color = v.getColor(binding.chart.context), + color = v.getColor(this), ) }, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt index 4cd1fde54..30fad194a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.stats.ui.views +import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Color @@ -9,28 +10,37 @@ 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 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.sqrt import com.google.android.material.R as materialR class PieChartView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : View(context, attrs, defStyleAttr) { +) : View(context, attrs, defStyleAttr), GestureDetector.OnGestureListener { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val segments = ArrayList() private val chartBounds = RectF() private val clearColor = context.getThemeColor(android.R.attr.colorBackground) + private val touchDetector = GestureDetectorCompat(context, this) + private var hightlightedSegment = -1 + + var onSegmentClickListener: OnSegmentClickListener? = null init { + touchDetector.setIsLongpressEnabled(false) paint.strokeWidth = context.resources.resolveDp(2f) } @@ -39,6 +49,9 @@ class PieChartView @JvmOverloads constructor( var angle = 0f for ((i, segment) in segments.withIndex()) { paint.color = segment.color + if (i == hightlightedSegment) { + paint.color = ColorUtils.setAlphaComponent(paint.color, 180) + } paint.style = Paint.Style.FILL val sweepAngle = segment.percent * 360f canvas.drawArc( @@ -75,15 +88,80 @@ class PieChartView @JvmOverloads constructor( ) } + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_CANCEL || event.actionMasked == MotionEvent.ACTION_UP) { + hightlightedSegment = -1 + invalidate() + } + return super.onTouchEvent(event) || touchDetector.onTouchEvent(event) + } + + override fun onDown(e: MotionEvent): Boolean { + val segment = findSegmentIndex(e.x, e.y) + if (segment != hightlightedSegment) { + hightlightedSegment = segment + invalidate() + return true + } else { + return false + } + } + + override fun onShowPress(e: MotionEvent) = Unit + + override fun onSingleTapUp(e: MotionEvent): Boolean { + onSegmentClickListener?.run { + val segment = segments.getOrNull(findSegmentIndex(e.x, e.y)) + if (segment != null) { + onSegmentClick(this@PieChartView, segment) + } + } + return true + } + + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean = false + + override fun onLongPress(e: MotionEvent) = Unit + + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean = false + fun setData(value: List) { segments.replaceWith(value) invalidate() } + private fun findSegmentIndex(x: Float, y: Float): Int { + val dy = (y - chartBounds.centerY()).toDouble() + val dx = (x - chartBounds.centerX()).toDouble() + val distance = sqrt(dx * dx + dy * dy).toFloat() + if (distance < chartBounds.height() / 4f || distance > chartBounds.centerX()) { + return -1 + } + var touchAngle = Math.toDegrees(Math.atan2(dy, dx)).toFloat() + if (touchAngle < 0) { + touchAngle += 360 + } + var angle = 0f + for ((i, segment) in segments.withIndex()) { + val sweepAngle = segment.percent * 360f + if (touchAngle in angle..(angle + sweepAngle)) { + return i + } + angle += sweepAngle + } + return -1 + } + class Segment( val value: Int, val label: String, val percent: Float, val color: Int, ) + + interface OnSegmentClickListener { + + fun onSegmentClick(view: PieChartView, segment: Segment) + } } diff --git a/app/src/main/res/layout/activity_stats.xml b/app/src/main/res/layout/activity_stats.xml new file mode 100644 index 000000000..b33e94aa3 --- /dev/null +++ b/app/src/main/res/layout/activity_stats.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_stats.xml b/app/src/main/res/layout/fragment_stats.xml deleted file mode 100644 index c3c806baa..000000000 --- a/app/src/main/res/layout/fragment_stats.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_stats.xml b/app/src/main/res/layout/item_stats.xml index eeb73eee4..873cf7334 100644 --- a/app/src/main/res/layout/item_stats.xml +++ b/app/src/main/res/layout/item_stats.xml @@ -17,6 +17,7 @@ android:id="@+id/imageView_badge" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:contentDescription="@null" app:srcCompat="@drawable/bg_rounded_square" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd0f6d687..a2d905ab8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -605,4 +605,7 @@ Multiple CBZ files Enable statistics Reading statistics + Other manga + Less than a minute + Statistics diff --git a/app/src/main/res/xml/pref_services.xml b/app/src/main/res/xml/pref_services.xml index 3c7e98af4..c1d8b8bf6 100644 --- a/app/src/main/res/xml/pref_services.xml +++ b/app/src/main/res/xml/pref_services.xml @@ -28,9 +28,9 @@ android:summary="@string/related_manga_summary" android:title="@string/related_manga" /> - diff --git a/app/src/main/res/xml/pref_stats.xml b/app/src/main/res/xml/pref_stats.xml deleted file mode 100644 index 9f164bc46..000000000 --- a/app/src/main/res/xml/pref_stats.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - -