diff --git a/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt b/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt index bfbb7ba853..07d5933ad0 100644 --- a/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt +++ b/app/src/main/java/eu/kanade/core/util/CollectionUtils.kt @@ -44,7 +44,6 @@ fun HashSet.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 List.fastFilter(predicate: (T) -> Boolean): List { contract { callsInPlace(predicate) } @@ -60,7 +59,6 @@ inline fun List.fastFilter(predicate: (T) -> Boolean): List { * 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 List.fastFilterNot(predicate: (T) -> Boolean): List { contract { callsInPlace(predicate) } @@ -77,7 +75,6 @@ inline fun List.fastFilterNot(predicate: (T) -> Boolean): List { * 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 List.fastMapNotNull(transform: (T) -> R?): List { contract { callsInPlace(transform) } @@ -97,7 +94,6 @@ inline fun List.fastMapNotNull(transform: (T) -> R?): List { * 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 List.fastPartition(predicate: (T) -> Boolean): Pair, List> { contract { callsInPlace(predicate) } @@ -112,3 +108,41 @@ inline fun List.fastPartition(predicate: (T) -> Boolean): Pair, 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 List.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 List.fastDistinctBy(selector: (T) -> K): List { + contract { callsInPlace(selector) } + val set = HashSet() + val list = ArrayList() + fastForEach { + val key = selector(it) + if (set.add(key)) list.add(it) + } + return list +} diff --git a/app/src/main/java/eu/kanade/core/util/DurationUtils.kt b/app/src/main/java/eu/kanade/core/util/DurationUtils.kt new file mode 100644 index 0000000000..3eec1a0dc4 --- /dev/null +++ b/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 } + } +} diff --git a/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt index 15aa46de03..1716a9c370 100644 --- a/app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt +++ b/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) } diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 075b50e7d9..0b5db26c8e 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/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()) } diff --git a/app/src/main/java/eu/kanade/domain/history/interactor/GetTotalReadDuration.kt b/app/src/main/java/eu/kanade/domain/history/interactor/GetTotalReadDuration.kt new file mode 100644 index 0000000000..525f290e78 --- /dev/null +++ b/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() + } +} diff --git a/app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt b/app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt index b20a01f10c..f6297c51c4 100644 --- a/app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt +++ b/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) diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt index 2446646e64..d86ba00c8b 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt +++ b/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, ) diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt index bbc8b2e4e4..8a712f22b1 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaSettingsDialog.kt +++ b/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, ) diff --git a/app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt b/app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt index e055166f01..8ee590305e 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/TrackServiceSearch.kt +++ b/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, ) } diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt index 08f2204ae9..329e821ff6 100644 --- a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt +++ b/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), diff --git a/app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenContent.kt b/app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenContent.kt new file mode 100644 index 0000000000..1b89882b24 --- /dev/null +++ b/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), + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenState.kt b/app/src/main/java/eu/kanade/presentation/more/stats/StatsScreenState.kt new file mode 100644 index 0000000000..ffc7705899 --- /dev/null +++ b/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() +} diff --git a/app/src/main/java/eu/kanade/presentation/more/stats/components/StatsItem.kt b/app/src/main/java/eu/kanade/presentation/more/stats/components/StatsItem.kt new file mode 100644 index 0000000000..de2b50e818 --- /dev/null +++ b/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, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/stats/components/StatsSection.kt b/app/src/main/java/eu/kanade/presentation/more/stats/components/StatsSection.kt new file mode 100644 index 0000000000..2cb8fa3372 --- /dev/null +++ b/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() + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/stats/data/StatsData.kt b/app/src/main/java/eu/kanade/presentation/more/stats/data/StatsData.kt new file mode 100644 index 0000000000..98143f8875 --- /dev/null +++ b/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() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index b000625d01..5fe0dda0ff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/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. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index d0db09a8db..f63d96f860 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/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. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index 4603ae7df1..422f0d3ba9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/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 + // 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() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index f7ebb9a19e..07e7e481e6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt index ee4e101059..ed0deee46c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreScreen.kt index b0b14353f0..6b3ed27a5e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreScreen.kt +++ b/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()) }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsController.kt new file mode 100644 index 0000000000..115b595c59 --- /dev/null +++ b/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()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreen.kt new file mode 100644 index 0000000000..5a5780aa25 --- /dev/null +++ b/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, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreenModel.kt new file mode 100644 index 0000000000..eebe91d731 --- /dev/null +++ b/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.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): 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): Map> { + 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>): Map> { + 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>): 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) + } +} diff --git a/app/src/main/sqldelight/data/history.sq b/app/src/main/sqldelight/data/history.sq index 810c8d6735..6ddb06ca73 100644 --- a/app/src/main/sqldelight/data/history.sq +++ b/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; \ No newline at end of file +WHERE chapter_id = :chapterId; + +getReadDuration: +SELECT coalesce(sum(time_read), 0) +FROM history; diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 8f3f8323ab..3d7430272d 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ History Sources Backup and restore + Statistics Migrate Extensions Extension info @@ -30,6 +31,11 @@ Default Warning + + Started + Local + Downloaded + Unlock Tachiyomi Authenticate to confirm change Press back again to exit @@ -38,11 +44,9 @@ Settings Menu Filter - Downloaded Bookmarked Tracked Unread - Started Remove filter Alphabetically @@ -576,7 +580,6 @@ Updating category - Local From library Downloaded chapters Badges @@ -699,7 +702,6 @@ Title Status Status - Started Start date Finish date Type @@ -783,6 +785,24 @@ %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. Restart the application + + Overview + Completed entries + Read duration + Entries + In global update + Total + Read + Trackers + Tracked entries + Mean score + Used + N/A + %dd + %dh + %dm + %ds + Couldn\'t download chapters. You can try again in the downloads section Couldn\'t download chapters due to low storage space