mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 06:17:57 +01:00 
			
		
		
		
	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:
		| @@ -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) | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user