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
This commit is contained in:
AntsyLich 2022-11-27 02:50:26 +06:00 committed by GitHub
parent e14909fff4
commit 3d7591feca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 695 additions and 14 deletions

View File

@ -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 * 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. * collections that are created by code we control and are known to support random access.
*/ */
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class) @OptIn(ExperimentalContracts::class)
inline fun <T> List<T>.fastFilter(predicate: (T) -> Boolean): List<T> { inline fun <T> List<T>.fastFilter(predicate: (T) -> Boolean): List<T> {
contract { callsInPlace(predicate) } 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 * 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. * collections that are created by code we control and are known to support random access.
*/ */
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class) @OptIn(ExperimentalContracts::class)
inline fun <T> List<T>.fastFilterNot(predicate: (T) -> Boolean): List<T> { inline fun <T> List<T>.fastFilterNot(predicate: (T) -> Boolean): List<T> {
contract { callsInPlace(predicate) } 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 * 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. * collections that are created by code we control and are known to support random access.
*/ */
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class) @OptIn(ExperimentalContracts::class)
inline fun <T, R> List<T>.fastMapNotNull(transform: (T) -> R?): List<R> { inline fun <T, R> List<T>.fastMapNotNull(transform: (T) -> R?): List<R> {
contract { callsInPlace(transform) } 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 * 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. * collections that are created by code we control and are known to support random access.
*/ */
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class) @OptIn(ExperimentalContracts::class)
inline fun <T> List<T>.fastPartition(predicate: (T) -> Boolean): Pair<List<T>, List<T>> { inline fun <T> List<T>.fastPartition(predicate: (T) -> Boolean): Pair<List<T>, List<T>> {
contract { callsInPlace(predicate) } contract { callsInPlace(predicate) }
@ -112,3 +108,41 @@ inline fun <T> List<T>.fastPartition(predicate: (T) -> Boolean): Pair<List<T>, L
} }
return Pair(first, second) 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
}

View File

@ -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 }
}
}

View File

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

View File

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

View File

@ -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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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),
)
}
}
}

View File

@ -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()
}

View File

@ -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,
)
}
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}

View File

@ -107,6 +107,19 @@ class DownloadCache(
return false 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. * Returns the amount of downloaded chapters for a manga.
* *

View File

@ -205,6 +205,13 @@ class DownloadManager(
.firstOrNull { it.chapter.id == chapter.id && it.chapter.manga_id == chapter.mangaId } .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. * Returns the amount of downloaded chapters for a manga.
* *

View File

@ -24,6 +24,7 @@ import okhttp3.OkHttpClient
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import eu.kanade.domain.track.model.Track as DomainTrack
abstract class TrackService(val id: Long) { abstract class TrackService(val id: Long) {
@ -59,6 +60,11 @@ abstract class TrackService(val id: Long) {
abstract fun getScoreList(): List<String> 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 { open fun indexToScore(index: Int): Float {
return index.toFloat() return index.toFloat()
} }

View File

@ -11,6 +11,7 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import eu.kanade.domain.track.model.Track as DomainTrack
class Anilist(private val context: Context, id: Long) : TrackService(id) { 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 { override fun indexToScore(index: Int): Float {
return when (scorePreference.get()) { return when (scorePreference.get()) {
// 10 point // 10 point

View File

@ -91,9 +91,9 @@ class LibrarySettingsSheet(
inner class FilterGroup : Group { 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 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 bookmarked = Item.TriStateGroup(R.string.action_filter_bookmarked, this)
private val completed = Item.TriStateGroup(R.string.completed, this) private val completed = Item.TriStateGroup(R.string.completed, this)
private val trackFilters: Map<Long, Item.TriStateGroup> private val trackFilters: Map<Long, Item.TriStateGroup>

View File

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

View File

@ -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())
}
}

View File

@ -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,
)
}
}
}

View File

@ -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)
}
}

View File

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

View File

@ -23,6 +23,7 @@
<string name="label_recent_manga">History</string> <string name="label_recent_manga">History</string>
<string name="label_sources">Sources</string> <string name="label_sources">Sources</string>
<string name="label_backup">Backup and restore</string> <string name="label_backup">Backup and restore</string>
<string name="label_stats">Statistics</string>
<string name="label_migration">Migrate</string> <string name="label_migration">Migrate</string>
<string name="label_extensions">Extensions</string> <string name="label_extensions">Extensions</string>
<string name="label_extension_info">Extension info</string> <string name="label_extension_info">Extension info</string>
@ -30,6 +31,11 @@
<string name="label_default">Default</string> <string name="label_default">Default</string>
<string name="label_warning">Warning</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="unlock_app">Unlock Tachiyomi</string>
<string name="confirm_lock_change">Authenticate to confirm change</string> <string name="confirm_lock_change">Authenticate to confirm change</string>
<string name="confirm_exit">Press back again to exit</string> <string name="confirm_exit">Press back again to exit</string>
@ -38,11 +44,9 @@
<string name="action_settings">Settings</string> <string name="action_settings">Settings</string>
<string name="action_menu">Menu</string> <string name="action_menu">Menu</string>
<string name="action_filter">Filter</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_bookmarked">Bookmarked</string>
<string name="action_filter_tracked">Tracked</string> <string name="action_filter_tracked">Tracked</string>
<string name="action_filter_unread">Unread</string> <string name="action_filter_unread">Unread</string>
<string name="action_filter_started">Started</string>
<!-- reserved for #4048 --> <!-- reserved for #4048 -->
<string name="action_filter_empty">Remove filter</string> <string name="action_filter_empty">Remove filter</string>
<string name="action_sort_alpha">Alphabetically</string> <string name="action_sort_alpha">Alphabetically</string>
@ -576,7 +580,6 @@
<!-- Library fragment --> <!-- Library fragment -->
<string name="updating_category">Updating category</string> <string name="updating_category">Updating category</string>
<string name="local_source_badge">Local</string>
<string name="manga_from_library">From library</string> <string name="manga_from_library">From library</string>
<string name="downloaded_chapters">Downloaded chapters</string> <string name="downloaded_chapters">Downloaded chapters</string>
<string name="badges_header">Badges</string> <string name="badges_header">Badges</string>
@ -699,7 +702,6 @@
<string name="title">Title</string> <string name="title">Title</string>
<string name="status">Status</string> <string name="status">Status</string>
<string name="track_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_started_reading_date">Start date</string>
<string name="track_finished_reading_date">Finish date</string> <string name="track_finished_reading_date">Finish date</string>
<string name="track_type">Type</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_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> <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 --> <!-- 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_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> <string name="download_insufficient_space">Couldn\'t download chapters due to low storage space</string>