mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-11-20 16:29:20 +01:00
add download stats screen
This commit is contained in:
parent
2ee895ee3c
commit
c14fcfdec3
@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CloudOff
|
||||
import androidx.compose.material.icons.outlined.FileDownloadDone
|
||||
import androidx.compose.material.icons.outlined.GetApp
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
@ -48,6 +49,7 @@ fun MoreScreen(
|
||||
onClickDataAndStorage: () -> Unit,
|
||||
onClickSettings: () -> Unit,
|
||||
onClickAbout: () -> Unit,
|
||||
onClickDownloadState: () -> Unit,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
@ -139,6 +141,13 @@ fun MoreScreen(
|
||||
onPreferenceClick = onClickStats,
|
||||
)
|
||||
}
|
||||
item {
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(R.string.label_download_stats),
|
||||
icon = Icons.Outlined.FileDownloadDone,
|
||||
onPreferenceClick = onClickDownloadState,
|
||||
)
|
||||
}
|
||||
item {
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(R.string.label_data_storage),
|
||||
|
@ -0,0 +1,16 @@
|
||||
package eu.kanade.presentation.more.download
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
|
||||
@Immutable
|
||||
data class DownloadStatManga(
|
||||
val libraryManga: LibraryManga,
|
||||
val folderSize: Long,
|
||||
val selected: Boolean = false,
|
||||
val source: Source,
|
||||
val category: Category,
|
||||
val downloadChaptersCount: Int,
|
||||
)
|
@ -0,0 +1,309 @@
|
||||
package eu.kanade.presentation.more.download
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.DensitySmall
|
||||
import androidx.compose.material.icons.outlined.FlipToBack
|
||||
import androidx.compose.material.icons.outlined.SelectAll
|
||||
import androidx.compose.material.icons.outlined.Sort
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.AppBarActions
|
||||
import eu.kanade.presentation.components.DropdownMenu
|
||||
import eu.kanade.presentation.components.SearchToolbar
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
||||
import tachiyomi.presentation.core.components.SortItem
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
|
||||
class DownloadStatsScreen : Screen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val screenModel = rememberScreenModel { DownloadStatsScreenModel() }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = { scrollBehavior ->
|
||||
DownloadStatsAppBar(
|
||||
groupByMode = state.groupByMode,
|
||||
selected = state.selected,
|
||||
onSelectAll = { screenModel.toggleAllSelection(true) },
|
||||
onInvertSelection = { screenModel.invertSelection() },
|
||||
onCancelActionMode = { screenModel.toggleAllSelection(false) },
|
||||
onMultiDeleteClicked = screenModel::deleteMangas,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onClickGroup = screenModel::runGroupBy,
|
||||
onClickSort = screenModel::runSortAction,
|
||||
sortState = state.sortMode,
|
||||
descendingOrder = state.descendingOrder,
|
||||
searchQuery = state.searchQuery,
|
||||
onChangeSearchQuery = screenModel::search,
|
||||
navigateUp = navigator::pop,
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||
|
||||
state.processedItems.isEmpty() -> EmptyScreen(
|
||||
textResource = R.string.information_no_downloads,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
)
|
||||
|
||||
else -> {
|
||||
when (state.groupByMode) {
|
||||
GroupByMode.NONE -> FastScrollLazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
downloadStatUiItems(
|
||||
items = state.processedItems,
|
||||
selectionMode = state.selectionMode,
|
||||
onClick = { item ->
|
||||
navigator.push(
|
||||
MangaScreen(item.libraryManga.manga.id),
|
||||
)
|
||||
},
|
||||
onSelected = screenModel::toggleSelection,
|
||||
onDeleteManga = screenModel::deleteMangas,
|
||||
)
|
||||
}
|
||||
|
||||
GroupByMode.BY_SOURCE -> {
|
||||
CategoryList(
|
||||
contentPadding = contentPadding,
|
||||
selectionMode = state.selectionMode,
|
||||
onMangaClick = { item ->
|
||||
navigator.push(
|
||||
MangaScreen(item.libraryManga.manga.id),
|
||||
)
|
||||
},
|
||||
onDeleteManga = screenModel::deleteMangas,
|
||||
onGroupSelected = screenModel::groupSelection,
|
||||
onSelected = screenModel::toggleSelection,
|
||||
categoryMap = screenModel.categoryMap(state.processedItems, GroupByMode.BY_SOURCE, state.sortMode, state.descendingOrder),
|
||||
)
|
||||
}
|
||||
|
||||
GroupByMode.BY_CATEGORY -> {
|
||||
CategoryList(
|
||||
contentPadding = contentPadding,
|
||||
selectionMode = state.selectionMode,
|
||||
onMangaClick = { item ->
|
||||
navigator.push(
|
||||
MangaScreen(item.libraryManga.manga.id),
|
||||
)
|
||||
},
|
||||
onDeleteManga = screenModel::deleteMangas,
|
||||
onGroupSelected = screenModel::groupSelection,
|
||||
onSelected = screenModel::toggleSelection,
|
||||
categoryMap = screenModel.categoryMap(state.processedItems, GroupByMode.BY_CATEGORY, state.sortMode, state.descendingOrder),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadStatsAppBar(
|
||||
groupByMode: GroupByMode,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: List<DownloadStatManga>,
|
||||
onSelectAll: () -> Unit,
|
||||
onInvertSelection: () -> Unit,
|
||||
onCancelActionMode: () -> Unit,
|
||||
onClickSort: (SortingMode) -> Unit,
|
||||
onClickGroup: (GroupByMode) -> Unit,
|
||||
onMultiDeleteClicked: (List<DownloadStatManga>) -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
sortState: SortingMode,
|
||||
descendingOrder: Boolean? = null,
|
||||
searchQuery: String?,
|
||||
onChangeSearchQuery: (String?) -> Unit,
|
||||
navigateUp: (() -> Unit)?,
|
||||
) {
|
||||
if (selected.isNotEmpty()) {
|
||||
DownloadStatsActionAppBar(
|
||||
modifier = modifier,
|
||||
selected = selected,
|
||||
onSelectAll = onSelectAll,
|
||||
onInvertSelection = onInvertSelection,
|
||||
onCancelActionMode = onCancelActionMode,
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigateUp = navigateUp,
|
||||
onMultiDeleteClicked = onMultiDeleteClicked,
|
||||
)
|
||||
} else {
|
||||
SearchToolbar(
|
||||
navigateUp = navigateUp,
|
||||
titleContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.label_download_stats),
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1f, false),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
searchQuery = searchQuery,
|
||||
onChangeSearchQuery = onChangeSearchQuery,
|
||||
actions = {
|
||||
val filterTint = LocalContentColor.current
|
||||
var groupExpanded by remember { mutableStateOf(false) }
|
||||
val onDownloadDismissRequest = { groupExpanded = false }
|
||||
GroupDropdownMenu(
|
||||
expanded = groupExpanded,
|
||||
groupByMode = groupByMode,
|
||||
onDismissRequest = onDownloadDismissRequest,
|
||||
onGroupClicked = onClickGroup,
|
||||
)
|
||||
var sortExpanded by remember { mutableStateOf(false) }
|
||||
val onSortDismissRequest = { sortExpanded = false }
|
||||
SortDropdownMenu(
|
||||
expanded = sortExpanded,
|
||||
onDismissRequest = onSortDismissRequest,
|
||||
onSortClicked = onClickSort,
|
||||
sortState = sortState,
|
||||
descendingOrder = descendingOrder,
|
||||
)
|
||||
AppBarActions(
|
||||
listOf(
|
||||
AppBar.Action(
|
||||
title = stringResource(R.string.action_sort),
|
||||
icon = Icons.Outlined.Sort,
|
||||
iconTint = filterTint,
|
||||
onClick = { sortExpanded = !sortExpanded },
|
||||
),
|
||||
AppBar.Action(
|
||||
title = stringResource(R.string.action_group),
|
||||
icon = Icons.Outlined.DensitySmall,
|
||||
onClick = { groupExpanded = !groupExpanded },
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadStatsActionAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
selected: List<DownloadStatManga>,
|
||||
onSelectAll: () -> Unit,
|
||||
onInvertSelection: () -> Unit,
|
||||
onCancelActionMode: () -> Unit,
|
||||
onMultiDeleteClicked: (List<DownloadStatManga>) -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
navigateUp: (() -> Unit)?,
|
||||
) {
|
||||
AppBar(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.label_download_stats),
|
||||
onCancelActionMode = onCancelActionMode,
|
||||
actions = {
|
||||
AppBarActions(
|
||||
listOf(
|
||||
AppBar.Action(
|
||||
title = stringResource(R.string.action_select_all),
|
||||
icon = Icons.Outlined.SelectAll,
|
||||
onClick = onSelectAll,
|
||||
),
|
||||
AppBar.Action(
|
||||
title = stringResource(R.string.action_select_inverse),
|
||||
icon = Icons.Outlined.FlipToBack,
|
||||
onClick = onInvertSelection,
|
||||
),
|
||||
AppBar.Action(
|
||||
title = stringResource(R.string.delete_downloads_for_manga),
|
||||
icon = Icons.Outlined.Delete,
|
||||
onClick = { onMultiDeleteClicked(selected) },
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigateUp = navigateUp,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupDropdownMenu(
|
||||
expanded: Boolean,
|
||||
groupByMode: GroupByMode,
|
||||
onDismissRequest: () -> Unit,
|
||||
onGroupClicked: (GroupByMode) -> Unit,
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
listOfNotNull(
|
||||
if (groupByMode != GroupByMode.NONE) GroupByMode.NONE to stringResource(R.string.action_ungroup) else null,
|
||||
if (groupByMode != GroupByMode.BY_CATEGORY) GroupByMode.BY_CATEGORY to stringResource(R.string.action_group_by_category) else null,
|
||||
if (groupByMode != GroupByMode.BY_SOURCE) GroupByMode.BY_SOURCE to stringResource(R.string.action_group_by_source) else null,
|
||||
).map { (mode, string) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = string) },
|
||||
onClick = {
|
||||
onGroupClicked(mode)
|
||||
onDismissRequest()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SortDropdownMenu(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSortClicked: (SortingMode) -> Unit,
|
||||
sortState: SortingMode,
|
||||
descendingOrder: Boolean? = null,
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
listOfNotNull(
|
||||
SortingMode.BY_ALPHABET to stringResource(R.string.action_sort_alpha),
|
||||
SortingMode.BY_SIZE to stringResource(R.string.action_sort_size),
|
||||
).map { (mode, string) ->
|
||||
SortItem(
|
||||
label = string,
|
||||
sortDescending = descendingOrder.takeIf { sortState == mode },
|
||||
onClick = { onSortClicked(mode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,355 @@
|
||||
package eu.kanade.presentation.more.download
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.coroutineScope
|
||||
import eu.kanade.core.util.addOrRemove
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import tachiyomi.core.preference.getEnum
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
import tachiyomi.core.util.lang.launchNonCancellable
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.util.TreeMap
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class DownloadStatsScreenModel(
|
||||
private val getLibraryManga: GetLibraryManga = Injekt.get(),
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get(),
|
||||
private val downloadProvider: DownloadProvider = Injekt.get(),
|
||||
private val getCategories: GetCategories = Injekt.get(),
|
||||
private val preferenceStore: PreferenceStore = Injekt.get(),
|
||||
) : StateScreenModel<DownloadStatsScreenState>(DownloadStatsScreenState()) {
|
||||
|
||||
private val downloadCache: DownloadCache by injectLazy()
|
||||
|
||||
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
|
||||
private val selectedMangaIds: HashSet<Long> = HashSet()
|
||||
|
||||
init {
|
||||
coroutineScope.launchIO {
|
||||
val sortMode = preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).get()
|
||||
mutableState.update {
|
||||
val categories = getCategories.await().associateBy { group -> group.id }
|
||||
it.copy(
|
||||
items = getLibraryManga.await().filter { libraryManga ->
|
||||
downloadCache.getDownloadCount(libraryManga.manga) > 0
|
||||
}.map { libraryManga ->
|
||||
val source = sourceManager.get(libraryManga.manga.source)!!
|
||||
DownloadStatManga(
|
||||
libraryManga = libraryManga,
|
||||
selected = libraryManga.id in selectedMangaIds,
|
||||
source = source,
|
||||
folderSize = getFolderSize(
|
||||
downloadProvider.findMangaDir(
|
||||
libraryManga.manga.title,
|
||||
source,
|
||||
)?.filePath!!,
|
||||
),
|
||||
downloadChaptersCount = downloadCache.getDownloadCount(libraryManga.manga),
|
||||
category = categories[libraryManga.category]!!,
|
||||
)
|
||||
},
|
||||
groupByMode = preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).get(),
|
||||
sortMode = sortMode,
|
||||
descendingOrder = preferenceStore.getBoolean("descending_order", false).get(),
|
||||
searchQuery = preferenceStore.getString("search_query", "").get().takeIf { string -> string != "" },
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
runSortAction(sortMode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFolderSize(path: String): Long {
|
||||
val file = File(path)
|
||||
var size: Long = 0
|
||||
|
||||
if (file.exists()) {
|
||||
if (file.isDirectory) {
|
||||
val files = file.listFiles()
|
||||
if (files != null) {
|
||||
for (childFile in files) {
|
||||
size += if (childFile.isDirectory) {
|
||||
getFolderSize(childFile.path)
|
||||
} else {
|
||||
getFileSize(childFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
size = getFileSize(file)
|
||||
}
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
private fun getFileSize(file: File): Long {
|
||||
val archiveFormats = setOf(".zip", ".cbz", ".rar", ".cbr")
|
||||
return if (file.isDirectory) {
|
||||
getFolderSize(file.path)
|
||||
} else if (file.isFile) {
|
||||
file.length()
|
||||
} else if (file.extension.lowercase() in archiveFormats) {
|
||||
getZipFileSize(file)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun getZipFileSize(file: File): Long {
|
||||
var size: Long = 0
|
||||
|
||||
val zipFile = ZipFile(file)
|
||||
val entries = zipFile.entries()
|
||||
|
||||
while (entries.hasMoreElements()) {
|
||||
val entry: ZipEntry = entries.nextElement()
|
||||
size += entry.size
|
||||
}
|
||||
|
||||
zipFile.close()
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
fun runSortAction(mode: SortingMode) {
|
||||
when (mode) {
|
||||
SortingMode.BY_ALPHABET -> sortByAlphabet()
|
||||
SortingMode.BY_SIZE -> sortBySize()
|
||||
}
|
||||
}
|
||||
|
||||
fun runGroupBy(mode: GroupByMode) {
|
||||
when (mode) {
|
||||
GroupByMode.NONE -> unGroup()
|
||||
GroupByMode.BY_CATEGORY -> groupByCategory()
|
||||
GroupByMode.BY_SOURCE -> groupBySource()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortByAlphabet() {
|
||||
mutableState.update { state ->
|
||||
val descendingOrder = if (state.sortMode == SortingMode.BY_ALPHABET) !state.descendingOrder else false
|
||||
preferenceStore.getBoolean("descending_order", false).set(descendingOrder)
|
||||
state.copy(
|
||||
items = if (descendingOrder) state.items.sortedByDescending { it.libraryManga.manga.title } else state.items.sortedBy { it.libraryManga.manga.title },
|
||||
descendingOrder = descendingOrder,
|
||||
sortMode = SortingMode.BY_ALPHABET,
|
||||
)
|
||||
}
|
||||
preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).set(SortingMode.BY_ALPHABET)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun categoryMap(items: List<DownloadStatManga>, groupMode: GroupByMode, sortMode: SortingMode, descendingOrder: Boolean): Map<String, List<DownloadStatManga>> {
|
||||
val unsortedMap = when (groupMode) {
|
||||
GroupByMode.BY_CATEGORY -> items.groupBy { if (it.category.isSystemCategory) { stringResource(R.string.label_default) } else { it.category.name } }
|
||||
GroupByMode.BY_SOURCE -> items.groupBy { it.source.name }
|
||||
GroupByMode.NONE -> emptyMap()
|
||||
}
|
||||
return when (sortMode) {
|
||||
SortingMode.BY_ALPHABET -> {
|
||||
val sortedMap = TreeMap<String, List<DownloadStatManga>>(if (descendingOrder) { compareByDescending { it } } else { compareBy { it } })
|
||||
sortedMap.putAll(unsortedMap)
|
||||
sortedMap
|
||||
}
|
||||
SortingMode.BY_SIZE -> {
|
||||
val compareFun: (String) -> Comparable<*> = { it: String -> unsortedMap[it]?.sumOf { manga -> manga.folderSize } ?: 0 }
|
||||
val sortedMap = TreeMap<String, List<DownloadStatManga>>(if (descendingOrder) { compareByDescending { compareFun(it) } } else { compareBy { compareFun(it) } })
|
||||
sortedMap.putAll(unsortedMap)
|
||||
sortedMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortBySize() {
|
||||
mutableState.update { state ->
|
||||
val descendingOrder = if (state.sortMode == SortingMode.BY_SIZE) !state.descendingOrder else false
|
||||
preferenceStore.getBoolean("descending_order", false).set(descendingOrder)
|
||||
state.copy(
|
||||
items = if (descendingOrder) state.items.sortedByDescending { it.folderSize } else state.items.sortedBy { it.folderSize },
|
||||
descendingOrder = descendingOrder,
|
||||
sortMode = SortingMode.BY_SIZE,
|
||||
)
|
||||
}
|
||||
preferenceStore.getEnum("sort_mode", SortingMode.BY_SIZE).set(SortingMode.BY_SIZE)
|
||||
}
|
||||
|
||||
private fun groupBySource() {
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
groupByMode = GroupByMode.BY_SOURCE,
|
||||
)
|
||||
}
|
||||
preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.BY_SOURCE)
|
||||
}
|
||||
|
||||
private fun groupByCategory() {
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
groupByMode = GroupByMode.BY_CATEGORY,
|
||||
)
|
||||
}
|
||||
preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.BY_CATEGORY)
|
||||
}
|
||||
|
||||
private fun unGroup() {
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
groupByMode = GroupByMode.NONE,
|
||||
)
|
||||
}
|
||||
preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.NONE)
|
||||
}
|
||||
|
||||
fun toggleSelection(
|
||||
item: DownloadStatManga,
|
||||
selected: Boolean,
|
||||
userSelected: Boolean = false,
|
||||
fromLongPress: Boolean = false,
|
||||
) {
|
||||
mutableState.update { state ->
|
||||
val newItems = state.items.toMutableList().apply {
|
||||
val selectedIndex = indexOfFirst { it.libraryManga.manga.id == item.libraryManga.manga.id }
|
||||
if (selectedIndex < 0) return@apply
|
||||
|
||||
val selectedItem = get(selectedIndex)
|
||||
if (selectedItem.selected == selected) return@apply
|
||||
|
||||
val firstSelection = none { it.selected }
|
||||
set(selectedIndex, selectedItem.copy(selected = selected))
|
||||
selectedMangaIds.addOrRemove(item.libraryManga.manga.id, selected)
|
||||
|
||||
if (selected && userSelected && fromLongPress) {
|
||||
if (firstSelection) {
|
||||
selectedPositions[0] = selectedIndex
|
||||
selectedPositions[1] = selectedIndex
|
||||
} else {
|
||||
// Try to select the items in-between when possible
|
||||
val range: IntRange
|
||||
if (selectedIndex < selectedPositions[0]) {
|
||||
range = selectedIndex + 1 until selectedPositions[0]
|
||||
selectedPositions[0] = selectedIndex
|
||||
} else if (selectedIndex > selectedPositions[1]) {
|
||||
range = (selectedPositions[1] + 1) until selectedIndex
|
||||
selectedPositions[1] = selectedIndex
|
||||
} else {
|
||||
// Just select itself
|
||||
range = IntRange.EMPTY
|
||||
}
|
||||
|
||||
range.forEach {
|
||||
val inbetweenItem = get(it)
|
||||
if (!inbetweenItem.selected) {
|
||||
selectedMangaIds.add(inbetweenItem.libraryManga.manga.id)
|
||||
set(it, inbetweenItem.copy(selected = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (userSelected && !fromLongPress) {
|
||||
if (!selected) {
|
||||
if (selectedIndex == selectedPositions[0]) {
|
||||
selectedPositions[0] = indexOfFirst { it.selected }
|
||||
} else if (selectedIndex == selectedPositions[1]) {
|
||||
selectedPositions[1] = indexOfLast { it.selected }
|
||||
}
|
||||
} else {
|
||||
if (selectedIndex < selectedPositions[0]) {
|
||||
selectedPositions[0] = selectedIndex
|
||||
} else if (selectedIndex > selectedPositions[1]) {
|
||||
selectedPositions[1] = selectedIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.copy(items = newItems)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAllSelection(selected: Boolean) {
|
||||
mutableState.update { state ->
|
||||
val newItems = state.items.map {
|
||||
selectedMangaIds.addOrRemove(it.libraryManga.manga.id, selected)
|
||||
it.copy(selected = selected)
|
||||
}
|
||||
state.copy(items = newItems)
|
||||
}
|
||||
|
||||
selectedPositions[0] = -1
|
||||
selectedPositions[1] = -1
|
||||
}
|
||||
|
||||
fun search(query: String?) {
|
||||
mutableState.update { it.copy(searchQuery = query) }
|
||||
if (query != null) {
|
||||
preferenceStore.getString("search_query", "").set(query)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMangas(libraryMangas: List<DownloadStatManga>) {
|
||||
coroutineScope.launchNonCancellable {
|
||||
libraryMangas.forEach { manga ->
|
||||
val source = sourceManager.get(manga.libraryManga.manga.source) ?: return@forEach
|
||||
downloadManager.deleteManga(manga.libraryManga.manga, source)
|
||||
}
|
||||
}
|
||||
val set = libraryMangas.map { it.libraryManga.id }.toHashSet()
|
||||
toggleAllSelection(false)
|
||||
mutableState.update { state ->
|
||||
state.copy(
|
||||
items = state.items.filterNot { it.libraryManga.id in set },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun invertSelection() {
|
||||
mutableState.update { state ->
|
||||
val newItems = state.items.map {
|
||||
selectedMangaIds.addOrRemove(it.libraryManga.manga.id, !it.selected)
|
||||
it.copy(selected = !it.selected)
|
||||
}
|
||||
state.copy(items = newItems)
|
||||
}
|
||||
selectedPositions[0] = -1
|
||||
selectedPositions[1] = -1
|
||||
}
|
||||
|
||||
fun groupSelection(items: List<DownloadStatManga>) {
|
||||
val newSelected = items.map { manga -> manga.libraryManga.id }.toHashSet()
|
||||
selectedMangaIds.addAll(newSelected)
|
||||
mutableState.update { state ->
|
||||
val newItems = state.items.map {
|
||||
it.copy(selected = if (it.libraryManga.id in newSelected) !it.selected else it.selected)
|
||||
}
|
||||
state.copy(items = newItems)
|
||||
}
|
||||
selectedPositions[0] = -1
|
||||
selectedPositions[1] = -1
|
||||
}
|
||||
}
|
||||
|
||||
enum class GroupByMode {
|
||||
NONE,
|
||||
BY_CATEGORY,
|
||||
BY_SOURCE,
|
||||
}
|
||||
|
||||
enum class SortingMode {
|
||||
BY_ALPHABET,
|
||||
BY_SIZE,
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package eu.kanade.presentation.more.download
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class DownloadStatsScreenState(
|
||||
val isLoading: Boolean = true,
|
||||
val items: List<DownloadStatManga> = emptyList(),
|
||||
val groupByMode: GroupByMode = GroupByMode.NONE,
|
||||
val sortMode: SortingMode = SortingMode.BY_ALPHABET,
|
||||
val descendingOrder: Boolean = false,
|
||||
val searchQuery: String? = null,
|
||||
) {
|
||||
val selected = items.filter { it.selected }
|
||||
val selectionMode = selected.isNotEmpty()
|
||||
val processedItems: List<DownloadStatManga>
|
||||
get() = search(items, searchQuery, groupByMode)
|
||||
|
||||
fun search(items: List<DownloadStatManga>, searchQuery: String?, groupByMode: GroupByMode): List<DownloadStatManga> {
|
||||
return if (searchQuery != null) {
|
||||
items.filter { downloadStatManga ->
|
||||
downloadStatManga.libraryManga.manga.title.contains(searchQuery, true) ||
|
||||
if (groupByMode == GroupByMode.BY_SOURCE) { downloadStatManga.source.name.contains(searchQuery, true) } else { false } ||
|
||||
if (groupByMode == GroupByMode.BY_CATEGORY) { downloadStatManga.category.name.contains(searchQuery, true) } else { false }
|
||||
}
|
||||
} else {
|
||||
items
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,255 @@
|
||||
package eu.kanade.presentation.more.download
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.MaterialTheme.typography
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ArrowDropUp
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import eu.kanade.presentation.manga.components.DotSeparatorText
|
||||
import eu.kanade.presentation.manga.components.MangaCover
|
||||
import eu.kanade.tachiyomi.R
|
||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
||||
import tachiyomi.presentation.core.components.material.TextButton
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.util.selectedBackground
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
|
||||
fun LazyListScope.downloadStatUiItems(
|
||||
items: List<DownloadStatManga>,
|
||||
selectionMode: Boolean,
|
||||
onSelected: (DownloadStatManga, Boolean, Boolean, Boolean) -> Unit,
|
||||
onClick: (DownloadStatManga) -> Unit,
|
||||
onDeleteManga: (List<DownloadStatManga>) -> Unit,
|
||||
) {
|
||||
items(
|
||||
items = items,
|
||||
) { item ->
|
||||
DownloadStatUiItem(
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
selected = item.selected,
|
||||
onLongClick = {
|
||||
onSelected(item, !item.selected, true, true)
|
||||
},
|
||||
onClick = {
|
||||
when {
|
||||
selectionMode -> onSelected(item, !item.selected, true, false)
|
||||
else -> onClick(item)
|
||||
}
|
||||
},
|
||||
manga = item,
|
||||
onDeleteManga = { onDeleteManga(listOf(item)) }.takeIf { !selectionMode },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadStatUiItem(
|
||||
modifier: Modifier,
|
||||
manga: DownloadStatManga,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onDeleteManga: (() -> Unit)?,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val textAlpha = 1f
|
||||
Row(
|
||||
modifier = modifier
|
||||
.selectedBackground(selected)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
onLongClick()
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
)
|
||||
.height(56.dp)
|
||||
.padding(horizontal = MaterialTheme.padding.medium),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MangaCover.Square(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 6.dp)
|
||||
.fillMaxHeight(),
|
||||
data = manga.libraryManga.manga,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = MaterialTheme.padding.medium)
|
||||
.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = manga.libraryManga.manga.title,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = LocalContentColor.current.copy(alpha = textAlpha),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
var textHeight by remember { mutableIntStateOf(0) }
|
||||
FolderSizeText(manga.folderSize)
|
||||
DotSeparatorText()
|
||||
Text(
|
||||
text = String.format("%d %s", manga.downloadChaptersCount, stringResource(R.string.chapters)),
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalContentColor.current.copy(alpha = textAlpha),
|
||||
onTextLayout = { textHeight = it.size.height },
|
||||
modifier = Modifier
|
||||
.weight(weight = 1f, fill = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DownloadedIndicator(
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
onClick = { onDeleteManga?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListScope.downloadStatGroupUiItem(
|
||||
items: List<DownloadStatManga>,
|
||||
selectionMode: Boolean,
|
||||
onSelected: (DownloadStatManga, Boolean, Boolean, Boolean) -> Unit,
|
||||
onMangaClick: (DownloadStatManga) -> Unit,
|
||||
id: String,
|
||||
onDeleteManga: (List<DownloadStatManga>) -> Unit,
|
||||
onGroupSelected: (List<DownloadStatManga>) -> Unit,
|
||||
expanded: MutableMap<String, Boolean>,
|
||||
) {
|
||||
stickyHeader {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectedBackground(!items.fastAny { !it.selected })
|
||||
.combinedClickable(
|
||||
onClick = { expanded[id] = if (expanded[id] == null) { false } else { !expanded[id]!! } },
|
||||
onLongClick = { onGroupSelected(items) },
|
||||
)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
id,
|
||||
style = typography.h6,
|
||||
)
|
||||
DotSeparatorText()
|
||||
FolderSizeText(items.fold(0L) { acc, downloadStatManga -> acc + downloadStatManga.folderSize })
|
||||
Icon(
|
||||
imageVector = if (expanded[id] == true) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (expanded[id] == true) {
|
||||
downloadStatUiItems(
|
||||
items = items,
|
||||
onClick = onMangaClick,
|
||||
selectionMode = selectionMode,
|
||||
onDeleteManga = onDeleteManga,
|
||||
onSelected = onSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FolderSizeText(folderSizeBytes: Long) {
|
||||
val units = arrayOf(R.string.memory_unit_b, R.string.memory_unit_kb, R.string.memory_unit_mb, R.string.memory_unit_gb)
|
||||
val base = 1024.0
|
||||
val exponent = (ln(folderSizeBytes.toDouble()) / ln(base)).toInt()
|
||||
val size = folderSizeBytes / base.pow(exponent.toDouble())
|
||||
Text(
|
||||
text = String.format("%.2f %s", size, stringResource(units[exponent])),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadedIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.action_delete),
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CategoryList(
|
||||
contentPadding: PaddingValues,
|
||||
selectionMode: Boolean,
|
||||
onMangaClick: (DownloadStatManga) -> Unit,
|
||||
onDeleteManga: (List<DownloadStatManga>) -> Unit,
|
||||
onGroupSelected: (List<DownloadStatManga>) -> Unit,
|
||||
onSelected: (DownloadStatManga, Boolean, Boolean, Boolean) -> Unit,
|
||||
categoryMap: Map<String, List<DownloadStatManga>>,
|
||||
) {
|
||||
val expanded = remember {
|
||||
mutableStateMapOf(*categoryMap.keys.toList().map { it to false }.toTypedArray())
|
||||
}
|
||||
FastScrollLazyColumn(contentPadding = contentPadding) {
|
||||
categoryMap.forEach { (category, items) ->
|
||||
downloadStatGroupUiItem(
|
||||
id = category,
|
||||
items = items,
|
||||
selectionMode = selectionMode,
|
||||
onMangaClick = onMangaClick,
|
||||
onSelected = onSelected,
|
||||
onDeleteManga = onDeleteManga,
|
||||
onGroupSelected = onGroupSelected,
|
||||
expanded = expanded,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -20,6 +20,7 @@ import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||
import eu.kanade.core.preference.asState
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.presentation.more.MoreScreen
|
||||
import eu.kanade.presentation.more.download.DownloadStatsScreen
|
||||
import eu.kanade.presentation.util.Tab
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
@ -74,6 +75,7 @@ object MoreTab : Tab {
|
||||
onClickDataAndStorage = { navigator.push(SettingsScreen.toDataAndStorageScreen()) },
|
||||
onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) },
|
||||
onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) },
|
||||
onClickDownloadState = { navigator.push(DownloadStatsScreen()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +26,7 @@
|
||||
<string name="label_backup">Backup and restore</string>
|
||||
<string name="label_data_storage">Data and storage</string>
|
||||
<string name="label_stats">Statistics</string>
|
||||
<string name="label_download_stats">Download statistics</string>
|
||||
<string name="label_migration">Migrate</string>
|
||||
<string name="label_extensions">Extensions</string>
|
||||
<string name="label_extension_info">Extension info</string>
|
||||
@ -66,6 +67,7 @@
|
||||
<string name="action_sort_latest_chapter">Latest chapter</string>
|
||||
<string name="action_sort_chapter_fetch_date">Chapter fetch date</string>
|
||||
<string name="action_sort_date_added">Date added</string>
|
||||
<string name="action_sort_size">Size</string>
|
||||
<string name="action_search">Search</string>
|
||||
<string name="action_search_hint">Search…</string>
|
||||
<string name="action_search_settings">Search settings</string>
|
||||
@ -107,6 +109,9 @@
|
||||
<string name="action_open_in_browser">Open in browser</string>
|
||||
<string name="action_show_manga">Show entry</string>
|
||||
<string name="action_copy_to_clipboard">Copy to clipboard</string>
|
||||
<string name="action_group_by_category">Category</string>
|
||||
<string name="action_group_by_source">Source</string>
|
||||
<string name="action_ungroup">ungroup</string>
|
||||
<!-- Do not translate "WebView" -->
|
||||
<string name="action_open_in_web_view">Open in WebView</string>
|
||||
<string name="action_web_view" translatable="false">WebView</string>
|
||||
@ -131,6 +136,7 @@
|
||||
<string name="action_cancel_all">Cancel all</string>
|
||||
<string name="cancel_all_for_series">Cancel all for this series</string>
|
||||
<string name="action_sort">Sort</string>
|
||||
<string name="action_group">Group</string>
|
||||
<string name="action_order_by_upload_date">By upload date</string>
|
||||
<string name="action_order_by_chapter_number">By chapter number</string>
|
||||
<string name="action_newest">Newest</string>
|
||||
@ -158,6 +164,13 @@
|
||||
<string name="action_faq_and_guides">FAQ and Guides</string>
|
||||
<string name="action_not_now">Not now</string>
|
||||
|
||||
<!-- Memory units -->
|
||||
<string name="memory_unit_b">B</string>
|
||||
<string name="memory_unit_kb">KB</string>
|
||||
<string name="memory_unit_mb">MB</string>
|
||||
<string name="memory_unit_gb">GB</string>
|
||||
|
||||
|
||||
<!-- Operations -->
|
||||
<string name="loading">Loading…</string>
|
||||
<string name="internal_error">InternalError: Check crash logs for further information</string>
|
||||
|
Loading…
Reference in New Issue
Block a user