mirror of
https://github.com/mihonapp/mihon.git
synced 2025-11-17 22:47:29 +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:
@@ -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