Forráskód Böngészése

Implement simple stats screen (#8068)

* Implement simple stats screen

* Review Changes

* Some other changes

* Remove unused

* Small changes

* Review Changes 2 + Cleanup

* Review Changes 3

* Cleanup leftovers

* Optimize imports
AntsyLich 2 éve
szülő
commit
3d7591feca
26 módosított fájl, 695 hozzáadás és 14 törlés
  1. 38 4
      app/src/main/java/eu/kanade/core/util/CollectionUtils.kt
  2. 16 0
      app/src/main/java/eu/kanade/core/util/DurationUtils.kt
  3. 4 0
      app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt
  4. 2 0
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  5. 12 0
      app/src/main/java/eu/kanade/domain/history/interactor/GetTotalReadDuration.kt
  6. 2 0
      app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt
  7. 1 1
      app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt
  8. 1 1
      app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt
  9. 1 1
      app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt
  10. 9 0
      app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt
  11. 159 0
      app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenContent.kt
  12. 17 0
      app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenState.kt
  13. 85 0
      app/src/main/java/eu/kanade/presentation/more/stats/components/StatsItem.kt
  14. 38 0
      app/src/main/java/eu/kanade/presentation/more/stats/components/StatsSection.kt
  15. 28 0
      app/src/main/java/eu/kanade/presentation/more/stats/data/StatsData.kt
  16. 13 0
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt
  17. 7 0
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt
  18. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt
  19. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt
  20. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt
  21. 2 0
      app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreScreen.kt
  22. 13 0
      app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsController.kt
  23. 52 0
      app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreen.kt
  24. 152 0
      app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreenModel.kt
  25. 5 1
      app/src/main/sqldelight/data/history.sq
  26. 24 4
      i18n/src/main/res/values/strings.xml

+ 38 - 4
app/src/main/java/eu/kanade/core/util/CollectionUtils.kt

@@ -44,7 +44,6 @@ fun <E> HashSet<E>.addOrRemove(value: E, shouldAdd: Boolean) {
  * access in an efficient way, and this method may actually be a lot slower. Only use for
  * collections that are created by code we control and are known to support random access.
  */
-@Suppress("BanInlineOptIn")
 @OptIn(ExperimentalContracts::class)
 inline fun <T> List<T>.fastFilter(predicate: (T) -> Boolean): List<T> {
     contract { callsInPlace(predicate) }
@@ -60,7 +59,6 @@ inline fun <T> List<T>.fastFilter(predicate: (T) -> Boolean): List<T> {
  * access in an efficient way, and this method may actually be a lot slower. Only use for
  * collections that are created by code we control and are known to support random access.
  */
-@Suppress("BanInlineOptIn")
 @OptIn(ExperimentalContracts::class)
 inline fun <T> List<T>.fastFilterNot(predicate: (T) -> Boolean): List<T> {
     contract { callsInPlace(predicate) }
@@ -77,7 +75,6 @@ inline fun <T> List<T>.fastFilterNot(predicate: (T) -> Boolean): List<T> {
  * access in an efficient way, and this method may actually be a lot slower. Only use for
  * collections that are created by code we control and are known to support random access.
  */
-@Suppress("BanInlineOptIn")
 @OptIn(ExperimentalContracts::class)
 inline fun <T, R> List<T>.fastMapNotNull(transform: (T) -> R?): List<R> {
     contract { callsInPlace(transform) }
@@ -97,7 +94,6 @@ inline fun <T, R> List<T>.fastMapNotNull(transform: (T) -> R?): List<R> {
  * access in an efficient way, and this method may actually be a lot slower. Only use for
  * collections that are created by code we control and are known to support random access.
  */
-@Suppress("BanInlineOptIn")
 @OptIn(ExperimentalContracts::class)
 inline fun <T> List<T>.fastPartition(predicate: (T) -> Boolean): Pair<List<T>, List<T>> {
     contract { callsInPlace(predicate) }
@@ -112,3 +108,41 @@ inline fun <T> List<T>.fastPartition(predicate: (T) -> Boolean): Pair<List<T>, L
     }
     return Pair(first, second)
 }
+
+/**
+ * Returns the number of entries not matching the given [predicate].
+ *
+ * **Do not use for collections that come from public APIs**, since they may not support random
+ * access in an efficient way, and this method may actually be a lot slower. Only use for
+ * collections that are created by code we control and are known to support random access.
+ */
+@OptIn(ExperimentalContracts::class)
+inline fun <T> List<T>.fastCountNot(predicate: (T) -> Boolean): Int {
+    contract { callsInPlace(predicate) }
+    var count = size
+    fastForEach { if (predicate(it)) --count }
+    return count
+}
+
+/**
+ * Returns a list containing only elements from the given collection
+ * having distinct keys returned by the given [selector] function.
+ *
+ * Among elements of the given collection with equal keys, only the first one will be present in the resulting list.
+ * The elements in the resulting list are in the same order as they were in the source collection.
+ *
+ * **Do not use for collections that come from public APIs**, since they may not support random
+ * access in an efficient way, and this method may actually be a lot slower. Only use for
+ * collections that are created by code we control and are known to support random access.
+ */
+@OptIn(ExperimentalContracts::class)
+inline fun <T, K> List<T>.fastDistinctBy(selector: (T) -> K): List<T> {
+    contract { callsInPlace(selector) }
+    val set = HashSet<K>()
+    val list = ArrayList<T>()
+    fastForEach {
+        val key = selector(it)
+        if (set.add(key)) list.add(it)
+    }
+    return list
+}

+ 16 - 0
app/src/main/java/eu/kanade/core/util/DurationUtils.kt

@@ -0,0 +1,16 @@
+package eu.kanade.core.util
+
+import android.content.Context
+import eu.kanade.tachiyomi.R
+import kotlin.time.Duration
+
+fun Duration.toDurationString(context: Context, fallback: String): String {
+    return toComponents { days, hours, minutes, seconds, _ ->
+        buildList(4) {
+            if (days != 0L) add(context.getString(R.string.day_short, days))
+            if (hours != 0) add(context.getString(R.string.hour_short, hours))
+            if (minutes != 0 && (days == 0L || hours == 0)) add(context.getString(R.string.minute_short, minutes))
+            if (seconds != 0 && days == 0L && hours == 0) add(context.getString(R.string.seconds_short, seconds))
+        }.joinToString(" ").ifBlank { fallback }
+    }
+}

+ 4 - 0
app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt

@@ -24,6 +24,10 @@ class HistoryRepositoryImpl(
         }
     }
 
+    override suspend fun getTotalReadDuration(): Long {
+        return handler.awaitOne { historyQueries.getReadDuration() }
+    }
+
     override suspend fun resetHistory(historyId: Long) {
         try {
             handler.await { historyQueries.resetHistoryById(historyId) }

+ 2 - 0
app/src/main/java/eu/kanade/domain/DomainModule.kt

@@ -34,6 +34,7 @@ import eu.kanade.domain.extension.interactor.GetExtensionSources
 import eu.kanade.domain.extension.interactor.GetExtensionsByType
 import eu.kanade.domain.history.interactor.GetHistory
 import eu.kanade.domain.history.interactor.GetNextChapters
+import eu.kanade.domain.history.interactor.GetTotalReadDuration
 import eu.kanade.domain.history.interactor.RemoveHistory
 import eu.kanade.domain.history.interactor.UpsertHistory
 import eu.kanade.domain.history.repository.HistoryRepository
@@ -120,6 +121,7 @@ class DomainModule : InjektModule {
         addFactory { GetHistory(get()) }
         addFactory { UpsertHistory(get()) }
         addFactory { RemoveHistory(get()) }
+        addFactory { GetTotalReadDuration(get()) }
 
         addFactory { DeleteDownload(get(), get()) }
 

+ 12 - 0
app/src/main/java/eu/kanade/domain/history/interactor/GetTotalReadDuration.kt

@@ -0,0 +1,12 @@
+package eu.kanade.domain.history.interactor
+
+import eu.kanade.domain.history.repository.HistoryRepository
+
+class GetTotalReadDuration(
+    private val repository: HistoryRepository,
+) {
+
+    suspend fun await(): Long {
+        return repository.getTotalReadDuration()
+    }
+}

+ 2 - 0
app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt

@@ -10,6 +10,8 @@ interface HistoryRepository {
 
     suspend fun getLastHistory(): HistoryWithRelations?
 
+    suspend fun getTotalReadDuration(): Long
+
     suspend fun resetHistory(historyId: Long)
 
     suspend fun resetHistoryByMangaId(mangaId: Long)

+ 1 - 1
app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt

@@ -31,7 +31,7 @@ fun LanguageBadge(
 ) {
     if (isLocal) {
         Badge(
-            text = stringResource(R.string.local_source_badge),
+            text = stringResource(R.string.label_local),
             color = MaterialTheme.colorScheme.tertiary,
             textColor = MaterialTheme.colorScheme.onTertiary,
         )

+ 1 - 1
app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt

@@ -292,7 +292,7 @@ private fun FilterPage(
             .verticalScroll(rememberScrollState()),
     ) {
         FilterPageItem(
-            label = stringResource(R.string.action_filter_downloaded),
+            label = stringResource(R.string.label_downloaded),
             state = downloadFilter,
             onClick = onDownloadFilterChanged,
         )

+ 1 - 1
app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt

@@ -270,7 +270,7 @@ private fun SearchResultItem(
                     }
                     if (startDate.isNotBlank()) {
                         SearchResultItemDetails(
-                            title = stringResource(R.string.track_start_date),
+                            title = stringResource(R.string.label_started),
                             text = startDate,
                         )
                     }

+ 9 - 0
app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt

@@ -11,6 +11,7 @@ import androidx.compose.material.icons.outlined.GetApp
 import androidx.compose.material.icons.outlined.HelpOutline
 import androidx.compose.material.icons.outlined.Info
 import androidx.compose.material.icons.outlined.Label
+import androidx.compose.material.icons.outlined.QueryStats
 import androidx.compose.material.icons.outlined.Settings
 import androidx.compose.material.icons.outlined.SettingsBackupRestore
 import androidx.compose.runtime.Composable
@@ -41,6 +42,7 @@ fun MoreScreen(
     isFDroid: Boolean,
     onClickDownloadQueue: () -> Unit,
     onClickCategories: () -> Unit,
+    onClickStats: () -> Unit,
     onClickBackupAndRestore: () -> Unit,
     onClickSettings: () -> Unit,
     onClickAbout: () -> Unit,
@@ -132,6 +134,13 @@ fun MoreScreen(
                 onPreferenceClick = onClickCategories,
             )
         }
+        item {
+            TextPreferenceWidget(
+                title = stringResource(R.string.label_stats),
+                icon = Icons.Outlined.QueryStats,
+                onPreferenceClick = onClickStats,
+            )
+        }
         item {
             TextPreferenceWidget(
                 title = stringResource(R.string.label_backup),

+ 159 - 0
app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenContent.kt

@@ -0,0 +1,159 @@
+package eu.kanade.presentation.more.stats
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CollectionsBookmark
+import androidx.compose.material.icons.outlined.LocalLibrary
+import androidx.compose.material.icons.outlined.Schedule
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import eu.kanade.core.util.toDurationString
+import eu.kanade.presentation.components.LazyColumn
+import eu.kanade.presentation.more.stats.components.StatsItem
+import eu.kanade.presentation.more.stats.components.StatsOverviewItem
+import eu.kanade.presentation.more.stats.components.StatsSection
+import eu.kanade.presentation.more.stats.data.StatsData
+import eu.kanade.presentation.util.padding
+import eu.kanade.tachiyomi.R
+import java.util.Locale
+import kotlin.time.DurationUnit
+import kotlin.time.toDuration
+
+@Composable
+fun StatsScreenContent(
+    state: StatsScreenState.Success,
+    paddingValues: PaddingValues,
+) {
+    val statListState = rememberLazyListState()
+    LazyColumn(
+        state = statListState,
+        contentPadding = paddingValues,
+        verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
+    ) {
+        item {
+            OverviewSection(state.overview)
+        }
+        item {
+            TitlesStats(state.titles)
+        }
+        item {
+            ChapterStats(state.chapters)
+        }
+        item {
+            TrackerStats(state.trackers)
+        }
+    }
+}
+
+@Composable
+private fun OverviewSection(
+    data: StatsData.Overview,
+) {
+    val none = stringResource(R.string.none)
+    val context = LocalContext.current
+    val readDurationString = remember(data.totalReadDuration) {
+        data.totalReadDuration
+            .toDuration(DurationUnit.MILLISECONDS)
+            .toDurationString(context, fallback = none)
+    }
+    StatsSection(R.string.label_overview_section) {
+        Row {
+            StatsOverviewItem(
+                title = data.libraryMangaCount.toString(),
+                subtitle = stringResource(R.string.in_library),
+                icon = Icons.Outlined.CollectionsBookmark,
+            )
+            StatsOverviewItem(
+                title = data.completedMangaCount.toString(),
+                subtitle = stringResource(R.string.label_completed_titles),
+                icon = Icons.Outlined.LocalLibrary,
+            )
+            StatsOverviewItem(
+                title = readDurationString,
+                subtitle = stringResource(R.string.label_read_duration),
+                icon = Icons.Outlined.Schedule,
+            )
+        }
+    }
+}
+
+@Composable
+private fun TitlesStats(
+    data: StatsData.Titles,
+) {
+    StatsSection(R.string.label_titles_section) {
+        Row {
+            StatsItem(
+                data.globalUpdateItemCount.toString(),
+                stringResource(R.string.label_titles_in_global_update),
+            )
+            StatsItem(
+                data.startedMangaCount.toString(),
+                stringResource(R.string.label_started),
+            )
+            StatsItem(
+                data.localMangaCount.toString(),
+                stringResource(R.string.label_local),
+            )
+        }
+    }
+}
+
+@Composable
+private fun ChapterStats(
+    data: StatsData.Chapters,
+) {
+    StatsSection(R.string.chapters) {
+        Row {
+            StatsItem(
+                data.totalChapterCount.toString(),
+                stringResource(R.string.label_total_chapters),
+            )
+            StatsItem(
+                data.readChapterCount.toString(),
+                stringResource(R.string.label_read_chapters),
+            )
+            StatsItem(
+                data.downloadCount.toString(),
+                stringResource(R.string.label_downloaded),
+            )
+        }
+    }
+}
+
+@Composable
+private fun TrackerStats(
+    data: StatsData.Trackers,
+) {
+    val notApplicable = stringResource(R.string.not_applicable)
+    val meanScoreStr = remember(data.trackedTitleCount, data.meanScore) {
+        if (data.trackedTitleCount > 0 && !data.meanScore.isNaN()) {
+            // All other numbers are localized in English
+            String.format(Locale.ENGLISH, "%.2f ★", data.meanScore)
+        } else {
+            notApplicable
+        }
+    }
+    StatsSection(R.string.label_tracker_section) {
+        Row {
+            StatsItem(
+                data.trackedTitleCount.toString(),
+                stringResource(R.string.label_tracked_titles),
+            )
+            StatsItem(
+                meanScoreStr,
+                stringResource(R.string.label_mean_score),
+            )
+            StatsItem(
+                data.trackerCount.toString(),
+                stringResource(R.string.label_used),
+            )
+        }
+    }
+}

+ 17 - 0
app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenState.kt

@@ -0,0 +1,17 @@
+package eu.kanade.presentation.more.stats
+
+import androidx.compose.runtime.Immutable
+import eu.kanade.presentation.more.stats.data.StatsData
+
+sealed class StatsScreenState {
+    @Immutable
+    object Loading : StatsScreenState()
+
+    @Immutable
+    data class Success(
+        val overview: StatsData.Overview,
+        val titles: StatsData.Titles,
+        val chapters: StatsData.Chapters,
+        val trackers: StatsData.Trackers,
+    ) : StatsScreenState()
+}

+ 85 - 0
app/src/main/java/eu/kanade/presentation/more/stats/components/StatsItem.kt

@@ -0,0 +1,85 @@
+package eu.kanade.presentation.more.stats.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import eu.kanade.presentation.util.SecondaryItemAlpha
+import eu.kanade.presentation.util.padding
+
+@Composable
+fun RowScope.StatsOverviewItem(
+    title: String,
+    subtitle: String,
+    icon: ImageVector,
+) {
+    BaseStatsItem(
+        title = title,
+        titleStyle = MaterialTheme.typography.titleLarge,
+        subtitle = subtitle,
+        subtitleStyle = MaterialTheme.typography.bodyMedium,
+        icon = icon,
+    )
+}
+
+@Composable
+fun RowScope.StatsItem(
+    title: String,
+    subtitle: String,
+) {
+    BaseStatsItem(
+        title = title,
+        titleStyle = MaterialTheme.typography.bodyMedium,
+        subtitle = subtitle,
+        subtitleStyle = MaterialTheme.typography.labelSmall,
+    )
+}
+
+@Composable
+private fun RowScope.BaseStatsItem(
+    title: String,
+    titleStyle: TextStyle,
+    subtitle: String,
+    subtitleStyle: TextStyle,
+    icon: ImageVector? = null,
+) {
+    Column(
+        modifier = Modifier.weight(1f),
+        verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
+        horizontalAlignment = Alignment.CenterHorizontally,
+    ) {
+        Text(
+            text = title,
+            style = titleStyle
+                .copy(fontWeight = FontWeight.Bold),
+            textAlign = TextAlign.Center,
+            maxLines = 1,
+        )
+        Text(
+            text = subtitle,
+            style = subtitleStyle
+                .copy(
+                    color = MaterialTheme.colorScheme.onSurface
+                        .copy(alpha = SecondaryItemAlpha),
+                ),
+            textAlign = TextAlign.Center,
+        )
+        if (icon != null) {
+            Icon(
+                painter = rememberVectorPainter(icon),
+                contentDescription = null,
+                tint = MaterialTheme.colorScheme.primary,
+            )
+        }
+    }
+}

+ 38 - 0
app/src/main/java/eu/kanade/presentation/more/stats/components/StatsSection.kt

@@ -0,0 +1,38 @@
+package eu.kanade.presentation.more.stats.components
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import eu.kanade.presentation.util.padding
+
+@Composable
+fun StatsSection(
+    @StringRes titleRes: Int,
+    content: @Composable () -> Unit,
+) {
+    Text(
+        modifier = Modifier.padding(horizontal = MaterialTheme.padding.extraLarge),
+        text = stringResource(titleRes),
+        style = MaterialTheme.typography.titleSmall,
+    )
+    ElevatedCard(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(
+                horizontal = MaterialTheme.padding.medium,
+                vertical = MaterialTheme.padding.small,
+            ),
+        shape = MaterialTheme.shapes.extraLarge,
+    ) {
+        Column(modifier = Modifier.padding(MaterialTheme.padding.medium)) {
+            content()
+        }
+    }
+}

+ 28 - 0
app/src/main/java/eu/kanade/presentation/more/stats/data/StatsData.kt

@@ -0,0 +1,28 @@
+package eu.kanade.presentation.more.stats.data
+
+sealed class StatsData {
+
+    data class Overview(
+        val libraryMangaCount: Int,
+        val completedMangaCount: Int,
+        val totalReadDuration: Long,
+    ) : StatsData()
+
+    data class Titles(
+        val globalUpdateItemCount: Int,
+        val startedMangaCount: Int,
+        val localMangaCount: Int,
+    ) : StatsData()
+
+    data class Chapters(
+        val totalChapterCount: Int,
+        val readChapterCount: Int,
+        val downloadCount: Int,
+    ) : StatsData()
+
+    data class Trackers(
+        val trackedTitleCount: Int,
+        val meanScore: Double,
+        val trackerCount: Int,
+    ) : StatsData()
+}

+ 13 - 0
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt

@@ -107,6 +107,19 @@ class DownloadCache(
         return false
     }
 
+    /**
+     * Returns the amount of downloaded chapters.
+     */
+    fun getTotalDownloadCount(): Int {
+        renewCache()
+
+        return rootDownloadsDir.sourceDirs.values.sumOf { sourceDir ->
+            sourceDir.mangaDirs.values.sumOf { mangaDir ->
+                mangaDir.chapterDirs.size
+            }
+        }
+    }
+
     /**
      * Returns the amount of downloaded chapters for a manga.
      *

+ 7 - 0
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt

@@ -205,6 +205,13 @@ class DownloadManager(
             .firstOrNull { it.chapter.id == chapter.id && it.chapter.manga_id == chapter.mangaId }
     }
 
+    /**
+     * Returns the amount of downloaded chapters.
+     */
+    fun getDownloadCount(): Int {
+        return cache.getTotalDownloadCount()
+    }
+
     /**
      * Returns the amount of downloaded chapters for a manga.
      *

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt

@@ -24,6 +24,7 @@ import okhttp3.OkHttpClient
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 import uy.kohesive.injekt.injectLazy
+import eu.kanade.domain.track.model.Track as DomainTrack
 
 abstract class TrackService(val id: Long) {
 
@@ -59,6 +60,11 @@ abstract class TrackService(val id: Long) {
 
     abstract fun getScoreList(): List<String>
 
+    // TODO: Store all scores as 10 point in the future maybe?
+    open fun get10PointScore(track: DomainTrack): Float {
+        return track.score
+    }
+
     open fun indexToScore(index: Int): Float {
         return index.toFloat()
     }

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt

@@ -11,6 +11,7 @@ import kotlinx.serialization.decodeFromString
 import kotlinx.serialization.encodeToString
 import kotlinx.serialization.json.Json
 import uy.kohesive.injekt.injectLazy
+import eu.kanade.domain.track.model.Track as DomainTrack
 
 class Anilist(private val context: Context, id: Long) : TrackService(id) {
 
@@ -94,6 +95,11 @@ class Anilist(private val context: Context, id: Long) : TrackService(id) {
         }
     }
 
+    override fun get10PointScore(track: DomainTrack): Float {
+        // Score is stored in 100 point format
+        return track.score / 10f
+    }
+
     override fun indexToScore(index: Int): Float {
         return when (scorePreference.get()) {
             // 10 point

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt

@@ -91,9 +91,9 @@ class LibrarySettingsSheet(
 
         inner class FilterGroup : Group {
 
-            private val downloaded = Item.TriStateGroup(R.string.action_filter_downloaded, this)
+            private val downloaded = Item.TriStateGroup(R.string.label_downloaded, this)
             private val unread = Item.TriStateGroup(R.string.action_filter_unread, this)
-            private val started = Item.TriStateGroup(R.string.action_filter_started, this)
+            private val started = Item.TriStateGroup(R.string.label_started, this)
             private val bookmarked = Item.TriStateGroup(R.string.action_filter_bookmarked, this)
             private val completed = Item.TriStateGroup(R.string.completed, this)
             private val trackFilters: Map<Long, Item.TriStateGroup>

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreScreen.kt

@@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.category.CategoryController
 import eu.kanade.tachiyomi.ui.download.DownloadController
 import eu.kanade.tachiyomi.ui.setting.SettingsMainController
+import eu.kanade.tachiyomi.ui.stats.StatsController
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.system.isInstalledFromFDroid
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,6 +47,7 @@ object MoreScreen : Screen {
             isFDroid = context.isInstalledFromFDroid(),
             onClickDownloadQueue = { router.pushController(DownloadController()) },
             onClickCategories = { router.pushController(CategoryController()) },
+            onClickStats = { router.pushController(StatsController()) },
             onClickBackupAndRestore = { router.pushController(SettingsMainController.toBackupScreen()) },
             onClickSettings = { router.pushController(SettingsMainController()) },
             onClickAbout = { router.pushController(SettingsMainController.toAboutScreen()) },

+ 13 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsController.kt

@@ -0,0 +1,13 @@
+package eu.kanade.tachiyomi.ui.stats
+
+import androidx.compose.runtime.Composable
+import cafe.adriel.voyager.navigator.Navigator
+import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
+
+class StatsController : BasicFullComposeController() {
+
+    @Composable
+    override fun ComposeContent() {
+        Navigator(screen = StatsScreen())
+    }
+}

+ 52 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreen.kt

@@ -0,0 +1,52 @@
+package eu.kanade.tachiyomi.ui.stats
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.core.screen.uniqueScreenKey
+import cafe.adriel.voyager.navigator.currentOrThrow
+import eu.kanade.presentation.components.AppBar
+import eu.kanade.presentation.components.LoadingScreen
+import eu.kanade.presentation.components.Scaffold
+import eu.kanade.presentation.more.stats.StatsScreenContent
+import eu.kanade.presentation.more.stats.StatsScreenState
+import eu.kanade.presentation.util.LocalRouter
+import eu.kanade.tachiyomi.R
+
+class StatsScreen : Screen {
+
+    override val key = uniqueScreenKey
+
+    @Composable
+    override fun Content() {
+        val router = LocalRouter.currentOrThrow
+        val context = LocalContext.current
+
+        val screenModel = rememberScreenModel { StatsScreenModel() }
+        val state by screenModel.state.collectAsState()
+
+        if (state is StatsScreenState.Loading) {
+            LoadingScreen()
+            return
+        }
+
+        Scaffold(
+            topBar = { scrollBehavior ->
+                AppBar(
+                    title = stringResource(R.string.label_stats),
+                    navigateUp = router::popCurrentController,
+                    scrollBehavior = scrollBehavior,
+                )
+            },
+        ) { paddingValues ->
+            StatsScreenContent(
+                state = state as StatsScreenState.Success,
+                paddingValues = paddingValues,
+            )
+        }
+    }
+}

+ 152 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreenModel.kt

@@ -0,0 +1,152 @@
+package eu.kanade.tachiyomi.ui.stats
+
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
+import eu.kanade.core.util.fastCountNot
+import eu.kanade.core.util.fastDistinctBy
+import eu.kanade.core.util.fastFilter
+import eu.kanade.core.util.fastFilterNot
+import eu.kanade.core.util.fastMapNotNull
+import eu.kanade.domain.history.interactor.GetTotalReadDuration
+import eu.kanade.domain.library.model.LibraryManga
+import eu.kanade.domain.library.service.LibraryPreferences
+import eu.kanade.domain.manga.interactor.GetLibraryManga
+import eu.kanade.domain.manga.model.isLocal
+import eu.kanade.domain.track.interactor.GetTracks
+import eu.kanade.domain.track.model.Track
+import eu.kanade.presentation.more.stats.StatsScreenState
+import eu.kanade.presentation.more.stats.data.StatsData
+import eu.kanade.tachiyomi.data.download.DownloadManager
+import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD
+import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED
+import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.source.model.SManga
+import eu.kanade.tachiyomi.util.lang.launchIO
+import kotlinx.coroutines.flow.update
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class StatsScreenModel(
+    private val downloadManager: DownloadManager = Injekt.get(),
+    private val getLibraryManga: GetLibraryManga = Injekt.get(),
+    private val getTotalReadDuration: GetTotalReadDuration = Injekt.get(),
+    private val getTracks: GetTracks = Injekt.get(),
+    private val preferences: LibraryPreferences = Injekt.get(),
+    private val trackManager: TrackManager = Injekt.get(),
+) : StateScreenModel<StatsScreenState>(StatsScreenState.Loading) {
+
+    private val loggedServices by lazy { trackManager.services.fastFilter { it.isLogged } }
+
+    init {
+        coroutineScope.launchIO {
+            val libraryManga = getLibraryManga.await()
+
+            val distinctLibraryManga = libraryManga.fastDistinctBy { it.id }
+
+            val mangaTrackMap = getMangaTrackMap(distinctLibraryManga)
+            val scoredMangaTrackerMap = getScoredMangaTrackMap(mangaTrackMap)
+
+            val meanScore = getTrackMeanScore(scoredMangaTrackerMap)
+
+            val overviewStatData = StatsData.Overview(
+                libraryMangaCount = distinctLibraryManga.size,
+                completedMangaCount = distinctLibraryManga.count {
+                    it.manga.status.toInt() == SManga.COMPLETED && it.unreadCount == 0L
+                },
+                totalReadDuration = getTotalReadDuration.await(),
+            )
+
+            val titlesStatData = StatsData.Titles(
+                globalUpdateItemCount = getGlobalUpdateItemCount(libraryManga),
+                startedMangaCount = distinctLibraryManga.count { it.hasStarted },
+                localMangaCount = distinctLibraryManga.count { it.manga.isLocal() },
+            )
+
+            val chaptersStatData = StatsData.Chapters(
+                totalChapterCount = distinctLibraryManga.sumOf { it.totalChapters }.toInt(),
+                readChapterCount = distinctLibraryManga.sumOf { it.readCount }.toInt(),
+                downloadCount = downloadManager.getDownloadCount(),
+            )
+
+            val trackersStatData = StatsData.Trackers(
+                trackedTitleCount = mangaTrackMap.count { it.value.isNotEmpty() },
+                meanScore = meanScore,
+                trackerCount = loggedServices.size,
+            )
+
+            mutableState.update {
+                StatsScreenState.Success(
+                    overview = overviewStatData,
+                    titles = titlesStatData,
+                    chapters = chaptersStatData,
+                    trackers = trackersStatData,
+                )
+            }
+        }
+    }
+
+    private fun getGlobalUpdateItemCount(libraryManga: List<LibraryManga>): Int {
+        val includedCategories = preferences.libraryUpdateCategories().get().map { it.toLong() }
+        val includedManga = if (includedCategories.isNotEmpty()) {
+            libraryManga.filter { it.category in includedCategories }
+        } else {
+            libraryManga
+        }
+
+        val excludedCategories = preferences.libraryUpdateCategoriesExclude().get().map { it.toLong() }
+        val excludedMangaIds = if (excludedCategories.isNotEmpty()) {
+            libraryManga.fastMapNotNull { manga ->
+                manga.id.takeIf { manga.category in excludedCategories }
+            }
+        } else {
+            emptyList()
+        }
+
+        val updateRestrictions = preferences.libraryUpdateMangaRestriction().get()
+        return includedManga
+            .fastFilterNot { it.manga.id in excludedMangaIds }
+            .fastDistinctBy { it.manga.id }
+            .fastCountNot {
+                (MANGA_NON_COMPLETED in updateRestrictions && it.manga.status.toInt() == SManga.COMPLETED) ||
+                    (MANGA_HAS_UNREAD in updateRestrictions && it.unreadCount != 0L) ||
+                    (MANGA_NON_READ in updateRestrictions && it.totalChapters > 0 && !it.hasStarted)
+            }
+    }
+
+    private suspend fun getMangaTrackMap(libraryManga: List<LibraryManga>): Map<Long, List<Track>> {
+        val loggedServicesIds = loggedServices.map { it.id }.toHashSet()
+        return libraryManga.associate { manga ->
+            val tracks = getTracks.await(manga.id)
+                .fastFilter { it.syncId in loggedServicesIds }
+
+            manga.id to tracks
+        }
+    }
+
+    private fun getScoredMangaTrackMap(mangaTrackMap: Map<Long, List<Track>>): Map<Long, List<Track>> {
+        return mangaTrackMap.mapNotNull { (mangaId, tracks) ->
+            val trackList = tracks.mapNotNull { track ->
+                track.takeIf { it.score > 0.0 }
+            }
+            if (trackList.isEmpty()) return@mapNotNull null
+            mangaId to trackList
+        }.toMap()
+    }
+
+    private fun getTrackMeanScore(scoredMangaTrackMap: Map<Long, List<Track>>): Double {
+        return scoredMangaTrackMap
+            .map { (_, tracks) ->
+                tracks.map {
+                    get10PointScore(it)
+                }.average()
+            }
+            .fastFilter { !it.isNaN() }
+            .average()
+    }
+
+    private fun get10PointScore(track: Track): Float {
+        val service = trackManager.getService(track.syncId)!!
+        return service.get10PointScore(track)
+    }
+}

+ 5 - 1
app/src/main/sqldelight/data/history.sq

@@ -66,4 +66,8 @@ DO UPDATE
 SET
     last_read = :readAt,
     time_read = time_read + :time_read
-WHERE chapter_id = :chapterId;
+WHERE chapter_id = :chapterId;
+
+getReadDuration:
+SELECT coalesce(sum(time_read), 0)
+FROM history;

+ 24 - 4
i18n/src/main/res/values/strings.xml

@@ -23,6 +23,7 @@
     <string name="label_recent_manga">History</string>
     <string name="label_sources">Sources</string>
     <string name="label_backup">Backup and restore</string>
+    <string name="label_stats">Statistics</string>
     <string name="label_migration">Migrate</string>
     <string name="label_extensions">Extensions</string>
     <string name="label_extension_info">Extension info</string>
@@ -30,6 +31,11 @@
     <string name="label_default">Default</string>
     <string name="label_warning">Warning</string>
 
+    <!-- Shared labels -->
+    <string name="label_started">Started</string>
+    <string name="label_local">Local</string>
+    <string name="label_downloaded">Downloaded</string>
+
     <string name="unlock_app">Unlock Tachiyomi</string>
     <string name="confirm_lock_change">Authenticate to confirm change</string>
     <string name="confirm_exit">Press back again to exit</string>
@@ -38,11 +44,9 @@
     <string name="action_settings">Settings</string>
     <string name="action_menu">Menu</string>
     <string name="action_filter">Filter</string>
-    <string name="action_filter_downloaded">Downloaded</string>
     <string name="action_filter_bookmarked">Bookmarked</string>
     <string name="action_filter_tracked">Tracked</string>
     <string name="action_filter_unread">Unread</string>
-    <string name="action_filter_started">Started</string>
     <!-- reserved for #4048 -->
     <string name="action_filter_empty">Remove filter</string>
     <string name="action_sort_alpha">Alphabetically</string>
@@ -576,7 +580,6 @@
 
     <!-- Library fragment -->
     <string name="updating_category">Updating category</string>
-    <string name="local_source_badge">Local</string>
     <string name="manga_from_library">From library</string>
     <string name="downloaded_chapters">Downloaded chapters</string>
     <string name="badges_header">Badges</string>
@@ -699,7 +702,6 @@
     <string name="title">Title</string>
     <string name="status">Status</string>
     <string name="track_status">Status</string>
-    <string name="track_start_date">Started</string>
     <string name="track_started_reading_date">Start date</string>
     <string name="track_finished_reading_date">Finish date</string>
     <string name="track_type">Type</string>
@@ -783,6 +785,24 @@
     <string name="crash_screen_description">%s ran into an unexpected error. We suggest you screenshot this message, dump the crash logs, and then share it in our support channel on Discord.</string>
     <string name="crash_screen_restart_application">Restart the application</string>
 
+    <!-- Stats screen -->
+    <string name="label_overview_section">Overview</string>
+    <string name="label_completed_titles">Completed entries</string>
+    <string name="label_read_duration">Read duration</string>
+    <string name="label_titles_section">Entries</string>
+    <string name="label_titles_in_global_update">In global update</string>
+    <string name="label_total_chapters">Total</string>
+    <string name="label_read_chapters">Read</string>
+    <string name="label_tracker_section">Trackers</string>
+    <string name="label_tracked_titles">Tracked entries</string>
+    <string name="label_mean_score">Mean score</string>
+    <string name="label_used">Used</string>
+    <string name="not_applicable">N/A</string>
+    <string name="day_short">%dd</string>
+    <string name="hour_short">%dh</string>
+    <string name="minute_short">%dm</string>
+    <string name="seconds_short">%ds</string>
+
     <!-- Downloads activity and service -->
     <string name="download_queue_error">Couldn\'t download chapters. You can try again in the downloads section</string>
     <string name="download_insufficient_space">Couldn\'t download chapters due to low storage space</string>