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:
parent
e14909fff4
commit
3d7591feca
26 changed files with 695 additions and 14 deletions
|
@ -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
app/src/main/java/eu/kanade/core/util/DurationUtils.kt
Normal file
16
app/src/main/java/eu/kanade/core/util/DurationUtils.kt
Normal 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 }
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
|
|
|
@ -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()) }
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -10,6 +10,8 @@ interface HistoryRepository {
|
|||
|
||||
suspend fun getLastHistory(): HistoryWithRelations?
|
||||
|
||||
suspend fun getTotalReadDuration(): Long
|
||||
|
||||
suspend fun resetHistory(historyId: Long)
|
||||
|
||||
suspend fun resetHistoryByMangaId(mangaId: Long)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()) },
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
Reference in a new issue