add download stats screen

This commit is contained in:
semenvav 2023-07-18 22:49:47 +03:00
parent 2ee895ee3c
commit c14fcfdec3
8 changed files with 989 additions and 0 deletions

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CloudOff 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.GetApp
import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
@ -48,6 +49,7 @@ fun MoreScreen(
onClickDataAndStorage: () -> Unit, onClickDataAndStorage: () -> Unit,
onClickSettings: () -> Unit, onClickSettings: () -> Unit,
onClickAbout: () -> Unit, onClickAbout: () -> Unit,
onClickDownloadState: () -> Unit,
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
@ -139,6 +141,13 @@ fun MoreScreen(
onPreferenceClick = onClickStats, onPreferenceClick = onClickStats,
) )
} }
item {
TextPreferenceWidget(
title = stringResource(R.string.label_download_stats),
icon = Icons.Outlined.FileDownloadDone,
onPreferenceClick = onClickDownloadState,
)
}
item { item {
TextPreferenceWidget( TextPreferenceWidget(
title = stringResource(R.string.label_data_storage), title = stringResource(R.string.label_data_storage),

View File

@ -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,
)

View File

@ -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) },
)
}
}
}

View File

@ -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,
}

View File

@ -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
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -20,6 +20,7 @@ import cafe.adriel.voyager.navigator.tab.TabOptions
import eu.kanade.core.preference.asState import eu.kanade.core.preference.asState
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.presentation.more.MoreScreen import eu.kanade.presentation.more.MoreScreen
import eu.kanade.presentation.more.download.DownloadStatsScreen
import eu.kanade.presentation.util.Tab import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
@ -74,6 +75,7 @@ object MoreTab : Tab {
onClickDataAndStorage = { navigator.push(SettingsScreen.toDataAndStorageScreen()) }, onClickDataAndStorage = { navigator.push(SettingsScreen.toDataAndStorageScreen()) },
onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) }, onClickSettings = { navigator.push(SettingsScreen.toMainScreen()) },
onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) }, onClickAbout = { navigator.push(SettingsScreen.toAboutScreen()) },
onClickDownloadState = { navigator.push(DownloadStatsScreen()) },
) )
} }
} }

View File

@ -26,6 +26,7 @@
<string name="label_backup">Backup and restore</string> <string name="label_backup">Backup and restore</string>
<string name="label_data_storage">Data and storage</string> <string name="label_data_storage">Data and storage</string>
<string name="label_stats">Statistics</string> <string name="label_stats">Statistics</string>
<string name="label_download_stats">Download statistics</string>
<string name="label_migration">Migrate</string> <string name="label_migration">Migrate</string>
<string name="label_extensions">Extensions</string> <string name="label_extensions">Extensions</string>
<string name="label_extension_info">Extension info</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_latest_chapter">Latest chapter</string>
<string name="action_sort_chapter_fetch_date">Chapter fetch date</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_date_added">Date added</string>
<string name="action_sort_size">Size</string>
<string name="action_search">Search</string> <string name="action_search">Search</string>
<string name="action_search_hint">Search…</string> <string name="action_search_hint">Search…</string>
<string name="action_search_settings">Search settings</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_open_in_browser">Open in browser</string>
<string name="action_show_manga">Show entry</string> <string name="action_show_manga">Show entry</string>
<string name="action_copy_to_clipboard">Copy to clipboard</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" --> <!-- Do not translate "WebView" -->
<string name="action_open_in_web_view">Open in WebView</string> <string name="action_open_in_web_view">Open in WebView</string>
<string name="action_web_view" translatable="false">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="action_cancel_all">Cancel all</string>
<string name="cancel_all_for_series">Cancel all for this series</string> <string name="cancel_all_for_series">Cancel all for this series</string>
<string name="action_sort">Sort</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_upload_date">By upload date</string>
<string name="action_order_by_chapter_number">By chapter number</string> <string name="action_order_by_chapter_number">By chapter number</string>
<string name="action_newest">Newest</string> <string name="action_newest">Newest</string>
@ -158,6 +164,13 @@
<string name="action_faq_and_guides">FAQ and Guides</string> <string name="action_faq_and_guides">FAQ and Guides</string>
<string name="action_not_now">Not now</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 --> <!-- Operations -->
<string name="loading">Loading…</string> <string name="loading">Loading…</string>
<string name="internal_error">InternalError: Check crash logs for further information</string> <string name="internal_error">InternalError: Check crash logs for further information</string>