many changes for download stat screen

This commit is contained in:
semenvav 2023-10-23 23:26:10 +03:00
parent cc4e994682
commit 7f777e5c1a
26 changed files with 2416 additions and 672 deletions

View File

@ -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<DownloadStatRepository> { DownloadStatRepositoryImpl(get()) }
addFactory { GetDownloadStatOperations(get()) }
addFactory { AddDownloadStatOperation(get()) }
}
}

View File

@ -100,5 +100,6 @@ data class TabContent(
val badgeNumber: Int? = null,
val searchEnabled: Boolean = false,
val actions: List<AppBar.Action> = emptyList(),
val actionModeActions: List<AppBar.Action> = emptyList(),
val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit,
)

View File

@ -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<DownloadStatManga>,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
onCancelActionMode: () -> Unit,
onClickSort: (SortingMode) -> Unit,
onClickGroup: (GroupByMode) -> Unit,
onMultiDeleteClicked: (List<DownloadStatManga>) -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
sortState: SortingMode,
descendingOrder: Boolean? = null,
searchQuery: String?,
onChangeSearchQuery: (String?) -> Unit,
navigateUp: (() -> Unit)?,
defaultActions: List<AppBar.Action>,
actionModeActions: List<AppBar.Action>,
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<DownloadStatManga>,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
onCancelActionMode: () -> Unit,
onMultiDeleteClicked: (List<DownloadStatManga>) -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
navigateUp: (() -> Unit)?,
) {
AppBar(
modifier = modifier,
title = stringResource(R.string.label_download_stats),
onCancelActionMode = onCancelActionMode,
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) },
)
}
}
}

View File

@ -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>(DownloadStatsScreenState()) {
private val downloadCache: DownloadCache by injectLazy()
var activeCategoryIndex: Int by preferenceStore.getInt("downloadStatSelectedTab", 0).asState(coroutineScope)
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
private val selectedMangaIds: HashSet<Long> = 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<DownloadStatManga>, groupMode: GroupByMode, sortMode: SortingMode, descendingOrder: Boolean): Map<String, List<DownloadStatManga>> {
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<DownloadStatManga>,
groupMode: GroupByMode,
sortMode: SortingMode,
descendingOrder: Boolean,
defaultCategoryName: String?,
): Map<String, List<DownloadStatManga>> {
val unsortedMap = when (groupMode) {
GroupByMode.BY_CATEGORY -> items.groupBy { if (it.category.isSystemCategory) { stringResource(R.string.label_default) } else { it.category.name } }
GroupByMode.BY_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<String, List<DownloadStatManga>>(if (descendingOrder) { compareByDescending { compareFun(it) } } else { compareBy { compareFun(it) } })
sortedMap.putAll(unsortedMap)
sortedMap
}
}
}
private fun sortBySize() {
mutableState.update { state ->
val descendingOrder = if (state.sortMode == SortingMode.BY_SIZE) !state.descendingOrder else false
preferenceStore.getBoolean("descending_order", false).set(descendingOrder)
state.copy(
items = if (descendingOrder) state.items.sortedByDescending { it.folderSize } else state.items.sortedBy { it.folderSize },
descendingOrder = descendingOrder,
sortMode = SortingMode.BY_SIZE,
)
}
preferenceStore.getEnum("sort_mode", SortingMode.BY_SIZE).set(SortingMode.BY_SIZE)
}
private fun groupBySource() {
mutableState.update {
it.copy(
groupByMode = GroupByMode.BY_SOURCE,
)
}
preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.BY_SOURCE)
}
private fun groupByCategory() {
@ -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<DownloadStatManga>()
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<DownloadStatManga>) {
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<DownloadStatManga>) {
fun deleteMangas(manga: List<DownloadStatManga>) {
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<DownloadStatManga>) {
mutableState.update { it.copy(dialog = Dialog.DeleteManga(items)) }
}
fun groupSelection(items: List<DownloadStatManga>) {
val newSelected = items.map { manga -> manga.libraryManga.id }.toHashSet()
selectedMangaIds.addAll(newSelected)
mutableState.update { state ->
val newItems = state.items.map {
it.copy(selected = if (it.libraryManga.id in newSelected) !it.selected else it.selected)
}
state.copy(items = newItems)
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<DownloadStatOperation>,
currentWeight: Long,
graphGroupByMode: GraphGroupByMode,
context: Context,
): List<GraphicPoint> {
var weight = currentWeight.toFloat()
val pointsList = mutableListOf<GraphicPoint>()
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<String, MutableList<DownloadStatOperation>>()
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<String, MutableList<DownloadStatOperation>>()
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<DownloadStatManga>) : Dialog()
data class DownloadStatOperationInfo(val downloadStatOperation: DownloadStatOperation) : Dialog()
data class MultiMangaDownloadStatOperationInfo(val downloadStatOperation: List<DownloadStatOperation>) : Dialog()
object DownloadStatOperationStart : Dialog()
object SettingsSheet : Dialog()
}

View File

@ -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<DownloadStatOperation> = emptyList(),
) {
val selected = items.filter { it.selected }
val selectionMode = selected.isNotEmpty()
val processedItems: List<DownloadStatManga>
get() = search(items, searchQuery, groupByMode)
fun search(items: List<DownloadStatManga>, searchQuery: String?, groupByMode: GroupByMode): List<DownloadStatManga> {
return if (searchQuery != null) {
items.filter { downloadStatManga ->
downloadStatManga.libraryManga.manga.title.contains(searchQuery, true) ||
if (groupByMode == GroupByMode.BY_SOURCE) { downloadStatManga.source.name.contains(searchQuery, true) } else { false } ||
if (groupByMode == GroupByMode.BY_CATEGORY) { downloadStatManga.category.name.contains(searchQuery, true) } else { false }
fun uniqueItems(): List<DownloadStatManga> {
val uniqueIds = HashSet<Long>()
val uniqueMangas = mutableListOf<DownloadStatManga>()
for (manga in items) {
if (uniqueIds.add(manga.libraryManga.manga.id)) {
uniqueMangas.add(manga)
}
}
return uniqueMangas
}
fun processedItems(unique: Boolean): List<DownloadStatManga> {
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
}
}
}

View File

@ -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<DownloadStatManga>,
selectionMode: Boolean,
onSelected: (DownloadStatManga, Boolean, Boolean, Boolean) -> Unit,
onClick: (DownloadStatManga) -> Unit,
onDeleteManga: (List<DownloadStatManga>) -> Unit,
) {
items(
items = items,
) { item ->
DownloadStatUiItem(
modifier = Modifier.animateItemPlacement(),
selected = item.selected,
onLongClick = {
onSelected(item, !item.selected, true, true)
},
onClick = {
when {
selectionMode -> onSelected(item, !item.selected, true, false)
else -> onClick(item)
}
},
manga = item,
onDeleteManga = { onDeleteManga(listOf(item)) }.takeIf { !selectionMode },
)
}
}
@Composable
private fun DownloadStatUiItem(
modifier: Modifier,
manga: DownloadStatManga,
selected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onDeleteManga: (() -> Unit)?,
) {
val haptic = LocalHapticFeedback.current
val textAlpha = 1f
Row(
modifier = modifier
.selectedBackground(selected)
.combinedClickable(
onClick = onClick,
onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
)
.height(56.dp)
.padding(horizontal = MaterialTheme.padding.medium),
verticalAlignment = Alignment.CenterVertically,
) {
MangaCover.Square(
modifier = Modifier
.padding(vertical = 6.dp)
.fillMaxHeight(),
data = manga.libraryManga.manga,
)
Column(
modifier = Modifier
.padding(horizontal = MaterialTheme.padding.medium)
.weight(1f),
) {
Text(
text = manga.libraryManga.manga.title,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current.copy(alpha = textAlpha),
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
var textHeight by remember { mutableIntStateOf(0) }
FolderSizeText(manga.folderSize)
DotSeparatorText()
Text(
text = String.format("%d %s", manga.downloadChaptersCount, stringResource(R.string.chapters)),
maxLines = 1,
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = textAlpha),
onTextLayout = { textHeight = it.size.height },
modifier = Modifier
.weight(weight = 1f, fill = false),
)
}
}
DownloadedIndicator(
modifier = Modifier.padding(start = 4.dp),
onClick = { onDeleteManga?.invoke() },
)
}
}
fun LazyListScope.downloadStatGroupUiItem(
items: List<DownloadStatManga>,
selectionMode: Boolean,
onSelected: (DownloadStatManga, Boolean, Boolean, Boolean) -> Unit,
onMangaClick: (DownloadStatManga) -> Unit,
id: String,
onDeleteManga: (List<DownloadStatManga>) -> Unit,
onGroupSelected: (List<DownloadStatManga>) -> Unit,
expanded: MutableMap<String, Boolean>,
) {
stickyHeader {
Row(
modifier = Modifier
.fillMaxWidth()
.selectedBackground(!items.fastAny { !it.selected })
.combinedClickable(
onClick = { expanded[id] = if (expanded[id] == null) { false } else { !expanded[id]!! } },
onLongClick = { onGroupSelected(items) },
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
id,
style = typography.h6,
)
DotSeparatorText()
FolderSizeText(items.fold(0L) { acc, downloadStatManga -> acc + downloadStatManga.folderSize })
Icon(
imageVector = if (expanded[id] == true) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown,
contentDescription = null,
modifier = Modifier.padding(start = 8.dp),
)
}
}
if (expanded[id] == true) {
downloadStatUiItems(
items = items,
onClick = onMangaClick,
selectionMode = selectionMode,
onDeleteManga = onDeleteManga,
onSelected = onSelected,
)
}
}
@Composable
fun FolderSizeText(folderSizeBytes: Long) {
val units = arrayOf(R.string.memory_unit_b, R.string.memory_unit_kb, R.string.memory_unit_mb, R.string.memory_unit_gb)
val base = 1024.0
val exponent = (ln(folderSizeBytes.toDouble()) / ln(base)).toInt()
val size = folderSizeBytes / base.pow(exponent.toDouble())
Text(
text = String.format("%.2f %s", size, stringResource(units[exponent])),
)
}
@Composable
fun DownloadedIndicator(
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
TextButton(
onClick = onClick,
modifier = modifier,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.action_delete),
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
fun CategoryList(
contentPadding: PaddingValues,
selectionMode: Boolean,
onMangaClick: (DownloadStatManga) -> Unit,
onDeleteManga: (List<DownloadStatManga>) -> Unit,
onGroupSelected: (List<DownloadStatManga>) -> Unit,
onSelected: (DownloadStatManga, Boolean, Boolean, Boolean) -> Unit,
categoryMap: Map<String, List<DownloadStatManga>>,
) {
val categoryExpandedMapSaver: Saver<MutableMap<String, Boolean>, *> = 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,
)
}
}
}

View File

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

View File

@ -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<DownloadStatManga>) -> Unit,
onGroupSelected: (List<DownloadStatManga>) -> Unit,
onSelected: (DownloadStatManga) -> Unit,
onMassSelected: (DownloadStatManga) -> Unit,
categoryMap: Map<String, List<DownloadStatManga>>,
toggleExpanded: (DownloadStatManga) -> Unit,
operations: List<DownloadStatOperation>,
getGraphPoints: (List<DownloadStatOperation>, Long, GraphGroupByMode) -> List<GraphicPoint>,
snackbarHostState: SnackbarHostState,
) {
val categoryExpandedMapSaver: Saver<MutableMap<String, Boolean>, *> = 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<DownloadStatManga>,
selectionMode: Boolean,
onSelected: (DownloadStatManga) -> Unit,
onMassSelected: (DownloadStatManga) -> Unit,
onCoverClick: (DownloadStatManga) -> Unit,
title: String,
onDeleteManga: (List<DownloadStatManga>) -> Unit,
onGroupSelected: (List<DownloadStatManga>) -> Unit,
toggleExpanded: (DownloadStatManga) -> Unit,
expanded: MutableMap<String, Boolean>,
operations: List<DownloadStatOperation>,
getGraphPoints: (List<DownloadStatOperation>, Long, GraphGroupByMode) -> List<GraphicPoint>,
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<DownloadStatManga>,
selectionMode: Boolean,
onSelected: (DownloadStatManga) -> Unit,
onMassSelected: (DownloadStatManga) -> Unit,
onCoverClick: (DownloadStatManga) -> Unit,
onDeleteManga: (List<DownloadStatManga>) -> Unit,
toggleExpanded: (DownloadStatManga) -> Unit,
operations: List<DownloadStatOperation>,
getGraphPoints: (List<DownloadStatOperation>, Long, GraphGroupByMode) -> List<GraphicPoint>,
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<DownloadStatOperation>,
getGraphPoints: (List<DownloadStatOperation>, Long, GraphGroupByMode) -> List<GraphicPoint>,
) {
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<DownloadStatOperation>,
) {
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<String?> = 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<String?> = 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,
)
}
}
}
}
}

View File

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

View File

@ -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<GraphicPoint>,
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<GraphicPoint>, targetMax: Float): List<GraphicPoint> {
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,
}

View File

@ -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<String, List<DownloadStatManga>>,
) {
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<String>): Map<String, Color> {
val colorMap = mutableMapOf<String, Color>()
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<String, List<DownloadStatManga>>, total: Long, colors: Map<String, Color>) {
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,
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<DownloadStatOperation> {
return repository.getStatOperations()
}
}

View File

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

View File

@ -0,0 +1,10 @@
package tachiyomi.domain.stat.repository
import tachiyomi.domain.stat.model.DownloadStatOperation
interface DownloadStatRepository {
suspend fun getStatOperations(): List<DownloadStatOperation>
suspend fun insert(operation: DownloadStatOperation)
}

View File

@ -58,7 +58,6 @@
<!-- reserved for #4048 -->
<string name="action_filter_empty">Remove filter</string>
<string name="action_sort_alpha">Alphabetically</string>
<string name="action_sort_A_Z">A-Z</string>
<string name="action_sort_count">Total entries</string>
<string name="action_sort_total">Total chapters</string>
<string name="action_sort_last_read">Last read</string>
@ -112,7 +111,9 @@
<string name="action_copy_to_clipboard">Copy to clipboard</string>
<string name="action_group_by_category">Category</string>
<string name="action_group_by_source">Source</string>
<string name="action_ungroup">ungroup</string>
<string name="action_ungroup">Ungroup</string>
<string name="action_group_by_day">Day</string>
<string name="action_group_by_month">Month</string>
<!-- Do not translate "WebView" -->
<string name="action_open_in_web_view">Open in WebView</string>
<string name="action_web_view" translatable="false">WebView</string>
@ -165,6 +166,23 @@
<string name="action_faq_and_guides">FAQ and Guides</string>
<string name="action_not_now">Not now</string>
<!-- downloads` stat`s screen -->
<string name="label_download_enties_tab">Entries</string>
<string name="label_download_stats_overall_tab">Overall</string>
<string name="deleted_chapters">Deleted chapters</string>
<string name="no_stat_data">No stat data</string>
<string name="no_enough_stat_data">Not enough stat data to form graph</string>
<string name="group_info">downloaded %d chapters in %d entries</string>
<string name="download_stat_operation_deleted">Deleted %d chapter with weight %s</string>
<string name="download_stat_operation_downloaded">Downloaded %d chapter with weight %s</string>
<string name="entry_was_deleted">Entry was deleted from the library</string>
<string name="graph_start_point_subLine">earlier</string>
<string name="show_entries_without_downloaded">Show entries with no currently downloaded chapters</string>
<string name="scroll_to_start">Scroll to start</string>
<string name="scroll_to_end">Scroll to end</string>
<string name="disable_stat_graph_grouping">Disable group mode</string>
<string name="start_operation_massage">Start of recording, no information previously available</string>
<!-- Memory units -->
<string name="memory_unit_b">B</string>
<string name="memory_unit_kb">KB</string>
@ -707,6 +725,7 @@
<string name="error_saving_cover">Error saving cover</string>
<string name="error_sharing_cover">Error sharing cover</string>
<string name="confirm_delete_chapters">Are you sure you want to delete the selected chapters?</string>
<string name="confirm_delete_entries">Are you sure you want to delete the selected entries?</string>
<string name="chapter_settings">Chapter settings</string>
<string name="confirm_set_chapter_settings">Are you sure you want to save these settings as default?</string>
<string name="also_set_chapter_settings_for_library">Also apply to all entries in my library</string>