From c14fcfdec3f1f9e3866d4248118743f6b8011daf Mon Sep 17 00:00:00 2001 From: semenvav Date: Tue, 18 Jul 2023 22:49:47 +0300 Subject: [PATCH] add download stats screen --- .../eu/kanade/presentation/more/MoreScreen.kt | 9 + .../more/download/DownloadStatManga.kt | 16 + .../more/download/DownloadStatsScreen.kt | 309 +++++++++++++++ .../more/download/DownloadStatsScreenModel.kt | 355 ++++++++++++++++++ .../more/download/DownloadStatsScreenState.kt | 30 ++ .../more/download/DownloadstatsContent.kt | 255 +++++++++++++ .../eu/kanade/tachiyomi/ui/more/MoreTab.kt | 2 + i18n/src/main/res/values/strings.xml | 13 + 8 files changed, 989 insertions(+) create mode 100644 app/src/main/java/eu/kanade/presentation/more/download/DownloadStatManga.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenModel.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenState.kt create mode 100644 app/src/main/java/eu/kanade/presentation/more/download/DownloadstatsContent.kt diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt index 6f7e9e3d70..4152850fed 100644 --- a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt @@ -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), diff --git a/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatManga.kt b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatManga.kt new file mode 100644 index 0000000000..f72bfd2b14 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatManga.kt @@ -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, +) diff --git a/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreen.kt b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreen.kt new file mode 100644 index 0000000000..9d8d340317 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreen.kt @@ -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, + onSelectAll: () -> Unit, + onInvertSelection: () -> Unit, + onCancelActionMode: () -> Unit, + onClickSort: (SortingMode) -> Unit, + onClickGroup: (GroupByMode) -> Unit, + onMultiDeleteClicked: (List) -> 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, + onSelectAll: () -> Unit, + onInvertSelection: () -> Unit, + onCancelActionMode: () -> Unit, + onMultiDeleteClicked: (List) -> 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) }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenModel.kt b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenModel.kt new file mode 100644 index 0000000000..022f893dcd --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenModel.kt @@ -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()) { + + private val downloadCache: DownloadCache by injectLazy() + + private val selectedPositions: Array = arrayOf(-1, -1) + private val selectedMangaIds: HashSet = 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, groupMode: GroupByMode, sortMode: SortingMode, descendingOrder: Boolean): Map> { + 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>(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>(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) { + 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) { + 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, +} diff --git a/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenState.kt b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenState.kt new file mode 100644 index 0000000000..2efbfe116f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenState.kt @@ -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 = 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 + get() = search(items, searchQuery, groupByMode) + + fun search(items: List, searchQuery: String?, groupByMode: GroupByMode): List { + 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 + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/download/DownloadstatsContent.kt b/app/src/main/java/eu/kanade/presentation/more/download/DownloadstatsContent.kt new file mode 100644 index 0000000000..33852a183c --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/DownloadstatsContent.kt @@ -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, + selectionMode: Boolean, + onSelected: (DownloadStatManga, Boolean, Boolean, Boolean) -> Unit, + onClick: (DownloadStatManga) -> Unit, + onDeleteManga: (List) -> 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, + selectionMode: Boolean, + onSelected: (DownloadStatManga, Boolean, Boolean, Boolean) -> Unit, + onMangaClick: (DownloadStatManga) -> Unit, + id: String, + onDeleteManga: (List) -> Unit, + onGroupSelected: (List) -> Unit, + expanded: MutableMap, +) { + 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) -> Unit, + onGroupSelected: (List) -> Unit, + onSelected: (DownloadStatManga, Boolean, Boolean, Boolean) -> Unit, + categoryMap: Map>, +) { + 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, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt index 8179fe68d4..f334e94e5b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt @@ -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()) }, ) } } diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 11df5d7014..b9b1ad12d8 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ Backup and restore Data and storage Statistics + Download statistics Migrate Extensions Extension info @@ -66,6 +67,7 @@ Latest chapter Chapter fetch date Date added + Size Search Search… Search settings @@ -107,6 +109,9 @@ Open in browser Show entry Copy to clipboard + Category + Source + ungroup Open in WebView WebView @@ -131,6 +136,7 @@ Cancel all Cancel all for this series Sort + Group By upload date By chapter number Newest @@ -158,6 +164,13 @@ FAQ and Guides Not now + + B + KB + MB + GB + + Loading… InternalError: Check crash logs for further information