diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 9967e000f3..a2c85e87e2 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -26,6 +26,7 @@ import tachiyomi.data.manga.MangaRepositoryImpl import tachiyomi.data.release.ReleaseServiceImpl import tachiyomi.data.source.SourceRepositoryImpl import tachiyomi.data.source.StubSourceRepositoryImpl +import tachiyomi.data.stat.DownloadStatRepositoryImpl import tachiyomi.data.track.TrackRepositoryImpl import tachiyomi.data.updates.UpdatesRepositoryImpl import tachiyomi.domain.category.interactor.CreateCategoryWithName @@ -69,6 +70,9 @@ import tachiyomi.domain.source.interactor.GetRemoteManga import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga import tachiyomi.domain.source.repository.SourceRepository import tachiyomi.domain.source.repository.StubSourceRepository +import tachiyomi.domain.stat.interactor.AddDownloadStatOperation +import tachiyomi.domain.stat.interactor.GetDownloadStatOperations +import tachiyomi.domain.stat.repository.DownloadStatRepository import tachiyomi.domain.track.interactor.DeleteTrack import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.domain.track.interactor.GetTracksPerManga @@ -161,5 +165,9 @@ class DomainModule : InjektModule { addFactory { ToggleLanguage(get()) } addFactory { ToggleSource(get()) } addFactory { ToggleSourcePin(get()) } + + addSingletonFactory { DownloadStatRepositoryImpl(get()) } + addFactory { GetDownloadStatOperations(get()) } + addFactory { AddDownloadStatOperation(get()) } } } diff --git a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt index 422b1f7dc6..637ec1f788 100644 --- a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt @@ -100,5 +100,6 @@ data class TabContent( val badgeNumber: Int? = null, val searchEnabled: Boolean = false, val actions: List = emptyList(), + val actionModeActions: List = emptyList(), val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit, ) 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 index c1a9fe19db..e07ed970e5 100644 --- a/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreen.kt @@ -1,26 +1,30 @@ package eu.kanade.presentation.more.download import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize 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.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import cafe.adriel.voyager.core.model.rememberScreenModel @@ -28,15 +32,23 @@ 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.more.download.components.DeleteAlertDialog +import eu.kanade.presentation.more.download.components.DownloadStatOperationInfoDialog +import eu.kanade.presentation.more.download.components.DownloadStatOperationInfoStartDialog +import eu.kanade.presentation.more.download.components.DownloadStatOperationMultiInfoDialog +import eu.kanade.presentation.more.download.components.DownloadStatSettingsDialog +import eu.kanade.presentation.more.download.data.DownloadStatManga +import eu.kanade.presentation.more.download.tabs.downloadStatsItemsTab +import eu.kanade.presentation.more.download.tabs.overAllStatsTab 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 kotlinx.coroutines.launch +import tachiyomi.presentation.core.components.HorizontalPager import tachiyomi.presentation.core.components.material.Scaffold -import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.components.material.TabIndicator +import tachiyomi.presentation.core.components.material.TabText import tachiyomi.presentation.core.screens.LoadingScreen class DownloadStatsScreen : Screen() { @@ -47,85 +59,131 @@ class DownloadStatsScreen : Screen() { val screenModel = rememberScreenModel { DownloadStatsScreenModel() } val state by screenModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + val tabs = listOf( + overAllStatsTab( + state = state, + screenModel = screenModel, + ), + downloadStatsItemsTab( + state = state, + screenModel = screenModel, + ), + ) + + val scope = rememberCoroutineScope() + + val pagerState = rememberPagerState(initialPage = screenModel.activeCategoryIndex) { tabs.size } + + val tab = tabs[pagerState.currentPage] + + when (val dialog = state.dialog) { + is Dialog.SettingsSheet -> run { + DownloadStatSettingsDialog( + onDismissRequest = screenModel::dismissDialog, + descendingOrder = state.descendingOrder, + sortMode = state.sortMode, + groupByMode = state.groupByMode, + showNotDownloaded = state.showNotDownloaded, + onSort = screenModel::runSort, + onGroup = screenModel::runGroupBy, + toggleShowNotDownloaded = screenModel::toggleShowNoDownload, + ) + } + is Dialog.DeleteManga -> { + DeleteAlertDialog( + onDismissRequest = screenModel::dismissDialog, + onConfirm = { screenModel.deleteMangas(dialog.items) }, + ) + } + is Dialog.DownloadStatOperationInfo -> { + DownloadStatOperationInfoDialog( + onDismissRequest = screenModel::dismissDialog, + onMangaClick = { mangaId -> navigator.push(MangaScreen(mangaId)) }, + item = dialog.downloadStatOperation, + findManga = screenModel::findManga, + ) + } + + is Dialog.MultiMangaDownloadStatOperationInfo -> { + DownloadStatOperationMultiInfoDialog( + onDismissRequest = screenModel::dismissDialog, + onMangaClick = { mangaId -> navigator.push(MangaScreen(mangaId)) }, + items = dialog.downloadStatOperation, + findManga = screenModel::findManga, + ) + } + + is Dialog.DownloadStatOperationStart -> { + DownloadStatOperationInfoStartDialog( + onDismissRequest = screenModel::dismissDialog, + ) + } + + else -> {} + } 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, + defaultActions = tab.actions, + actionModeActions = tab.actionModeActions, + searchEnabled = tab.searchEnabled, + actionModeEnabled = tab.actionModeActions.isNotEmpty(), ) }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { 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), + if (state.isLoading) { + LoadingScreen(modifier = Modifier.padding(contentPadding)) + } else { + Column( + modifier = Modifier.padding( + top = contentPadding.calculateTopPadding(), + start = contentPadding.calculateStartPadding(LocalLayoutDirection.current), + end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), + ), + ) { + TabRow( + selectedTabIndex = pagerState.currentPage, + indicator = { + TabIndicator( + it[pagerState.currentPage], + pagerState.currentPageOffsetFraction, + ) + }, + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { TabText(text = stringResource(tab.titleRes)) }, + unselectedContentColor = MaterialTheme.colorScheme.onSurface, ) } } + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + verticalAlignment = Alignment.Top, + ) { page -> + tabs[page].content( + PaddingValues( + bottom = contentPadding.calculateBottomPadding(), + ), + snackbarHostState, + ) + } + LaunchedEffect(pagerState.currentPage) { + screenModel.activeCategoryIndex = pagerState.currentPage + } } } } @@ -134,32 +192,27 @@ class DownloadStatsScreen : Screen() { @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)?, + defaultActions: List, + actionModeActions: List, + searchEnabled: Boolean, + actionModeEnabled: Boolean, ) { - if (selected.isNotEmpty()) { - DownloadStatsActionAppBar( + if (actionModeEnabled && selected.isNotEmpty()) { + AppBar( modifier = modifier, - selected = selected, - onSelectAll = onSelectAll, - onInvertSelection = onInvertSelection, + title = stringResource(R.string.label_download_stats), onCancelActionMode = onCancelActionMode, + actionModeActions = { AppBarActions(actionModeActions) }, + actionModeCounter = selected.size, scrollBehavior = scrollBehavior, navigateUp = navigateUp, - onMultiDeleteClicked = onMultiDeleteClicked, ) BackHandler( onBack = onCancelActionMode, @@ -177,143 +230,16 @@ private fun DownloadStatsAppBar( ) } }, - searchQuery = searchQuery, + searchEnabled = searchEnabled, + searchQuery = searchQuery.takeIf { searchEnabled }, 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 }, - ), - ), - ) - }, + actions = { AppBarActions(defaultActions) }, scrollBehavior = scrollBehavior, ) - if (searchQuery != null) { + if (searchQuery != null && searchEnabled) { BackHandler( onBack = { onChangeSearchQuery(null) }, ) } } } - -@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, - actionModeActions = { - 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) }, - ), - ), - ) - }, - actionModeCounter = selected.size, - 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_A_Z), - 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 index 8366a3de38..b55dbf9d87 100644 --- a/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenModel.kt +++ b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenModel.kt @@ -1,26 +1,38 @@ package eu.kanade.presentation.more.download -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.coroutineScope -import eu.kanade.core.util.addOrRemove +import eu.kanade.core.preference.asState +import eu.kanade.presentation.more.download.components.graphic.GraphGroupByMode +import eu.kanade.presentation.more.download.components.graphic.GraphicPoint +import eu.kanade.presentation.more.download.data.DownloadStatManga 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 eu.kanade.tachiyomi.util.lang.toRelativeString +import eu.kanade.tachiyomi.util.preference.toggle import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking 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.library.model.LibraryManga import tachiyomi.domain.manga.interactor.GetLibraryManga +import tachiyomi.domain.manga.interactor.GetManga +import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.service.SourceManager +import tachiyomi.domain.stat.interactor.GetDownloadStatOperations +import tachiyomi.domain.stat.model.DownloadStatOperation import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy -import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.TreeMap class DownloadStatsScreenModel( @@ -30,87 +42,71 @@ class DownloadStatsScreenModel( private val downloadProvider: DownloadProvider = Injekt.get(), private val getCategories: GetCategories = Injekt.get(), private val preferenceStore: PreferenceStore = Injekt.get(), + private val getDownloadStatOperations: GetDownloadStatOperations = Injekt.get(), + private val getManga: GetManga = Injekt.get(), ) : StateScreenModel(DownloadStatsScreenState()) { - private val downloadCache: DownloadCache by injectLazy() + var activeCategoryIndex: Int by preferenceStore.getInt("downloadStatSelectedTab", 0).asState(coroutineScope) - private val selectedPositions: Array = arrayOf(-1, -1) - private val selectedMangaIds: HashSet = HashSet() + private lateinit var lastSelectedManga: LibraryManga init { coroutineScope.launchIO { val sortMode = preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).get() - mutableState.update { + mutableState.update { state -> val categories = getCategories.await().associateBy { group -> group.id } - it.copy( + val operations = getDownloadStatOperations.await() + state.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]!!, - ) + (downloadManager.getDownloadCount(libraryManga.manga) > 0) || operations.any { it.mangaId == libraryManga.id } + }.mapNotNull { libraryManga -> + val source = sourceManager.getOrStub(libraryManga.manga.source) + val path = downloadProvider.findMangaDir( + libraryManga.manga.title, + source, + )?.filePath + val downloadChaptersCount = downloadManager.getDownloadCount(libraryManga.manga) + if (downloadChaptersCount == 0) { + DownloadStatManga( + libraryManga = libraryManga, + source = source, + category = categories[libraryManga.category]!!, + ) + } else if (path != null) { + DownloadStatManga( + libraryManga = libraryManga, + source = source, + folderSize = downloadProvider.getFolderSize(path), + downloadChaptersCount = downloadChaptersCount, + category = categories[libraryManga.category]!!, + ) + } else { + null + } }, 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 != "" }, + downloadStatOperations = operations, + showNotDownloaded = preferenceStore.getBoolean("show_no_downloaded", false).get(), isLoading = false, ) } - runSortAction(sortMode) + runSort(sortMode, true) } } - 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 { - return if (file.isDirectory) { - getFolderSize(file.path) - } else if (file.isFile) { - file.length() - } else { - 0 - } - } - - fun runSortAction(mode: SortingMode) { + fun runSort( + mode: SortingMode, + initSort: Boolean = false, + ) { when (mode) { - SortingMode.BY_ALPHABET -> sortByAlphabet() - SortingMode.BY_SIZE -> sortBySize() + SortingMode.BY_ALPHABET -> sortByAlphabet(initSort) + SortingMode.BY_SIZE -> sortBySize(initSort) + SortingMode.BY_CHAPTERS -> sortByChapters(initSort) } + preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).set(mode) } fun runGroupBy(mode: GroupByMode) { @@ -119,11 +115,21 @@ class DownloadStatsScreenModel( GroupByMode.BY_CATEGORY -> groupByCategory() GroupByMode.BY_SOURCE -> groupBySource() } + preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(mode) } - private fun sortByAlphabet() { + fun toggleShowNoDownload() { + val showNotDownloaded = preferenceStore.getBoolean("show_no_downloaded").toggle() mutableState.update { state -> - val descendingOrder = if (state.sortMode == SortingMode.BY_ALPHABET) !state.descendingOrder else false + state.copy( + showNotDownloaded = showNotDownloaded, + ) + } + } + + private fun sortByAlphabet(initSort: Boolean) { + mutableState.update { state -> + val descendingOrder = if (initSort) state.descendingOrder else 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 }, @@ -131,13 +137,43 @@ class DownloadStatsScreenModel( 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> { + private fun sortBySize(initSort: Boolean) { + mutableState.update { state -> + val descendingOrder = if (initSort) state.descendingOrder else 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, + ) + } + } + + private fun sortByChapters(initSort: Boolean) { + mutableState.update { state -> + val descendingOrder = if (initSort) state.descendingOrder else if (state.sortMode == SortingMode.BY_CHAPTERS) !state.descendingOrder else false + preferenceStore.getBoolean("descending_order", false).set(descendingOrder) + state.copy( + items = if (descendingOrder) state.items.sortedByDescending { it.downloadChaptersCount } else state.items.sortedBy { it.downloadChaptersCount }, + descendingOrder = descendingOrder, + sortMode = SortingMode.BY_CHAPTERS, + ) + } + } + + fun categoryMap( + items: List, + groupMode: GroupByMode, + sortMode: SortingMode, + descendingOrder: Boolean, + defaultCategoryName: String?, + ): 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_CATEGORY -> items.groupBy { + if (it.category.isSystemCategory && defaultCategoryName != null) { defaultCategoryName } else { it.category.name } + } GroupByMode.BY_SOURCE -> items.groupBy { it.source.name } GroupByMode.NONE -> emptyMap() } @@ -153,29 +189,21 @@ class DownloadStatsScreenModel( sortedMap.putAll(unsortedMap) sortedMap } + SortingMode.BY_CHAPTERS -> { + val compareFun: (String) -> Comparable<*> = { it: String -> unsortedMap[it]?.sumOf { manga -> manga.downloadChaptersCount } ?: 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() { @@ -184,7 +212,6 @@ class DownloadStatsScreenModel( groupByMode = GroupByMode.BY_CATEGORY, ) } - preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.BY_CATEGORY) } private fun unGroup() { @@ -193,67 +220,66 @@ class DownloadStatsScreenModel( 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, ) { + lastSelectedManga = item.libraryManga 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 + state.copy( + items = state.items.map { + if (it.libraryManga.id == item.libraryManga.id) { + it.copy(selected = !it.selected) } 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 - } + it + } + }, + ) + } + } - 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 - } - } + fun toggleMassSelection(item: DownloadStatManga) { + mutableState.update { state -> + val processedItems = if (state.groupByMode == GroupByMode.NONE) { + state.processedItems(false) + } else { + val temp = mutableListOf() + categoryMap( + items = state.processedItems(false), + groupMode = state.groupByMode, + sortMode = state.sortMode, + descendingOrder = state.descendingOrder, + defaultCategoryName = null, + ).map { + temp.addAll(it.value) + } + temp + } + val lastSelectedIndex = + processedItems.indexOfFirst { it.libraryManga.id == lastSelectedManga.id && it.libraryManga.category == lastSelectedManga.category } + val selectedIndex = + processedItems.indexOfFirst { it.libraryManga.id == item.libraryManga.id && it.libraryManga.category == item.libraryManga.category } + val itemsToChange = mutableSetOf(processedItems[lastSelectedIndex].libraryManga.id) + val range = if (selectedIndex < lastSelectedIndex) { + selectedIndex until lastSelectedIndex + } else if (selectedIndex > lastSelectedIndex) { + (lastSelectedIndex + 1) until (selectedIndex + 1) + } else { + IntRange.EMPTY + } + range.forEach { + val betweenItem = processedItems[it] + if (!betweenItem.selected) { + lastSelectedManga = betweenItem.libraryManga + itemsToChange.add(betweenItem.libraryManga.id) + } + } + val newItems = state.items.map { + if (it.libraryManga.id in itemsToChange) { + it.copy(selected = true) + } else { + it } } state.copy(items = newItems) @@ -262,15 +288,45 @@ class DownloadStatsScreenModel( fun toggleAllSelection(selected: Boolean) { mutableState.update { state -> + val newSelected = state.processedItems(false).map { manga -> manga.libraryManga.id }.toHashSet() val newItems = state.items.map { - selectedMangaIds.addOrRemove(it.libraryManga.manga.id, selected) - it.copy(selected = selected) + if (it.libraryManga.id in newSelected) { + it.copy(selected = selected) + } else { + it + } } state.copy(items = newItems) } + } - selectedPositions[0] = -1 - selectedPositions[1] = -1 + fun invertSelection() { + mutableState.update { state -> + val newSelected = state.processedItems(false).map { manga -> manga.libraryManga.id }.toHashSet() + val newItems = state.items.map { + if (it.libraryManga.id in newSelected) { + it.copy(selected = !it.selected) + } else { + it + } + } + state.copy(items = newItems) + } + } + + fun groupSelection(items: List) { + val newSelected = items.map { manga -> manga.libraryManga.manga.id }.toHashSet() + lastSelectedManga = items.last().libraryManga + mutableState.update { state -> + val newItems = state.items.map { + if (it.libraryManga.manga.id in newSelected) { + it.copy(selected = !it.selected) + } else { + it + } + } + state.copy(items = newItems) + } } fun search(query: String?) { @@ -281,46 +337,143 @@ class DownloadStatsScreenModel( preferenceStore.getString("search_query", "").delete() } } + fun findManga(id: Long?): Manga? { + return runBlocking { if (id != null) getManga.await(id) else null } + } - fun deleteMangas(libraryMangas: List) { + fun deleteMangas(manga: List) { coroutineScope.launchNonCancellable { - libraryMangas.forEach { manga -> - val source = sourceManager.get(manga.libraryManga.manga.source) ?: return@forEach - downloadManager.deleteManga(manga.libraryManga.manga, source) + manga.forEach { manga -> + downloadManager.deleteManga(manga.libraryManga.manga, manga.source) } } - val set = libraryMangas.map { it.libraryManga.id }.toHashSet() + val toDeleteIds = manga.map { it.libraryManga.manga.id }.toHashSet() toggleAllSelection(false) mutableState.update { state -> state.copy( - items = state.items.filterNot { it.libraryManga.id in set }, + items = state.items.map { if (it.libraryManga.manga.id in toDeleteIds) it.copy(downloadChaptersCount = 0, folderSize = 0) else it }, ) } } - 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 showDeleteAlert(items: List) { + mutableState.update { it.copy(dialog = Dialog.DeleteManga(items)) } } - 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) + fun dismissDialog() { + mutableState.update { it.copy(dialog = null) } + } + + fun openSettingsDialog() { + mutableState.update { it.copy(dialog = Dialog.SettingsSheet) } + } + + fun setDialog(dialog: Dialog?) { + mutableState.update { it.copy(dialog = dialog) } + } + + fun formGraphData( + downloadStatOperations: List, + currentWeight: Long, + graphGroupByMode: GraphGroupByMode, + context: Context, + ): List { + var weight = currentWeight.toFloat() + val pointsList = mutableListOf() + for (i in downloadStatOperations.indices.reversed()) { + weight -= downloadStatOperations[i].size + } + pointsList.add( + GraphicPoint( + coordinate = weight, + subLine = context.getString(R.string.graph_start_point_subLine), + dialog = Dialog.DownloadStatOperationStart, + ), + ) + when (graphGroupByMode) { + GraphGroupByMode.NONE -> { + for (i in downloadStatOperations) { + weight += i.size + pointsList.add( + GraphicPoint( + coordinate = weight, + subLine = Date(i.date).toRelativeString( + context = context, + range = 0, + ), + dialog = Dialog.DownloadStatOperationInfo(i), + ), + ) + } + } + GraphGroupByMode.BY_DAY -> { + val dateMap = mutableMapOf>() + for (i in downloadStatOperations) { + weight += i.size + val key = Date(i.date).toRelativeString( + context = context, + range = 0, + ) + if (dateMap.containsKey(key)) { + dateMap[key]?.add(i) + } else { + dateMap[key] = mutableListOf(i) + } + } + for (i in dateMap) { + weight += i.value.sumOf { it.size } + pointsList.add( + GraphicPoint( + coordinate = weight, + subLine = i.key, + dialog = Dialog.MultiMangaDownloadStatOperationInfo( + i.value, + ), + ), + ) + } + } + GraphGroupByMode.BY_MONTH -> { + val dateMap = mutableMapOf>() + val dateFormat = SimpleDateFormat("MM.yyyy", Locale.US) + for (i in downloadStatOperations) { + weight += i.size + val key = dateFormat.format(Date(i.date)) + if (dateMap.containsKey(key)) { + dateMap[key]?.add(i) + } else { + dateMap[key] = mutableListOf(i) + } + } + for (i in dateMap) { + weight += i.value.sumOf { it.size } + pointsList.add( + GraphicPoint( + coordinate = weight, + subLine = i.key, + dialog = Dialog.MultiMangaDownloadStatOperationInfo( + i.value, + ), + ), + ) + } + } + } + return pointsList + } + + fun toggleExpanded(manga: DownloadStatManga) { + mutableState.update { state -> + state.copy( + items = state.items.map { + if (it.libraryManga.id == manga.libraryManga.id && it.libraryManga.category == manga.libraryManga.category) { + it.copy(expanded = !it.expanded) + } else { + it + } + }, + ) } - selectedPositions[0] = -1 - selectedPositions[1] = -1 } } @@ -333,4 +486,13 @@ enum class GroupByMode { enum class SortingMode { BY_ALPHABET, BY_SIZE, + BY_CHAPTERS, +} + +sealed class Dialog { + data class DeleteManga(val items: List) : Dialog() + data class DownloadStatOperationInfo(val downloadStatOperation: DownloadStatOperation) : Dialog() + data class MultiMangaDownloadStatOperationInfo(val downloadStatOperation: List) : Dialog() + object DownloadStatOperationStart : Dialog() + object SettingsSheet : Dialog() } 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 index 2efbfe116f..51732512ac 100644 --- a/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenState.kt +++ b/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatsScreenState.kt @@ -1,6 +1,8 @@ package eu.kanade.presentation.more.download import androidx.compose.runtime.Immutable +import eu.kanade.presentation.more.download.data.DownloadStatManga +import tachiyomi.domain.stat.model.DownloadStatOperation @Immutable data class DownloadStatsScreenState( @@ -10,21 +12,35 @@ data class DownloadStatsScreenState( val sortMode: SortingMode = SortingMode.BY_ALPHABET, val descendingOrder: Boolean = false, val searchQuery: String? = null, + val showNotDownloaded: Boolean = false, + val dialog: Dialog? = null, + val downloadStatOperations: List = emptyList(), ) { 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 } + fun uniqueItems(): List { + val uniqueIds = HashSet() + val uniqueMangas = mutableListOf() + for (manga in items) { + if (uniqueIds.add(manga.libraryManga.manga.id)) { + uniqueMangas.add(manga) + } + } + return uniqueMangas + } + + fun processedItems(unique: Boolean): List { + return (if (unique) uniqueItems() else items).filter { + if (!showNotDownloaded) it.downloadChaptersCount > 0 else true + }.filter { + if (searchQuery != null) { + it.libraryManga.manga.title.contains(searchQuery, true) || + if (groupByMode == GroupByMode.BY_SOURCE) { it.source.name.contains(searchQuery, true) } else { false } || + if (groupByMode == GroupByMode.BY_CATEGORY) { it.category.name.contains(searchQuery, true) } else { false } + } else { + true } - } 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 deleted file mode 100644 index d98755b2ba..0000000000 --- a/app/src/main/java/eu/kanade/presentation/more/download/DownloadstatsContent.kt +++ /dev/null @@ -1,265 +0,0 @@ -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.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -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 categoryExpandedMapSaver: Saver, *> = Saver( - save = { map -> map.toMap() }, - restore = { map -> mutableStateMapOf(*map.toList().toTypedArray()) }, - ) - - val expanded = rememberSaveable( - saver = categoryExpandedMapSaver, - key = "CategoryExpandedMap", - init = { 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/presentation/more/download/components/DownloadStatDialog.kt b/app/src/main/java/eu/kanade/presentation/more/download/components/DownloadStatDialog.kt new file mode 100644 index 0000000000..f33fd42d64 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/components/DownloadStatDialog.kt @@ -0,0 +1,337 @@ +package eu.kanade.presentation.more.download.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +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.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 androidx.compose.ui.unit.dp +import eu.kanade.presentation.components.TabbedDialog +import eu.kanade.presentation.components.TabbedDialogPaddings +import eu.kanade.presentation.manga.components.MangaCover +import eu.kanade.presentation.more.download.GroupByMode +import eu.kanade.presentation.more.download.SortingMode +import eu.kanade.tachiyomi.R +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.stat.model.DownloadStatOperation +import tachiyomi.presentation.core.components.CheckboxItem +import tachiyomi.presentation.core.components.LazyColumn +import tachiyomi.presentation.core.components.RadioItem +import tachiyomi.presentation.core.components.SortItem +import tachiyomi.presentation.core.components.material.TextButton +import tachiyomi.presentation.core.components.material.padding +import kotlin.math.abs + +@Composable +fun DeleteAlertDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + TextButton( + onClick = onDismissRequest, + ) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + onDismissRequest() + onConfirm() + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + title = { + Text(text = stringResource(R.string.are_you_sure)) + }, + text = { + Text(text = stringResource(R.string.confirm_delete_entries)) + }, + ) +} + +@Composable +fun DownloadStatOperationInfoDialog( + findManga: (Long?) -> Manga?, + onMangaClick: (Long) -> Unit, + onDismissRequest: () -> Unit, + item: DownloadStatOperation, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + text = { + val manga = findManga(item.mangaId) + if (manga != null) { + MangaOperationsRow( + manga = manga, + onMangaClick = { onMangaClick(manga.id) }, + items = listOf(item), + ) + } else { + NoMangaOperationsRow(listOf(item)) + } + }, + ) +} + +@Composable +fun DownloadStatOperationMultiInfoDialog( + findManga: (Long?) -> Manga?, + onMangaClick: (Long) -> Unit, + onDismissRequest: () -> Unit, + items: List, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + text = { + LazyColumn { + items(items = items.groupBy { it.mangaId }.values.toList()) { items -> + val manga = findManga(items.first().mangaId) + if (manga != null) { + MangaOperationsRow( + manga = manga, + onMangaClick = { onMangaClick(manga.id) }, + items = items, + ) + } else { + NoMangaOperationsRow(items) + } + } + } + }, + ) +} + +@Composable +private fun MangaOperationsRow( + manga: Manga, + onMangaClick: () -> Unit, + items: List, +) { + Row( + modifier = Modifier.height(120.dp), + ) { + MangaCover.Book( + modifier = Modifier + .padding(vertical = 6.dp) + .fillMaxHeight(), + data = manga, + onClick = onMangaClick, + ) + MangaInfoColumn(items, manga.title) + } +} + +@Composable +private fun MangaInfoColumn( + items: List, + title: String, +) { + val downloadItems = items.filter { it.size > 0 } + val deleteItems = items.filter { it.size < 0 } + Column( + modifier = Modifier + .padding(horizontal = MaterialTheme.padding.medium), + ) { + Text( + text = title, + maxLines = 2, + style = MaterialTheme.typography.titleSmall, + color = LocalContentColor.current.copy(alpha = 1f), + overflow = TextOverflow.Ellipsis, + ) + if (downloadItems.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + var textHeight by remember { mutableIntStateOf(0) } + Text( + text = String.format( + stringResource(R.string.download_stat_operation_downloaded), + downloadItems.sumOf { it.units }, + folderSizeText( + folderSizeBytes = downloadItems.sumOf { it.size }, + ), + ), + maxLines = 2, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 1f), + onTextLayout = { textHeight = it.size.height }, + modifier = Modifier + .weight(weight = 1f, fill = false), + ) + } + } + if (deleteItems.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + var textHeight by remember { mutableIntStateOf(0) } + Text( + text = String.format( + stringResource(R.string.download_stat_operation_deleted), + deleteItems.sumOf { it.units }, + folderSizeText( + folderSizeBytes = abs(deleteItems.sumOf { it.size }), + ), + ), + maxLines = 2, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 1f), + onTextLayout = { textHeight = it.size.height }, + modifier = Modifier + .weight(weight = 1f, fill = false), + ) + } + } + } +} + +@Composable +private fun NoMangaOperationsRow( + items: List, +) { + Row { + MangaInfoColumn(items, stringResource(R.string.entry_was_deleted)) + } +} + +@Composable +fun DownloadStatOperationInfoStartDialog( + onDismissRequest: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + text = { + Text(stringResource(R.string.start_operation_massage)) + }, + ) +} + +@Composable +fun DownloadStatSettingsDialog( + onDismissRequest: () -> Unit, + sortMode: SortingMode, + descendingOrder: Boolean, + groupByMode: GroupByMode, + showNotDownloaded: Boolean, + onSort: (SortingMode) -> Unit, + onGroup: (GroupByMode) -> Unit, + toggleShowNotDownloaded: () -> Unit, +) { + TabbedDialog( + onDismissRequest = onDismissRequest, + tabTitles = listOf( + stringResource(R.string.action_sort), + stringResource(R.string.action_group), + stringResource(R.string.action_settings), + ), + ) { page -> + Column( + modifier = Modifier + .padding(vertical = TabbedDialogPaddings.Vertical) + .verticalScroll(rememberScrollState()), + ) { + when (page) { + 0 -> SortPage( + onSort = onSort, + descendingOrder = descendingOrder, + sortMode = sortMode, + + ) + 1 -> GroupPage( + onGroup = onGroup, + groupByMode = groupByMode, + ) + 2 -> SettingPage( + toggleShowNotDownloaded = toggleShowNotDownloaded, + showNotDownloaded = showNotDownloaded, + ) + } + } + } +} + +@Composable +private fun SortPage( + onSort: (SortingMode) -> Unit, + descendingOrder: Boolean, + sortMode: SortingMode, +) { + listOfNotNull( + SortingMode.BY_ALPHABET to stringResource(R.string.action_sort_alpha), + SortingMode.BY_SIZE to stringResource(R.string.action_sort_size), + SortingMode.BY_CHAPTERS to stringResource(R.string.chapters), + ).map { (mode, string) -> + SortItem( + label = string, + sortDescending = descendingOrder.takeIf { sortMode == mode }, + onClick = { onSort(mode) }, + ) + } +} + +@Composable +private fun GroupPage( + onGroup: (GroupByMode) -> Unit, + groupByMode: GroupByMode, +) { + listOf( + GroupByMode.NONE to stringResource(R.string.action_ungroup), + GroupByMode.BY_CATEGORY to stringResource(R.string.action_group_by_category), + GroupByMode.BY_SOURCE to stringResource(R.string.action_group_by_source), + ).map { (mode, string) -> + RadioItem( + label = string, + selected = groupByMode == mode, + onClick = { onGroup(mode) }, + ) + } +} + +@Composable +private fun SettingPage( + toggleShowNotDownloaded: () -> Unit, + showNotDownloaded: Boolean, +) { + CheckboxItem( + label = stringResource(R.string.show_entries_without_downloaded), + checked = showNotDownloaded, + onClick = toggleShowNotDownloaded, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/more/download/components/DownloadstatsContent.kt b/app/src/main/java/eu/kanade/presentation/more/download/components/DownloadstatsContent.kt new file mode 100644 index 0000000000..775b33bf41 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/components/DownloadstatsContent.kt @@ -0,0 +1,475 @@ +package eu.kanade.presentation.more.download.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +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.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.SnackbarHostState +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +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.TextStyle +import androidx.compose.ui.text.font.FontWeight +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.presentation.more.download.components.graphic.GraphGroupByMode +import eu.kanade.presentation.more.download.components.graphic.GraphicPoint +import eu.kanade.presentation.more.download.components.graphic.PointGraph +import eu.kanade.presentation.more.download.data.DownloadStatManga +import eu.kanade.tachiyomi.R +import kotlinx.coroutines.launch +import okhttp3.internal.format +import tachiyomi.domain.stat.model.DownloadStatOperation +import tachiyomi.presentation.core.components.FastScrollLazyColumn +import tachiyomi.presentation.core.components.material.SecondaryItemAlpha +import tachiyomi.presentation.core.components.material.TextButton +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.util.selectedBackground +import kotlin.math.abs +import kotlin.math.ln +import kotlin.math.pow + +@Composable +fun CategoryList( + contentPadding: PaddingValues, + selectionMode: Boolean, + onCoverClick: (DownloadStatManga) -> Unit, + onDeleteManga: (List) -> Unit, + onGroupSelected: (List) -> Unit, + onSelected: (DownloadStatManga) -> Unit, + onMassSelected: (DownloadStatManga) -> Unit, + categoryMap: Map>, + toggleExpanded: (DownloadStatManga) -> Unit, + operations: List, + getGraphPoints: (List, Long, GraphGroupByMode) -> List, + snackbarHostState: SnackbarHostState, +) { + val categoryExpandedMapSaver: Saver, *> = Saver( + save = { map -> map.toMap() }, + restore = { map -> mutableStateMapOf(*map.toList().toTypedArray()) }, + ) + + val expanded = rememberSaveable( + saver = categoryExpandedMapSaver, + init = { mutableStateMapOf(*categoryMap.keys.toList().map { it to false }.toTypedArray()) }, + ) + + FastScrollLazyColumn(contentPadding = contentPadding) { + categoryMap.forEach { (category, items) -> + downloadStatGroupUiItem( + title = category, + items = items, + selectionMode = selectionMode, + onCoverClick = onCoverClick, + onSelected = onSelected, + onMassSelected = onMassSelected, + onDeleteManga = onDeleteManga, + onGroupSelected = onGroupSelected, + expanded = expanded, + operations = operations, + getGraphPoints = getGraphPoints, + snackbarHostState = snackbarHostState, + toggleExpanded = toggleExpanded, + ) + } + } +} + +fun LazyListScope.downloadStatGroupUiItem( + items: List, + selectionMode: Boolean, + onSelected: (DownloadStatManga) -> Unit, + onMassSelected: (DownloadStatManga) -> Unit, + onCoverClick: (DownloadStatManga) -> Unit, + title: String, + onDeleteManga: (List) -> Unit, + onGroupSelected: (List) -> Unit, + toggleExpanded: (DownloadStatManga) -> Unit, + expanded: MutableMap, + operations: List, + getGraphPoints: (List, Long, GraphGroupByMode) -> List, + snackbarHostState: SnackbarHostState, +) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .selectedBackground(!items.fastAny { !it.selected }) + .combinedClickable( + onClick = { + expanded[title] = if (expanded[title] == null) { + false + } else { + !expanded[title]!! + } + }, + onLongClick = { onGroupSelected(items) }, + ) + .padding(horizontal = MaterialTheme.padding.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = title, + maxLines = 1, + style = MaterialTheme.typography.titleLarge, + color = LocalContentColor.current.copy(alpha = 1f), + overflow = TextOverflow.Ellipsis, + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + var textHeight by remember { mutableIntStateOf(0) } + Text( + text = folderSizeText(items.sumOf { downloadStatManga -> downloadStatManga.folderSize }), + ) + DotSeparatorText() + Text( + text = format( + stringResource(R.string.group_info), + items.sumOf { it.downloadChaptersCount }, + items.size, + ), + maxLines = 1, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 1f), + onTextLayout = { textHeight = it.size.height }, + modifier = Modifier + .weight(weight = 1f, fill = false), + ) + } + } + Icon( + imageVector = if (expanded[title] == true) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + if (expanded[title] == true) { + downloadStatUiItems( + items = items, + onCoverClick = onCoverClick, + selectionMode = selectionMode, + onDeleteManga = onDeleteManga, + onSelected = onSelected, + onMassSelected = onMassSelected, + toggleExpanded = toggleExpanded, + operations = operations, + getGraphPoints = getGraphPoints, + snackbarHostState = snackbarHostState, + ) + } +} + +fun LazyListScope.downloadStatUiItems( + items: List, + selectionMode: Boolean, + onSelected: (DownloadStatManga) -> Unit, + onMassSelected: (DownloadStatManga) -> Unit, + onCoverClick: (DownloadStatManga) -> Unit, + onDeleteManga: (List) -> Unit, + toggleExpanded: (DownloadStatManga) -> Unit, + operations: List, + getGraphPoints: (List, Long, GraphGroupByMode) -> List, + snackbarHostState: SnackbarHostState, +) { + items( + items = items, + ) { item -> + + val scope = rememberCoroutineScope() + val noDataString = stringResource(R.string.no_stat_data) + + val mangaOperations = operations.filter { it.mangaId == item.libraryManga.id } + + DownloadStatUiItem( + modifier = Modifier.animateItemPlacement(), + onLongClick = { + if (selectionMode) { + onMassSelected(item) + } else { + onSelected(item) + } + }, + onClick = { + if (selectionMode) { + onSelected(item) + } else if (mangaOperations.isNotEmpty()) { + toggleExpanded(item) + } else { + scope.launch { snackbarHostState.showSnackbar(noDataString) } + } + }, + onCoverClick = { onCoverClick(item) }, + manga = item, + onDeleteManga = { onDeleteManga(listOf(item)) }.takeIf { item.downloadChaptersCount > 0 }, + operations = mangaOperations, + getGraphPoints = getGraphPoints, + ) + } +} + +@Composable +private fun DownloadStatUiItem( + modifier: Modifier, + manga: DownloadStatManga, + onCoverClick: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + onDeleteManga: (() -> Unit)?, + operations: List, + getGraphPoints: (List, Long, GraphGroupByMode) -> List, +) { + val haptic = LocalHapticFeedback.current + + Column( + modifier = modifier + .selectedBackground(manga.selected) + .combinedClickable( + onClick = onClick, + onLongClick = { + onLongClick() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) + .padding(horizontal = MaterialTheme.padding.medium), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + ) { + MangaBaseInfoRow(modifier, manga, onCoverClick, onDeleteManga) + if (manga.expanded) { + MangaDownloadStatSection(operations) + PointGraph( + getPoints = { groupByMode -> + getGraphPoints( + operations, + manga.folderSize, + groupByMode, + ) + }, + canvasHeight = 100f, + supportLinesCount = 3, + onColumnClick = null, + showControls = false, + showSubLine = false, + columnWidth = 50f, + ) + } + } +} + +@Composable +private fun MangaBaseInfoRow( + modifier: Modifier, + manga: DownloadStatManga, + onCoverClick: () -> Unit, + onDeleteManga: (() -> Unit)?, +) { + Row( + modifier = modifier + .height(56.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MangaCover.Square( + modifier = Modifier + .padding(vertical = 6.dp) + .fillMaxHeight(), + data = manga.libraryManga.manga, + onClick = onCoverClick, + ) + + 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 = 1f), + overflow = TextOverflow.Ellipsis, + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + var textHeight by remember { mutableIntStateOf(0) } + Text( + text = 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 = 1f), + onTextLayout = { textHeight = it.size.height }, + modifier = Modifier + .weight(weight = 1f, fill = false), + ) + } + } + if (onDeleteManga != null) { + DownloadedIndicator( + modifier = Modifier.padding(start = 4.dp), + onClick = { onDeleteManga.invoke() }, + ) + } + } +} + +@Composable +fun MangaDownloadStatSection( + items: List, +) { + Column { + TitleRow( + titles = listOf( + stringResource(R.string.downloaded_chapters), + items.filter { it.size > 0 }.sumOf { it.units }.toString(), + folderSizeText(items.filter { it.size > 0 }.sumOf { it.size }), + ), + titleStyle = MaterialTheme.typography.bodyMedium, + ) + TitleRow( + titles = listOf( + stringResource(R.string.deleted_chapters), + items.filter { it.size < 0 }.sumOf { it.units }.toString(), + folderSizeText(items.filter { it.size < 0 }.sumOf { abs(it.size) }), + ), + titleStyle = MaterialTheme.typography.bodyMedium, + ) + SubTitleRow( + subtitles = listOf( + null, + stringResource(R.string.label_total_chapters), + stringResource(R.string.action_sort_size), + ), + subtitleStyle = MaterialTheme.typography.labelSmall, + ) + } +} + +@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 folderSizeText(folderSizeBytes: Long): String { + 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()) + return if (exponent > 0) { String.format("%.2f %s", size, stringResource(units[exponent])) } else "0" +} + +@Composable +fun TitleRow( + titles: List = emptyList(), + titleStyle: TextStyle, +) { + Row { + for (title in titles) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (title != null) { + Text( + text = title, + style = titleStyle + .copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Composable +fun SubTitleRow( + subtitles: List = emptyList(), + subtitleStyle: TextStyle, +) { + Row { + for (subtitle in subtitles) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (subtitle != null) { + Text( + text = subtitle, + style = subtitleStyle + .copy( + color = MaterialTheme.colorScheme.onSurface + .copy(alpha = SecondaryItemAlpha), + ), + textAlign = TextAlign.Center, + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/download/components/OverAllTabContent.kt b/app/src/main/java/eu/kanade/presentation/more/download/components/OverAllTabContent.kt new file mode 100644 index 0000000000..6af5bde561 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/components/OverAllTabContent.kt @@ -0,0 +1,77 @@ +package eu.kanade.presentation.more.download.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.kanade.presentation.more.download.data.DownloadStatManga +import eu.kanade.presentation.more.stats.components.StatsItem +import eu.kanade.presentation.more.stats.components.StatsOverviewItem +import eu.kanade.presentation.more.stats.components.StatsSection +import eu.kanade.tachiyomi.R +import tachiyomi.domain.stat.model.DownloadStatOperation +import kotlin.math.abs + +@Composable +fun DownloadStatOverviewSection( + items: List, +) { + StatsSection(R.string.label_overview_section) { + Row { + StatsOverviewItem( + title = folderSizeText(items.sumOf { it.folderSize }), + subtitle = stringResource(R.string.action_sort_size), + icon = Icons.Outlined.Storage, + ) + StatsOverviewItem( + title = items.sumOf { it.downloadChaptersCount }.toString(), + subtitle = stringResource(R.string.chapters), + icon = Icons.Outlined.Book, + ) + StatsOverviewItem( + title = items.size.toString(), + subtitle = stringResource(R.string.manga), + icon = Icons.Outlined.CollectionsBookmark, + ) + } + } +} + +@Composable +fun DownloadStatsRow( + data: List, +) { + StatsSection(R.string.downloaded_chapters) { + Row { + StatsItem( + data.size.toString(), + stringResource(R.string.label_total_chapters), + ) + StatsItem( + folderSizeText(data.sumOf { abs(it.size) }), + stringResource(R.string.action_sort_size), + ) + } + } +} + +@Composable +fun DeletedStatsRow( + data: List, +) { + StatsSection(R.string.deleted_chapters) { + Row { + StatsItem( + data.size.toString(), + stringResource(R.string.label_total_chapters), + ) + StatsItem( + folderSizeText(abs(data.sumOf { it.size })), + stringResource(R.string.action_sort_size), + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/download/components/graphic/Graph.kt b/app/src/main/java/eu/kanade/presentation/more/download/components/graphic/Graph.kt new file mode 100644 index 0000000000..61b5156824 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/components/graphic/Graph.kt @@ -0,0 +1,372 @@ +package eu.kanade.presentation.more.download.components.graphic + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowRightAlt +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.DensitySmall +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.more.download.Dialog +import eu.kanade.presentation.more.download.components.folderSizeText +import eu.kanade.tachiyomi.R +import kotlinx.coroutines.launch +import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.screens.EmptyScreenAction +import java.util.stream.IntStream.range + +@Composable +fun PointGraph( + showSubLine: Boolean, + getPoints: (GraphGroupByMode) -> List, + canvasHeight: Float, + showControls: Boolean, + supportLinesCount: Int, + onColumnClick: ((Dialog?) -> Unit)?, + columnWidth: Float = 80f, +) { + var groupByMode by remember { mutableStateOf(GraphGroupByMode.NONE) } + val points = getPoints(groupByMode) + val minValue = points.minOf { it.coordinate } + val maxValue = points.maxOf { it.coordinate } + val normalizedPoints = scaleData( + data = points, + targetMax = canvasHeight, + ) + val scope = rememberCoroutineScope() + val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp + if (normalizedPoints.size * columnWidth >= screenWidthDp.value) { + Column { + val graphScrollState = rememberLazyListState() + if (showControls) { + var groupByModeMenuExpanded by remember { mutableStateOf(false) } + + GraphGroupDropdownMenu( + expanded = groupByModeMenuExpanded, + groupByMode = groupByMode, + onGroupClicked = { graphGroupByMode -> groupByMode = graphGroupByMode }, + onDismissRequest = { groupByModeMenuExpanded = false }, + ) + + Row( + modifier = Modifier.height(30.dp), + horizontalArrangement = Arrangement.End, + ) { + IconButton( + enabled = graphScrollState.canScrollBackward, + onClick = { + scope.launch { + graphScrollState.scrollToItem(0) + } + }, + ) { + Icon( + imageVector = Icons.Filled.ArrowRightAlt, + contentDescription = stringResource(R.string.scroll_to_start), + modifier = Modifier + .scale(scaleX = -1f, scaleY = 1f), + ) + } + IconButton( + onClick = { + scope.launch { + graphScrollState.scrollToItem(normalizedPoints.size - 1) + } + }, + enabled = graphScrollState.canScrollForward, + ) { + Icon( + imageVector = Icons.Filled.ArrowRightAlt, + contentDescription = stringResource(R.string.scroll_to_end), + ) + } + IconButton(onClick = { groupByModeMenuExpanded = true }) { + Icon( + imageVector = Icons.Outlined.DensitySmall, + contentDescription = stringResource(R.string.action_group), + ) + } + } + } + Spacer(modifier = Modifier.height(20.dp)) + Row { + BoxWithConstraints( + Modifier + .fillMaxWidth(), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(canvasHeight.dp), + ) { + val spacing = 2f / (supportLinesCount - 1) + val textUnderLineDashBase = 20 + val underLineDash = textUnderLineDashBase * 0.1f * (textUnderLineDashBase / canvasHeight) + for (i in range(1, supportLinesCount - 1)) { + Text( + modifier = Modifier.align( + BiasAlignment( + 1f, + i * spacing - 1 - underLineDash * ((supportLinesCount - i).toFloat() / supportLinesCount), + ), + ), + text = folderSizeText((minValue + (maxValue - minValue) * ((supportLinesCount - 1 - i).toFloat() / (supportLinesCount - 1))).toLong()), + ) + } + if (supportLinesCount >= 2) { + Text( + modifier = Modifier.align(BiasAlignment(1f, -1f - underLineDash)), + text = folderSizeText(maxValue.toLong()), + ) + Text( + modifier = Modifier.align( + BiasAlignment( + 1f, + 1f, + ), + ), + text = folderSizeText(minValue.toLong()), + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth(), + ) { + LazyRow( + state = graphScrollState, + ) { + for (i in normalizedPoints.indices) { + item { + CenterPointItemWithSubAndLines( + point = normalizedPoints[i], + leftPoint = if (i > 0) normalizedPoints[i - 1].coordinate else null, + rightPoint = if (i < normalizedPoints.size - 1) normalizedPoints[i + 1].coordinate else null, + columnHeight = canvasHeight, + linesCount = supportLinesCount, + onClick = onColumnClick, + showSubLine = showSubLine, + columnWidth = columnWidth, + ) + } + } + } + } + } + } + } + } else if (showControls) { + Row { + Box( + modifier = Modifier + .fillMaxWidth() + .height((canvasHeight + 50).dp), + ) { + EmptyScreen( + textResource = R.string.no_enough_stat_data, + actions = if (groupByMode != GraphGroupByMode.NONE) { + listOf( + EmptyScreenAction( + stringResId = R.string.disable_stat_graph_grouping, + icon = Icons.Outlined.Cancel, + onClick = { groupByMode = GraphGroupByMode.NONE }, + ), + ) + } else { + null + }, + ) + } + } + } +} + +@Composable +fun CenterPointItemWithSubAndLines( + point: GraphicPoint, + leftPoint: Float?, + rightPoint: Float?, + columnHeight: Float, + linesCount: Int, + onClick: ((Dialog?) -> Unit)?, + showSubLine: Boolean, + columnWidth: Float, +) { + val density = LocalDensity.current + Column( + modifier = Modifier + .width(columnWidth.dp) + .clickable( + onClick = { + onClick?.invoke(point.dialog) + }, + ), + ) { + Row( + modifier = Modifier + .height(columnHeight.dp) + .fillMaxWidth(), + ) { + val lineColor = LocalContentColor.current + Canvas( + modifier = Modifier + .height(columnHeight.dp) + .fillMaxWidth(), + ) { + val pointOffset = Offset( + with(density) { (columnWidth / 2).dp.toPx() }, + with(density) { point.coordinate.dp.toPx() }, + ) + if (leftPoint != null) { + drawLine( + strokeWidth = 5f, + color = lineColor, + start = pointOffset, + end = Offset( + with(density) { ((columnWidth / 2) - columnWidth).dp.toPx() }, + with(density) { leftPoint.dp.toPx() }, + ), + ) + } + if (rightPoint != null) { + drawLine( + strokeWidth = 5f, + color = lineColor, + start = pointOffset, + end = Offset( + with(density) { ((columnWidth / 2) + columnWidth).dp.toPx() }, + with(density) { rightPoint.dp.toPx() }, + ), + ) + } + for (i in range(0, linesCount)) { + drawSupportLine((columnHeight / (linesCount - 1)) * i, density) + } + } + } + if (showSubLine) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth(), + ) { + Text( + text = point.subLine, + textAlign = TextAlign.Center, + ) + } + } + } +} + +fun DrawScope.drawSupportLine(y: Float, density: Density) { + drawLine( + color = Color.Gray, + start = Offset( + with(density) { 0.dp.toPx() }, + with(density) { y.dp.toPx() }, + ), + end = Offset( + with(density) { 80.dp.toPx() }, + with(density) { y.dp.toPx() }, + ), + alpha = 0.5F, + strokeWidth = 5F, + ) +} + +@Composable +fun GraphGroupDropdownMenu( + expanded: Boolean, + groupByMode: GraphGroupByMode, + onDismissRequest: () -> Unit, + onGroupClicked: (GraphGroupByMode) -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + listOfNotNull( + if (groupByMode != GraphGroupByMode.NONE) GraphGroupByMode.NONE to stringResource(R.string.action_ungroup) else null, + if (groupByMode != GraphGroupByMode.BY_DAY) GraphGroupByMode.BY_DAY to stringResource(R.string.action_group_by_day) else null, + if (groupByMode != GraphGroupByMode.BY_MONTH) GraphGroupByMode.BY_MONTH to stringResource(R.string.action_group_by_month) else null, + ).map { (mode, string) -> + DropdownMenuItem( + text = { Text(text = string) }, + onClick = { + onGroupClicked(mode) + onDismissRequest() + }, + ) + } + } +} + +fun scaleData(data: List, targetMax: Float): List { + val minValue = data.minOf { it.coordinate } + val maxValue = data.maxOf { it.coordinate } - minValue + return data + .map { + it.copy( + coordinate = it.coordinate - minValue, + ) + } + .map { + it.copy( + coordinate = it.coordinate / (maxValue / targetMax), + ) + } + .map { + it.copy( + coordinate = (it.coordinate * -1) + targetMax, + ) + } +} + +data class GraphicPoint( + val coordinate: Float, + val subLine: String, + val dialog: Dialog? = null, +) + +enum class GraphGroupByMode { + NONE, + BY_DAY, + BY_MONTH, +} diff --git a/app/src/main/java/eu/kanade/presentation/more/download/components/graphic/PieChart.kt b/app/src/main/java/eu/kanade/presentation/more/download/components/graphic/PieChart.kt new file mode 100644 index 0000000000..72d3a9baca --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/components/graphic/PieChart.kt @@ -0,0 +1,172 @@ +package eu.kanade.presentation.more.download.components.graphic + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.aspectRatio +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +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.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Swipe +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.more.download.GroupByMode +import eu.kanade.presentation.more.download.data.DownloadStatManga + +@Composable +fun LazyItemScope.PieChartWithLegend( + contentPadding: PaddingValues, + getMap: (Boolean, GroupByMode) -> Map>, +) { + var groupByMode by remember { mutableStateOf(GroupByMode.BY_CATEGORY) } + + val data = getMap(groupByMode == GroupByMode.BY_SOURCE, groupByMode) + + val total = data.values.sumOf { list -> list.sumOf { it.folderSize } } + + if (total > 0) { + Column( + modifier = Modifier + .padding( + top = contentPadding.calculateTopPadding(), + start = contentPadding.calculateStartPadding(LocalLayoutDirection.current), + end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), + ) + .padding( + horizontal = 10.dp, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(30.dp), + ) { + IconButton( + onClick = { + when (groupByMode) { + GroupByMode.BY_SOURCE -> groupByMode = GroupByMode.BY_CATEGORY + GroupByMode.BY_CATEGORY -> groupByMode = GroupByMode.BY_SOURCE + else -> {} + } + }, + ) { + Icon( + imageVector = Icons.Outlined.Swipe, + contentDescription = null, + ) + } + } + val colors = generateColors(data.keys.toList()) + Row( + modifier = Modifier.fillParentMaxWidth(), + ) { + Column( + modifier = Modifier + .weight(1f), + ) { + PieChart(data = data, total = total, colors = colors) + } + LazyColumn( + modifier = Modifier + .weight(1f) + .aspectRatio(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + items(items = colors.entries.toList()) { entry -> + LegendItem( + name = entry.key, + color = entry.value, + ) + } + } + } + } + } +} + +fun generateColors(strings: List): Map { + val colorMap = mutableMapOf() + val step = 360f / strings.size + + for ((index, str) in strings.withIndex()) { + val hue = (step * index) % 360 + val color = Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, 1f, 1f))) + colorMap[str] = color + } + + return colorMap +} + +@Composable +fun PieChart(data: Map>, total: Long, colors: Map) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .widthIn(100.dp, 500.dp) + .aspectRatio(1f), + onDraw = { + val sorted = data.toList().sortedBy { pair -> pair.second.sumOf { it.folderSize } } + var startAngle = 0f + sorted.forEach { (name, value) -> + val sweepAngle = (value.sumOf { it.folderSize }.toFloat() / total.toFloat()) * 360f + drawArc( + color = colors[name]!!, + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = true, + topLeft = Offset(0f, 0f), + size = size, + ) + startAngle += sweepAngle + } + }, + ) +} + +@Composable +fun LegendItem(name: String, color: Color) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Spacer(modifier = Modifier.width(5.dp)) + Box( + modifier = Modifier + .size(20.dp) + .background(color = color), + ) + Spacer(modifier = Modifier.width(5.dp)) + Text( + text = name, + style = MaterialTheme.typography.labelLarge, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatManga.kt b/app/src/main/java/eu/kanade/presentation/more/download/data/DownloadStatManga.kt similarity index 69% rename from app/src/main/java/eu/kanade/presentation/more/download/DownloadStatManga.kt rename to app/src/main/java/eu/kanade/presentation/more/download/data/DownloadStatManga.kt index f72bfd2b14..5042591116 100644 --- a/app/src/main/java/eu/kanade/presentation/more/download/DownloadStatManga.kt +++ b/app/src/main/java/eu/kanade/presentation/more/download/data/DownloadStatManga.kt @@ -1,4 +1,4 @@ -package eu.kanade.presentation.more.download +package eu.kanade.presentation.more.download.data import androidx.compose.runtime.Immutable import eu.kanade.tachiyomi.source.Source @@ -8,9 +8,10 @@ import tachiyomi.domain.library.model.LibraryManga @Immutable data class DownloadStatManga( val libraryManga: LibraryManga, - val folderSize: Long, + val folderSize: Long = 0, val selected: Boolean = false, + val expanded: Boolean = false, val source: Source, val category: Category, - val downloadChaptersCount: Int, + val downloadChaptersCount: Int = 0, ) diff --git a/app/src/main/java/eu/kanade/presentation/more/download/tabs/DownloadStatsItesmTab.kt b/app/src/main/java/eu/kanade/presentation/more/download/tabs/DownloadStatsItesmTab.kt new file mode 100644 index 0000000000..26c892c34d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/tabs/DownloadStatsItesmTab.kt @@ -0,0 +1,169 @@ +package eu.kanade.presentation.more.download.tabs + +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.FlipToBack +import androidx.compose.material.icons.outlined.SelectAll +import androidx.compose.material.icons.outlined.Sort +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.TabContent +import eu.kanade.presentation.more.download.DownloadStatsScreenModel +import eu.kanade.presentation.more.download.DownloadStatsScreenState +import eu.kanade.presentation.more.download.GroupByMode +import eu.kanade.presentation.more.download.components.CategoryList +import eu.kanade.presentation.more.download.components.downloadStatUiItems +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.manga.MangaScreen +import tachiyomi.presentation.core.components.FastScrollLazyColumn +import tachiyomi.presentation.core.screens.EmptyScreen + +@Composable +fun downloadStatsItemsTab( + state: DownloadStatsScreenState, + screenModel: DownloadStatsScreenModel, +): TabContent { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + + return TabContent( + titleRes = R.string.label_download_enties_tab, + searchEnabled = true, + actions = listOf( + AppBar.Action( + title = stringResource(R.string.action_sort), + icon = Icons.Outlined.Sort, + onClick = { screenModel.openSettingsDialog() }, + ), + ), + actionModeActions = listOf( + AppBar.Action( + title = stringResource(R.string.action_select_all), + icon = Icons.Outlined.SelectAll, + onClick = { screenModel.toggleAllSelection(true) }, + ), + AppBar.Action( + title = stringResource(R.string.action_select_inverse), + icon = Icons.Outlined.FlipToBack, + onClick = { screenModel.invertSelection() }, + ), + AppBar.Action( + title = stringResource(R.string.delete_downloads_for_manga), + icon = Icons.Outlined.Delete, + onClick = { screenModel.showDeleteAlert(state.selected) }, + ), + ), + content = { contentPadding, snackBarHost -> + if (state.items.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(true), + selectionMode = state.selectionMode, + onCoverClick = { manga -> + navigator.push( + MangaScreen(manga.libraryManga.manga.id), + ) + }, + onSelected = screenModel::toggleSelection, + onMassSelected = screenModel::toggleMassSelection, + onDeleteManga = screenModel::showDeleteAlert, + operations = state.downloadStatOperations, + getGraphPoints = { ops, weight, groupByMode -> + screenModel.formGraphData( + ops, + weight, + groupByMode, + context, + ) + }, + snackbarHostState = snackBarHost, + toggleExpanded = screenModel::toggleExpanded, + ) + } + + GroupByMode.BY_SOURCE -> { + CategoryList( + contentPadding = contentPadding, + selectionMode = state.selectionMode, + onCoverClick = { item -> + navigator.push( + MangaScreen(item.libraryManga.manga.id), + ) + }, + onDeleteManga = screenModel::showDeleteAlert, + onGroupSelected = screenModel::groupSelection, + onSelected = screenModel::toggleSelection, + onMassSelected = screenModel::toggleMassSelection, + categoryMap = screenModel.categoryMap( + state.processedItems(true), + GroupByMode.BY_SOURCE, + state.sortMode, + state.descendingOrder, + defaultCategoryName = null, + ), + operations = state.downloadStatOperations, + getGraphPoints = { ops, weight, groupByMode -> + screenModel.formGraphData( + ops, + weight, + groupByMode, + context, + ) + }, + snackbarHostState = snackBarHost, + toggleExpanded = screenModel::toggleExpanded, + ) + } + + GroupByMode.BY_CATEGORY -> { + CategoryList( + contentPadding = contentPadding, + selectionMode = state.selectionMode, + onCoverClick = { item -> + navigator.push( + MangaScreen(item.libraryManga.manga.id), + ) + }, + onDeleteManga = screenModel::showDeleteAlert, + onGroupSelected = screenModel::groupSelection, + onSelected = screenModel::toggleSelection, + onMassSelected = screenModel::toggleMassSelection, + categoryMap = screenModel.categoryMap( + items = state.processedItems(false), + groupMode = GroupByMode.BY_CATEGORY, + sortMode = state.sortMode, + descendingOrder = state.descendingOrder, + defaultCategoryName = stringResource(R.string.label_default), + ), + operations = state.downloadStatOperations, + getGraphPoints = { ops, weight, groupByMode -> + screenModel.formGraphData( + ops, + weight, + groupByMode, + context, + ) + }, + snackbarHostState = snackBarHost, + toggleExpanded = screenModel::toggleExpanded, + ) + } + } + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/more/download/tabs/OverAllStatsTab.kt b/app/src/main/java/eu/kanade/presentation/more/download/tabs/OverAllStatsTab.kt new file mode 100644 index 0000000000..d01e53fbf0 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/download/tabs/OverAllStatsTab.kt @@ -0,0 +1,83 @@ +package eu.kanade.presentation.more.download.tabs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import eu.kanade.presentation.components.TabContent +import eu.kanade.presentation.more.download.DownloadStatsScreenModel +import eu.kanade.presentation.more.download.DownloadStatsScreenState +import eu.kanade.presentation.more.download.SortingMode +import eu.kanade.presentation.more.download.components.DeletedStatsRow +import eu.kanade.presentation.more.download.components.DownloadStatOverviewSection +import eu.kanade.presentation.more.download.components.DownloadStatsRow +import eu.kanade.presentation.more.download.components.graphic.PieChartWithLegend +import eu.kanade.presentation.more.download.components.graphic.PointGraph +import eu.kanade.tachiyomi.R +import tachiyomi.presentation.core.components.LazyColumn +import tachiyomi.presentation.core.components.material.padding + +@Composable +fun overAllStatsTab( + state: DownloadStatsScreenState, + screenModel: DownloadStatsScreenModel, +): TabContent { + return TabContent( + titleRes = R.string.label_download_stats_overall_tab, + searchEnabled = false, + content = { PaddingValues, _ -> + LazyColumn( + state = rememberLazyListState(), + contentPadding = PaddingValues, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + ) { + item { + DownloadStatOverviewSection(state.uniqueItems()) + } + item { + DownloadStatsRow(state.downloadStatOperations.filter { it.size > 0 }) + } + item { + DeletedStatsRow(state.downloadStatOperations.filter { it.size < 0 }) + } + item { + val defaultCategoryName = stringResource(R.string.label_default) + + PieChartWithLegend( + getMap = { + unique, groupByMode -> + screenModel.categoryMap( + items = if (unique) state.uniqueItems() else state.items, + groupMode = groupByMode, + sortMode = SortingMode.BY_SIZE, + descendingOrder = true, + defaultCategoryName = defaultCategoryName, + ) + }, + contentPadding = PaddingValues, + ) + } + item { + val context = LocalContext.current + PointGraph( + getPoints = { groupByMode -> + screenModel.formGraphData( + state.downloadStatOperations, + state.items.sumOf { it.folderSize }, + groupByMode, + context, + ) + }, + canvasHeight = 200f, + supportLinesCount = 5, + onColumnClick = screenModel::setDialog, + showSubLine = true, + showControls = true, + ) + } + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 184269ea0e..065b35ca25 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -22,6 +22,8 @@ import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.service.SourceManager +import tachiyomi.domain.stat.interactor.AddDownloadStatOperation +import tachiyomi.domain.stat.model.DownloadStatOperation import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -37,6 +39,7 @@ class DownloadManager( private val getCategories: GetCategories = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(), private val downloadPreferences: DownloadPreferences = Injekt.get(), + private val addDownloadStatOperation: AddDownloadStatOperation = Injekt.get(), ) { /** @@ -223,6 +226,14 @@ class DownloadManager( removeFromDownloadQueue(filteredChapters) val (mangaDir, chapterDirs) = provider.findChapterDirs(filteredChapters, manga, source) + addDownloadStatOperation.await( + DownloadStatOperation.create().copy( + mangaId = manga.id, + size = chapterDirs.sumOf { provider.getFolderSize(it.filePath!!) } * -1, + units = filteredChapters.size.toLong(), + + ), + ) chapterDirs.forEach { it.delete() } cache.removeChapters(filteredChapters, manga) @@ -245,7 +256,18 @@ class DownloadManager( if (removeQueued) { downloader.removeFromQueue(manga) } - provider.findMangaDir(manga.title, source)?.delete() + val mangaDir = provider.findMangaDir(manga.title, source) + val dirSize = provider.getFolderSize(mangaDir?.filePath!!) + if (dirSize > 0) { + addDownloadStatOperation.await( + DownloadStatOperation.create().copy( + mangaId = manga.id, + size = dirSize * -1, + units = cache.getDownloadCount(manga).toLong(), + ), + ) + } + mangaDir.delete() cache.removeManga(manga) // Delete source directory if empty diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index 2c0b796dfb..bdb7694fa2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -16,6 +16,7 @@ import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.manga.model.Manga import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.io.File /** * This class is used to provide the directories where the downloads should be saved. @@ -188,4 +189,38 @@ class DownloadProvider( } } } + + 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 { + return if (file.isDirectory) { + getFolderSize(file.path) + } else if (file.isFile) { + file.length() + } else { + 0 + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 9a15967f4b..0a46e91123 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -54,6 +54,8 @@ import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.service.SourceManager +import tachiyomi.domain.stat.interactor.AddDownloadStatOperation +import tachiyomi.domain.stat.model.DownloadStatOperation import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.BufferedOutputStream @@ -74,6 +76,7 @@ class Downloader( private val sourceManager: SourceManager = Injekt.get(), private val chapterCache: ChapterCache = Injekt.get(), private val downloadPreferences: DownloadPreferences = Injekt.get(), + private val addDownloadStatOperation: AddDownloadStatOperation = Injekt.get(), private val xml: XML = Injekt.get(), private val getCategories: GetCategories = Injekt.get(), ) { @@ -401,6 +404,13 @@ class Downloader( DiskUtil.createNoMediaFile(tmpDir, context) + addDownloadStatOperation.await( + DownloadStatOperation.create().copy( + mangaId = download.manga.id, + size = provider.getFolderSize(provider.findChapterDir(download.chapter.name, download.chapter.scanlator, download.manga.title, download.source)?.filePath!!), + ), + ) + download.status = Download.State.DOWNLOADED } catch (error: Throwable) { if (error is CancellationException) throw error diff --git a/data/src/main/java/tachiyomi/data/stat/DownloadStatMapper.kt b/data/src/main/java/tachiyomi/data/stat/DownloadStatMapper.kt new file mode 100644 index 0000000000..609fd99a1c --- /dev/null +++ b/data/src/main/java/tachiyomi/data/stat/DownloadStatMapper.kt @@ -0,0 +1,14 @@ +package tachiyomi.data.stat + +import tachiyomi.domain.stat.model.DownloadStatOperation + +val DownloadStatActionMapper: (Long, Long?, Long, Long, Long) -> +DownloadStatOperation = { id, mangaId, date, size, units -> + DownloadStatOperation( + id = id, + mangaId = mangaId, + date = date * 1000, + size = size, + units = units, + ) +} diff --git a/data/src/main/java/tachiyomi/data/stat/DownloadStatRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/stat/DownloadStatRepositoryImpl.kt new file mode 100644 index 0000000000..1a755a38ac --- /dev/null +++ b/data/src/main/java/tachiyomi/data/stat/DownloadStatRepositoryImpl.kt @@ -0,0 +1,25 @@ +package tachiyomi.data.stat + +import tachiyomi.data.DatabaseHandler +import tachiyomi.domain.stat.model.DownloadStatOperation +import tachiyomi.domain.stat.repository.DownloadStatRepository + +class DownloadStatRepositoryImpl( + private val handler: DatabaseHandler, + +) : DownloadStatRepository { + override suspend fun getStatOperations(): List { + return handler.awaitList { download_statQueries.getStatOperations(DownloadStatActionMapper) } + } + + override suspend fun insert(operation: DownloadStatOperation) { + handler.await { + download_statQueries.insert( + mangaId = operation.mangaId, + date = operation.date / 1000, + size = operation.size, + units = operation.units, + ) + } + } +} diff --git a/data/src/main/sqldelight/tachiyomi/data/download_stat.sq b/data/src/main/sqldelight/tachiyomi/data/download_stat.sq new file mode 100644 index 0000000000..e4a1774ec0 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/download_stat.sq @@ -0,0 +1,18 @@ +CREATE TABLE download_stat( + _id INTEGER NOT NULL PRIMARY KEY, + manga_id INTEGER, + date INTEGER NOT NULL DEFAULT 0, + size INTEGER NOT NULL, + units INTEGER NOT NULL, + + FOREIGN KEY(manga_id) REFERENCES mangas (_id) + ON DELETE SET NULL +); + +-- Methods +getStatOperations: +SELECT * FROM download_stat; + +insert: +INSERT INTO download_stat(manga_id, date, size, units) +VALUES (:mangaId, :date, :size, :units); diff --git a/data/src/main/sqldelight/tachiyomi/migrations/27.sqm b/data/src/main/sqldelight/tachiyomi/migrations/27.sqm new file mode 100644 index 0000000000..44bb99e7a1 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/27.sqm @@ -0,0 +1,10 @@ +CREATE TABLE download_stat( + _id INTEGER NOT NULL PRIMARY KEY, + manga_id INTEGER, + date INTEGER NOT NULL DEFAULT 0, + size INTEGER NOT NULL, + units INTEGER NOT NULL, + + FOREIGN KEY(manga_id) REFERENCES mangas (_id) + ON DELETE SET NULL +); \ No newline at end of file diff --git a/domain/src/main/java/tachiyomi/domain/stat/interactor/AddDownloadStatOperation.kt b/domain/src/main/java/tachiyomi/domain/stat/interactor/AddDownloadStatOperation.kt new file mode 100644 index 0000000000..ff07f5ec9e --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/stat/interactor/AddDownloadStatOperation.kt @@ -0,0 +1,13 @@ +package tachiyomi.domain.stat.interactor + +import tachiyomi.domain.stat.model.DownloadStatOperation +import tachiyomi.domain.stat.repository.DownloadStatRepository + +class AddDownloadStatOperation( + private val repository: DownloadStatRepository, +) { + + suspend fun await(actions: DownloadStatOperation) { + repository.insert(actions) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/stat/interactor/GetDownloadStatOperations.kt b/domain/src/main/java/tachiyomi/domain/stat/interactor/GetDownloadStatOperations.kt new file mode 100644 index 0000000000..371e14f3b0 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/stat/interactor/GetDownloadStatOperations.kt @@ -0,0 +1,13 @@ +package tachiyomi.domain.stat.interactor + +import tachiyomi.domain.stat.model.DownloadStatOperation +import tachiyomi.domain.stat.repository.DownloadStatRepository + +class GetDownloadStatOperations( + private val repository: DownloadStatRepository, +) { + + suspend fun await(): List { + return repository.getStatOperations() + } +} diff --git a/domain/src/main/java/tachiyomi/domain/stat/model/DownloadStatOperation.kt b/domain/src/main/java/tachiyomi/domain/stat/model/DownloadStatOperation.kt new file mode 100644 index 0000000000..54937fba80 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/stat/model/DownloadStatOperation.kt @@ -0,0 +1,21 @@ +package tachiyomi.domain.stat.model + +import java.util.Date + +data class DownloadStatOperation( + val id: Long, + val mangaId: Long?, + val date: Long, + val size: Long, + val units: Long, +) { + companion object { + fun create() = DownloadStatOperation( + id = -1, + mangaId = -1, + date = Date().time, + size = -1, + units = 1, + ) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/stat/repository/DownloadStatRepository.kt b/domain/src/main/java/tachiyomi/domain/stat/repository/DownloadStatRepository.kt new file mode 100644 index 0000000000..77a2c9fcac --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/stat/repository/DownloadStatRepository.kt @@ -0,0 +1,10 @@ +package tachiyomi.domain.stat.repository + +import tachiyomi.domain.stat.model.DownloadStatOperation + +interface DownloadStatRepository { + + suspend fun getStatOperations(): List + + suspend fun insert(operation: DownloadStatOperation) +} diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 11fc6294a1..16dbb29dae 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -58,7 +58,6 @@ Remove filter Alphabetically - A-Z Total entries Total chapters Last read @@ -112,7 +111,9 @@ Copy to clipboard Category Source - ungroup + Ungroup + Day + Month Open in WebView WebView @@ -165,6 +166,23 @@ FAQ and Guides Not now + + Entries + Overall + Deleted chapters + No stat data + Not enough stat data to form graph + downloaded %d chapters in %d entries + Deleted %d chapter with weight %s + Downloaded %d chapter with weight %s + Entry was deleted from the library + earlier + Show entries with no currently downloaded chapters + Scroll to start + Scroll to end + Disable group mode + Start of recording, no information previously available + B KB @@ -707,6 +725,7 @@ Error saving cover Error sharing cover Are you sure you want to delete the selected chapters? + Are you sure you want to delete the selected entries? Chapter settings Are you sure you want to save these settings as default? Also apply to all entries in my library