mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-11-20 16:09:17 +01:00
many changes for download stat screen
This commit is contained in:
parent
cc4e994682
commit
7f777e5c1a
@ -26,6 +26,7 @@ import tachiyomi.data.manga.MangaRepositoryImpl
|
|||||||
import tachiyomi.data.release.ReleaseServiceImpl
|
import tachiyomi.data.release.ReleaseServiceImpl
|
||||||
import tachiyomi.data.source.SourceRepositoryImpl
|
import tachiyomi.data.source.SourceRepositoryImpl
|
||||||
import tachiyomi.data.source.StubSourceRepositoryImpl
|
import tachiyomi.data.source.StubSourceRepositoryImpl
|
||||||
|
import tachiyomi.data.stat.DownloadStatRepositoryImpl
|
||||||
import tachiyomi.data.track.TrackRepositoryImpl
|
import tachiyomi.data.track.TrackRepositoryImpl
|
||||||
import tachiyomi.data.updates.UpdatesRepositoryImpl
|
import tachiyomi.data.updates.UpdatesRepositoryImpl
|
||||||
import tachiyomi.domain.category.interactor.CreateCategoryWithName
|
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.interactor.GetSourcesWithNonLibraryManga
|
||||||
import tachiyomi.domain.source.repository.SourceRepository
|
import tachiyomi.domain.source.repository.SourceRepository
|
||||||
import tachiyomi.domain.source.repository.StubSourceRepository
|
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.DeleteTrack
|
||||||
import tachiyomi.domain.track.interactor.GetTracks
|
import tachiyomi.domain.track.interactor.GetTracks
|
||||||
import tachiyomi.domain.track.interactor.GetTracksPerManga
|
import tachiyomi.domain.track.interactor.GetTracksPerManga
|
||||||
@ -161,5 +165,9 @@ class DomainModule : InjektModule {
|
|||||||
addFactory { ToggleLanguage(get()) }
|
addFactory { ToggleLanguage(get()) }
|
||||||
addFactory { ToggleSource(get()) }
|
addFactory { ToggleSource(get()) }
|
||||||
addFactory { ToggleSourcePin(get()) }
|
addFactory { ToggleSourcePin(get()) }
|
||||||
|
|
||||||
|
addSingletonFactory<DownloadStatRepository> { DownloadStatRepositoryImpl(get()) }
|
||||||
|
addFactory { GetDownloadStatOperations(get()) }
|
||||||
|
addFactory { AddDownloadStatOperation(get()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -100,5 +100,6 @@ data class TabContent(
|
|||||||
val badgeNumber: Int? = null,
|
val badgeNumber: Int? = null,
|
||||||
val searchEnabled: Boolean = false,
|
val searchEnabled: Boolean = false,
|
||||||
val actions: List<AppBar.Action> = emptyList(),
|
val actions: List<AppBar.Action> = emptyList(),
|
||||||
|
val actionModeActions: List<AppBar.Action> = emptyList(),
|
||||||
val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit,
|
val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit,
|
||||||
)
|
)
|
||||||
|
@ -1,26 +1,30 @@
|
|||||||
package eu.kanade.presentation.more.download
|
package eu.kanade.presentation.more.download
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
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.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.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material.icons.outlined.DensitySmall
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material.icons.outlined.FlipToBack
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material.icons.outlined.SelectAll
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material.icons.outlined.Sort
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||||
@ -28,15 +32,23 @@ import cafe.adriel.voyager.navigator.LocalNavigator
|
|||||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
import eu.kanade.presentation.components.AppBar
|
import eu.kanade.presentation.components.AppBar
|
||||||
import eu.kanade.presentation.components.AppBarActions
|
import eu.kanade.presentation.components.AppBarActions
|
||||||
import eu.kanade.presentation.components.DropdownMenu
|
|
||||||
import eu.kanade.presentation.components.SearchToolbar
|
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.presentation.util.Screen
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
import kotlinx.coroutines.launch
|
||||||
import tachiyomi.presentation.core.components.SortItem
|
import tachiyomi.presentation.core.components.HorizontalPager
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
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
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||||
|
|
||||||
class DownloadStatsScreen : Screen() {
|
class DownloadStatsScreen : Screen() {
|
||||||
@ -47,85 +59,131 @@ class DownloadStatsScreen : Screen() {
|
|||||||
|
|
||||||
val screenModel = rememberScreenModel { DownloadStatsScreenModel() }
|
val screenModel = rememberScreenModel { DownloadStatsScreenModel() }
|
||||||
val state by screenModel.state.collectAsState()
|
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(
|
Scaffold(
|
||||||
topBar = { scrollBehavior ->
|
topBar = { scrollBehavior ->
|
||||||
DownloadStatsAppBar(
|
DownloadStatsAppBar(
|
||||||
groupByMode = state.groupByMode,
|
|
||||||
selected = state.selected,
|
selected = state.selected,
|
||||||
onSelectAll = { screenModel.toggleAllSelection(true) },
|
|
||||||
onInvertSelection = { screenModel.invertSelection() },
|
|
||||||
onCancelActionMode = { screenModel.toggleAllSelection(false) },
|
onCancelActionMode = { screenModel.toggleAllSelection(false) },
|
||||||
onMultiDeleteClicked = screenModel::deleteMangas,
|
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
onClickGroup = screenModel::runGroupBy,
|
|
||||||
onClickSort = screenModel::runSortAction,
|
|
||||||
sortState = state.sortMode,
|
|
||||||
descendingOrder = state.descendingOrder,
|
|
||||||
searchQuery = state.searchQuery,
|
searchQuery = state.searchQuery,
|
||||||
onChangeSearchQuery = screenModel::search,
|
onChangeSearchQuery = screenModel::search,
|
||||||
navigateUp = navigator::pop,
|
navigateUp = navigator::pop,
|
||||||
|
defaultActions = tab.actions,
|
||||||
|
actionModeActions = tab.actionModeActions,
|
||||||
|
searchEnabled = tab.searchEnabled,
|
||||||
|
actionModeEnabled = tab.actionModeActions.isNotEmpty(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
when {
|
if (state.isLoading) {
|
||||||
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
|
LoadingScreen(modifier = Modifier.padding(contentPadding))
|
||||||
|
} else {
|
||||||
state.processedItems.isEmpty() -> EmptyScreen(
|
Column(
|
||||||
textResource = R.string.information_no_downloads,
|
modifier = Modifier.padding(
|
||||||
modifier = Modifier.padding(contentPadding),
|
top = contentPadding.calculateTopPadding(),
|
||||||
)
|
start = contentPadding.calculateStartPadding(LocalLayoutDirection.current),
|
||||||
|
end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
|
||||||
else -> {
|
),
|
||||||
when (state.groupByMode) {
|
) {
|
||||||
GroupByMode.NONE -> FastScrollLazyColumn(
|
TabRow(
|
||||||
contentPadding = contentPadding,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
) {
|
indicator = {
|
||||||
downloadStatUiItems(
|
TabIndicator(
|
||||||
items = state.processedItems,
|
it[pagerState.currentPage],
|
||||||
selectionMode = state.selectionMode,
|
pagerState.currentPageOffsetFraction,
|
||||||
onClick = { item ->
|
)
|
||||||
navigator.push(
|
},
|
||||||
MangaScreen(item.libraryManga.manga.id),
|
) {
|
||||||
)
|
tabs.forEachIndexed { index, tab ->
|
||||||
},
|
Tab(
|
||||||
onSelected = screenModel::toggleSelection,
|
selected = pagerState.currentPage == index,
|
||||||
onDeleteManga = screenModel::deleteMangas,
|
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
)
|
text = { TabText(text = stringResource(tab.titleRes)) },
|
||||||
}
|
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
@Composable
|
||||||
private fun DownloadStatsAppBar(
|
private fun DownloadStatsAppBar(
|
||||||
groupByMode: GroupByMode,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
selected: List<DownloadStatManga>,
|
selected: List<DownloadStatManga>,
|
||||||
onSelectAll: () -> Unit,
|
|
||||||
onInvertSelection: () -> Unit,
|
|
||||||
onCancelActionMode: () -> Unit,
|
onCancelActionMode: () -> Unit,
|
||||||
onClickSort: (SortingMode) -> Unit,
|
|
||||||
onClickGroup: (GroupByMode) -> Unit,
|
|
||||||
onMultiDeleteClicked: (List<DownloadStatManga>) -> Unit,
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
sortState: SortingMode,
|
|
||||||
descendingOrder: Boolean? = null,
|
|
||||||
searchQuery: String?,
|
searchQuery: String?,
|
||||||
onChangeSearchQuery: (String?) -> Unit,
|
onChangeSearchQuery: (String?) -> Unit,
|
||||||
navigateUp: (() -> Unit)?,
|
navigateUp: (() -> Unit)?,
|
||||||
|
defaultActions: List<AppBar.Action>,
|
||||||
|
actionModeActions: List<AppBar.Action>,
|
||||||
|
searchEnabled: Boolean,
|
||||||
|
actionModeEnabled: Boolean,
|
||||||
) {
|
) {
|
||||||
if (selected.isNotEmpty()) {
|
if (actionModeEnabled && selected.isNotEmpty()) {
|
||||||
DownloadStatsActionAppBar(
|
AppBar(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
selected = selected,
|
title = stringResource(R.string.label_download_stats),
|
||||||
onSelectAll = onSelectAll,
|
|
||||||
onInvertSelection = onInvertSelection,
|
|
||||||
onCancelActionMode = onCancelActionMode,
|
onCancelActionMode = onCancelActionMode,
|
||||||
|
actionModeActions = { AppBarActions(actionModeActions) },
|
||||||
|
actionModeCounter = selected.size,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
navigateUp = navigateUp,
|
navigateUp = navigateUp,
|
||||||
onMultiDeleteClicked = onMultiDeleteClicked,
|
|
||||||
)
|
)
|
||||||
BackHandler(
|
BackHandler(
|
||||||
onBack = onCancelActionMode,
|
onBack = onCancelActionMode,
|
||||||
@ -177,143 +230,16 @@ private fun DownloadStatsAppBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
searchQuery = searchQuery,
|
searchEnabled = searchEnabled,
|
||||||
|
searchQuery = searchQuery.takeIf { searchEnabled },
|
||||||
onChangeSearchQuery = onChangeSearchQuery,
|
onChangeSearchQuery = onChangeSearchQuery,
|
||||||
actions = {
|
actions = { AppBarActions(defaultActions) },
|
||||||
val filterTint = LocalContentColor.current
|
|
||||||
var groupExpanded by remember { mutableStateOf(false) }
|
|
||||||
val onDownloadDismissRequest = { groupExpanded = false }
|
|
||||||
GroupDropdownMenu(
|
|
||||||
expanded = groupExpanded,
|
|
||||||
groupByMode = groupByMode,
|
|
||||||
onDismissRequest = onDownloadDismissRequest,
|
|
||||||
onGroupClicked = onClickGroup,
|
|
||||||
)
|
|
||||||
var sortExpanded by remember { mutableStateOf(false) }
|
|
||||||
val onSortDismissRequest = { sortExpanded = false }
|
|
||||||
SortDropdownMenu(
|
|
||||||
expanded = sortExpanded,
|
|
||||||
onDismissRequest = onSortDismissRequest,
|
|
||||||
onSortClicked = onClickSort,
|
|
||||||
sortState = sortState,
|
|
||||||
descendingOrder = descendingOrder,
|
|
||||||
)
|
|
||||||
AppBarActions(
|
|
||||||
listOf(
|
|
||||||
AppBar.Action(
|
|
||||||
title = stringResource(R.string.action_sort),
|
|
||||||
icon = Icons.Outlined.Sort,
|
|
||||||
iconTint = filterTint,
|
|
||||||
onClick = { sortExpanded = !sortExpanded },
|
|
||||||
),
|
|
||||||
AppBar.Action(
|
|
||||||
title = stringResource(R.string.action_group),
|
|
||||||
icon = Icons.Outlined.DensitySmall,
|
|
||||||
onClick = { groupExpanded = !groupExpanded },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
if (searchQuery != null) {
|
if (searchQuery != null && searchEnabled) {
|
||||||
BackHandler(
|
BackHandler(
|
||||||
onBack = { onChangeSearchQuery(null) },
|
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) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,26 +1,38 @@
|
|||||||
package eu.kanade.presentation.more.download
|
package eu.kanade.presentation.more.download
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import android.content.Context
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||||
import cafe.adriel.voyager.core.model.coroutineScope
|
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.R
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
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.flow.update
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import tachiyomi.core.preference.PreferenceStore
|
import tachiyomi.core.preference.PreferenceStore
|
||||||
import tachiyomi.core.preference.getEnum
|
import tachiyomi.core.preference.getEnum
|
||||||
import tachiyomi.core.util.lang.launchIO
|
import tachiyomi.core.util.lang.launchIO
|
||||||
import tachiyomi.core.util.lang.launchNonCancellable
|
import tachiyomi.core.util.lang.launchNonCancellable
|
||||||
import tachiyomi.domain.category.interactor.GetCategories
|
import tachiyomi.domain.category.interactor.GetCategories
|
||||||
|
import tachiyomi.domain.library.model.LibraryManga
|
||||||
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
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.source.service.SourceManager
|
||||||
|
import tachiyomi.domain.stat.interactor.GetDownloadStatOperations
|
||||||
|
import tachiyomi.domain.stat.model.DownloadStatOperation
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import uy.kohesive.injekt.injectLazy
|
import java.text.SimpleDateFormat
|
||||||
import java.io.File
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import java.util.TreeMap
|
import java.util.TreeMap
|
||||||
|
|
||||||
class DownloadStatsScreenModel(
|
class DownloadStatsScreenModel(
|
||||||
@ -30,87 +42,71 @@ class DownloadStatsScreenModel(
|
|||||||
private val downloadProvider: DownloadProvider = Injekt.get(),
|
private val downloadProvider: DownloadProvider = Injekt.get(),
|
||||||
private val getCategories: GetCategories = Injekt.get(),
|
private val getCategories: GetCategories = Injekt.get(),
|
||||||
private val preferenceStore: PreferenceStore = Injekt.get(),
|
private val preferenceStore: PreferenceStore = Injekt.get(),
|
||||||
|
private val getDownloadStatOperations: GetDownloadStatOperations = Injekt.get(),
|
||||||
|
private val getManga: GetManga = Injekt.get(),
|
||||||
) : StateScreenModel<DownloadStatsScreenState>(DownloadStatsScreenState()) {
|
) : 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 lateinit var lastSelectedManga: LibraryManga
|
||||||
private val selectedMangaIds: HashSet<Long> = HashSet()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
coroutineScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
val sortMode = preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).get()
|
val sortMode = preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).get()
|
||||||
mutableState.update {
|
mutableState.update { state ->
|
||||||
val categories = getCategories.await().associateBy { group -> group.id }
|
val categories = getCategories.await().associateBy { group -> group.id }
|
||||||
it.copy(
|
val operations = getDownloadStatOperations.await()
|
||||||
|
state.copy(
|
||||||
items = getLibraryManga.await().filter { libraryManga ->
|
items = getLibraryManga.await().filter { libraryManga ->
|
||||||
downloadCache.getDownloadCount(libraryManga.manga) > 0
|
(downloadManager.getDownloadCount(libraryManga.manga) > 0) || operations.any { it.mangaId == libraryManga.id }
|
||||||
}.map { libraryManga ->
|
}.mapNotNull { libraryManga ->
|
||||||
val source = sourceManager.get(libraryManga.manga.source)!!
|
val source = sourceManager.getOrStub(libraryManga.manga.source)
|
||||||
DownloadStatManga(
|
val path = downloadProvider.findMangaDir(
|
||||||
libraryManga = libraryManga,
|
libraryManga.manga.title,
|
||||||
selected = libraryManga.id in selectedMangaIds,
|
source,
|
||||||
source = source,
|
)?.filePath
|
||||||
folderSize = getFolderSize(
|
val downloadChaptersCount = downloadManager.getDownloadCount(libraryManga.manga)
|
||||||
downloadProvider.findMangaDir(
|
if (downloadChaptersCount == 0) {
|
||||||
libraryManga.manga.title,
|
DownloadStatManga(
|
||||||
source,
|
libraryManga = libraryManga,
|
||||||
)?.filePath!!,
|
source = source,
|
||||||
),
|
category = categories[libraryManga.category]!!,
|
||||||
downloadChaptersCount = downloadCache.getDownloadCount(libraryManga.manga),
|
)
|
||||||
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(),
|
groupByMode = preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).get(),
|
||||||
sortMode = sortMode,
|
sortMode = sortMode,
|
||||||
descendingOrder = preferenceStore.getBoolean("descending_order", false).get(),
|
descendingOrder = preferenceStore.getBoolean("descending_order", false).get(),
|
||||||
searchQuery = preferenceStore.getString("search_query", "").get().takeIf { string -> string != "" },
|
searchQuery = preferenceStore.getString("search_query", "").get().takeIf { string -> string != "" },
|
||||||
|
downloadStatOperations = operations,
|
||||||
|
showNotDownloaded = preferenceStore.getBoolean("show_no_downloaded", false).get(),
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
runSortAction(sortMode)
|
runSort(sortMode, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFolderSize(path: String): Long {
|
fun runSort(
|
||||||
val file = File(path)
|
mode: SortingMode,
|
||||||
var size: Long = 0
|
initSort: Boolean = false,
|
||||||
|
) {
|
||||||
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) {
|
|
||||||
when (mode) {
|
when (mode) {
|
||||||
SortingMode.BY_ALPHABET -> sortByAlphabet()
|
SortingMode.BY_ALPHABET -> sortByAlphabet(initSort)
|
||||||
SortingMode.BY_SIZE -> sortBySize()
|
SortingMode.BY_SIZE -> sortBySize(initSort)
|
||||||
|
SortingMode.BY_CHAPTERS -> sortByChapters(initSort)
|
||||||
}
|
}
|
||||||
|
preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).set(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runGroupBy(mode: GroupByMode) {
|
fun runGroupBy(mode: GroupByMode) {
|
||||||
@ -119,11 +115,21 @@ class DownloadStatsScreenModel(
|
|||||||
GroupByMode.BY_CATEGORY -> groupByCategory()
|
GroupByMode.BY_CATEGORY -> groupByCategory()
|
||||||
GroupByMode.BY_SOURCE -> groupBySource()
|
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 ->
|
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)
|
preferenceStore.getBoolean("descending_order", false).set(descendingOrder)
|
||||||
state.copy(
|
state.copy(
|
||||||
items = if (descendingOrder) state.items.sortedByDescending { it.libraryManga.manga.title } else state.items.sortedBy { it.libraryManga.manga.title },
|
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,
|
sortMode = SortingMode.BY_ALPHABET,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).set(SortingMode.BY_ALPHABET)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private fun sortBySize(initSort: Boolean) {
|
||||||
fun categoryMap(items: List<DownloadStatManga>, groupMode: GroupByMode, sortMode: SortingMode, descendingOrder: Boolean): Map<String, List<DownloadStatManga>> {
|
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) {
|
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.BY_SOURCE -> items.groupBy { it.source.name }
|
||||||
GroupByMode.NONE -> emptyMap()
|
GroupByMode.NONE -> emptyMap()
|
||||||
}
|
}
|
||||||
@ -153,29 +189,21 @@ class DownloadStatsScreenModel(
|
|||||||
sortedMap.putAll(unsortedMap)
|
sortedMap.putAll(unsortedMap)
|
||||||
sortedMap
|
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() {
|
private fun groupBySource() {
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
groupByMode = GroupByMode.BY_SOURCE,
|
groupByMode = GroupByMode.BY_SOURCE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.BY_SOURCE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun groupByCategory() {
|
private fun groupByCategory() {
|
||||||
@ -184,7 +212,6 @@ class DownloadStatsScreenModel(
|
|||||||
groupByMode = GroupByMode.BY_CATEGORY,
|
groupByMode = GroupByMode.BY_CATEGORY,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.BY_CATEGORY)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unGroup() {
|
private fun unGroup() {
|
||||||
@ -193,67 +220,66 @@ class DownloadStatsScreenModel(
|
|||||||
groupByMode = GroupByMode.NONE,
|
groupByMode = GroupByMode.NONE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.NONE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSelection(
|
fun toggleSelection(
|
||||||
item: DownloadStatManga,
|
item: DownloadStatManga,
|
||||||
selected: Boolean,
|
|
||||||
userSelected: Boolean = false,
|
|
||||||
fromLongPress: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
|
lastSelectedManga = item.libraryManga
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
val newItems = state.items.toMutableList().apply {
|
state.copy(
|
||||||
val selectedIndex = indexOfFirst { it.libraryManga.manga.id == item.libraryManga.manga.id }
|
items = state.items.map {
|
||||||
if (selectedIndex < 0) return@apply
|
if (it.libraryManga.id == item.libraryManga.id) {
|
||||||
|
it.copy(selected = !it.selected)
|
||||||
val selectedItem = get(selectedIndex)
|
|
||||||
if (selectedItem.selected == selected) return@apply
|
|
||||||
|
|
||||||
val firstSelection = none { it.selected }
|
|
||||||
set(selectedIndex, selectedItem.copy(selected = selected))
|
|
||||||
selectedMangaIds.addOrRemove(item.libraryManga.manga.id, selected)
|
|
||||||
|
|
||||||
if (selected && userSelected && fromLongPress) {
|
|
||||||
if (firstSelection) {
|
|
||||||
selectedPositions[0] = selectedIndex
|
|
||||||
selectedPositions[1] = selectedIndex
|
|
||||||
} else {
|
} else {
|
||||||
// Try to select the items in-between when possible
|
it
|
||||||
val range: IntRange
|
}
|
||||||
if (selectedIndex < selectedPositions[0]) {
|
},
|
||||||
range = selectedIndex + 1 until selectedPositions[0]
|
)
|
||||||
selectedPositions[0] = selectedIndex
|
}
|
||||||
} else if (selectedIndex > selectedPositions[1]) {
|
}
|
||||||
range = (selectedPositions[1] + 1) until selectedIndex
|
|
||||||
selectedPositions[1] = selectedIndex
|
|
||||||
} else {
|
|
||||||
// Just select itself
|
|
||||||
range = IntRange.EMPTY
|
|
||||||
}
|
|
||||||
|
|
||||||
range.forEach {
|
fun toggleMassSelection(item: DownloadStatManga) {
|
||||||
val inbetweenItem = get(it)
|
mutableState.update { state ->
|
||||||
if (!inbetweenItem.selected) {
|
val processedItems = if (state.groupByMode == GroupByMode.NONE) {
|
||||||
selectedMangaIds.add(inbetweenItem.libraryManga.manga.id)
|
state.processedItems(false)
|
||||||
set(it, inbetweenItem.copy(selected = true))
|
} else {
|
||||||
}
|
val temp = mutableListOf<DownloadStatManga>()
|
||||||
}
|
categoryMap(
|
||||||
}
|
items = state.processedItems(false),
|
||||||
} else if (userSelected && !fromLongPress) {
|
groupMode = state.groupByMode,
|
||||||
if (!selected) {
|
sortMode = state.sortMode,
|
||||||
if (selectedIndex == selectedPositions[0]) {
|
descendingOrder = state.descendingOrder,
|
||||||
selectedPositions[0] = indexOfFirst { it.selected }
|
defaultCategoryName = null,
|
||||||
} else if (selectedIndex == selectedPositions[1]) {
|
).map {
|
||||||
selectedPositions[1] = indexOfLast { it.selected }
|
temp.addAll(it.value)
|
||||||
}
|
}
|
||||||
} else {
|
temp
|
||||||
if (selectedIndex < selectedPositions[0]) {
|
}
|
||||||
selectedPositions[0] = selectedIndex
|
val lastSelectedIndex =
|
||||||
} else if (selectedIndex > selectedPositions[1]) {
|
processedItems.indexOfFirst { it.libraryManga.id == lastSelectedManga.id && it.libraryManga.category == lastSelectedManga.category }
|
||||||
selectedPositions[1] = selectedIndex
|
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)
|
state.copy(items = newItems)
|
||||||
@ -262,15 +288,45 @@ class DownloadStatsScreenModel(
|
|||||||
|
|
||||||
fun toggleAllSelection(selected: Boolean) {
|
fun toggleAllSelection(selected: Boolean) {
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
|
val newSelected = state.processedItems(false).map { manga -> manga.libraryManga.id }.toHashSet()
|
||||||
val newItems = state.items.map {
|
val newItems = state.items.map {
|
||||||
selectedMangaIds.addOrRemove(it.libraryManga.manga.id, selected)
|
if (it.libraryManga.id in newSelected) {
|
||||||
it.copy(selected = selected)
|
it.copy(selected = selected)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state.copy(items = newItems)
|
state.copy(items = newItems)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
selectedPositions[0] = -1
|
fun invertSelection() {
|
||||||
selectedPositions[1] = -1
|
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?) {
|
fun search(query: String?) {
|
||||||
@ -281,46 +337,143 @@ class DownloadStatsScreenModel(
|
|||||||
preferenceStore.getString("search_query", "").delete()
|
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 {
|
coroutineScope.launchNonCancellable {
|
||||||
libraryMangas.forEach { manga ->
|
manga.forEach { manga ->
|
||||||
val source = sourceManager.get(manga.libraryManga.manga.source) ?: return@forEach
|
downloadManager.deleteManga(manga.libraryManga.manga, manga.source)
|
||||||
downloadManager.deleteManga(manga.libraryManga.manga, source)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val set = libraryMangas.map { it.libraryManga.id }.toHashSet()
|
val toDeleteIds = manga.map { it.libraryManga.manga.id }.toHashSet()
|
||||||
toggleAllSelection(false)
|
toggleAllSelection(false)
|
||||||
mutableState.update { state ->
|
mutableState.update { state ->
|
||||||
state.copy(
|
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() {
|
fun showDeleteAlert(items: List<DownloadStatManga>) {
|
||||||
mutableState.update { state ->
|
mutableState.update { it.copy(dialog = Dialog.DeleteManga(items)) }
|
||||||
val newItems = state.items.map {
|
|
||||||
selectedMangaIds.addOrRemove(it.libraryManga.manga.id, !it.selected)
|
|
||||||
it.copy(selected = !it.selected)
|
|
||||||
}
|
|
||||||
state.copy(items = newItems)
|
|
||||||
}
|
|
||||||
selectedPositions[0] = -1
|
|
||||||
selectedPositions[1] = -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun groupSelection(items: List<DownloadStatManga>) {
|
fun dismissDialog() {
|
||||||
val newSelected = items.map { manga -> manga.libraryManga.id }.toHashSet()
|
mutableState.update { it.copy(dialog = null) }
|
||||||
selectedMangaIds.addAll(newSelected)
|
}
|
||||||
mutableState.update { state ->
|
|
||||||
val newItems = state.items.map {
|
fun openSettingsDialog() {
|
||||||
it.copy(selected = if (it.libraryManga.id in newSelected) !it.selected else it.selected)
|
mutableState.update { it.copy(dialog = Dialog.SettingsSheet) }
|
||||||
}
|
}
|
||||||
state.copy(items = newItems)
|
|
||||||
|
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 {
|
enum class SortingMode {
|
||||||
BY_ALPHABET,
|
BY_ALPHABET,
|
||||||
BY_SIZE,
|
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()
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
package eu.kanade.presentation.more.download
|
package eu.kanade.presentation.more.download
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import eu.kanade.presentation.more.download.data.DownloadStatManga
|
||||||
|
import tachiyomi.domain.stat.model.DownloadStatOperation
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class DownloadStatsScreenState(
|
data class DownloadStatsScreenState(
|
||||||
@ -10,21 +12,35 @@ data class DownloadStatsScreenState(
|
|||||||
val sortMode: SortingMode = SortingMode.BY_ALPHABET,
|
val sortMode: SortingMode = SortingMode.BY_ALPHABET,
|
||||||
val descendingOrder: Boolean = false,
|
val descendingOrder: Boolean = false,
|
||||||
val searchQuery: String? = null,
|
val searchQuery: String? = null,
|
||||||
|
val showNotDownloaded: Boolean = false,
|
||||||
|
val dialog: Dialog? = null,
|
||||||
|
val downloadStatOperations: List<DownloadStatOperation> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val selected = items.filter { it.selected }
|
val selected = items.filter { it.selected }
|
||||||
val selectionMode = selected.isNotEmpty()
|
val selectionMode = selected.isNotEmpty()
|
||||||
val processedItems: List<DownloadStatManga>
|
|
||||||
get() = search(items, searchQuery, groupByMode)
|
|
||||||
|
|
||||||
fun search(items: List<DownloadStatManga>, searchQuery: String?, groupByMode: GroupByMode): List<DownloadStatManga> {
|
fun uniqueItems(): List<DownloadStatManga> {
|
||||||
return if (searchQuery != null) {
|
val uniqueIds = HashSet<Long>()
|
||||||
items.filter { downloadStatManga ->
|
val uniqueMangas = mutableListOf<DownloadStatManga>()
|
||||||
downloadStatManga.libraryManga.manga.title.contains(searchQuery, true) ||
|
for (manga in items) {
|
||||||
if (groupByMode == GroupByMode.BY_SOURCE) { downloadStatManga.source.name.contains(searchQuery, true) } else { false } ||
|
if (uniqueIds.add(manga.libraryManga.manga.id)) {
|
||||||
if (groupByMode == GroupByMode.BY_CATEGORY) { downloadStatManga.category.name.contains(searchQuery, true) } else { false }
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
}
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package eu.kanade.presentation.more.download
|
package eu.kanade.presentation.more.download.data
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
@ -8,9 +8,10 @@ import tachiyomi.domain.library.model.LibraryManga
|
|||||||
@Immutable
|
@Immutable
|
||||||
data class DownloadStatManga(
|
data class DownloadStatManga(
|
||||||
val libraryManga: LibraryManga,
|
val libraryManga: LibraryManga,
|
||||||
val folderSize: Long,
|
val folderSize: Long = 0,
|
||||||
val selected: Boolean = false,
|
val selected: Boolean = false,
|
||||||
|
val expanded: Boolean = false,
|
||||||
val source: Source,
|
val source: Source,
|
||||||
val category: Category,
|
val category: Category,
|
||||||
val downloadChaptersCount: Int,
|
val downloadChaptersCount: Int = 0,
|
||||||
)
|
)
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
@ -22,6 +22,8 @@ import tachiyomi.domain.chapter.model.Chapter
|
|||||||
import tachiyomi.domain.download.service.DownloadPreferences
|
import tachiyomi.domain.download.service.DownloadPreferences
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
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.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
@ -37,6 +39,7 @@ class DownloadManager(
|
|||||||
private val getCategories: GetCategories = Injekt.get(),
|
private val getCategories: GetCategories = Injekt.get(),
|
||||||
private val sourceManager: SourceManager = Injekt.get(),
|
private val sourceManager: SourceManager = Injekt.get(),
|
||||||
private val downloadPreferences: DownloadPreferences = Injekt.get(),
|
private val downloadPreferences: DownloadPreferences = Injekt.get(),
|
||||||
|
private val addDownloadStatOperation: AddDownloadStatOperation = Injekt.get(),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -223,6 +226,14 @@ class DownloadManager(
|
|||||||
removeFromDownloadQueue(filteredChapters)
|
removeFromDownloadQueue(filteredChapters)
|
||||||
|
|
||||||
val (mangaDir, chapterDirs) = provider.findChapterDirs(filteredChapters, manga, source)
|
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() }
|
chapterDirs.forEach { it.delete() }
|
||||||
cache.removeChapters(filteredChapters, manga)
|
cache.removeChapters(filteredChapters, manga)
|
||||||
|
|
||||||
@ -245,7 +256,18 @@ class DownloadManager(
|
|||||||
if (removeQueued) {
|
if (removeQueued) {
|
||||||
downloader.removeFromQueue(manga)
|
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)
|
cache.removeManga(manga)
|
||||||
|
|
||||||
// Delete source directory if empty
|
// Delete source directory if empty
|
||||||
|
@ -16,6 +16,7 @@ import tachiyomi.domain.download.service.DownloadPreferences
|
|||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is used to provide the directories where the downloads should be saved.
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -54,6 +54,8 @@ import tachiyomi.domain.chapter.model.Chapter
|
|||||||
import tachiyomi.domain.download.service.DownloadPreferences
|
import tachiyomi.domain.download.service.DownloadPreferences
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
import tachiyomi.domain.source.service.SourceManager
|
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.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.io.BufferedOutputStream
|
import java.io.BufferedOutputStream
|
||||||
@ -74,6 +76,7 @@ class Downloader(
|
|||||||
private val sourceManager: SourceManager = Injekt.get(),
|
private val sourceManager: SourceManager = Injekt.get(),
|
||||||
private val chapterCache: ChapterCache = Injekt.get(),
|
private val chapterCache: ChapterCache = Injekt.get(),
|
||||||
private val downloadPreferences: DownloadPreferences = Injekt.get(),
|
private val downloadPreferences: DownloadPreferences = Injekt.get(),
|
||||||
|
private val addDownloadStatOperation: AddDownloadStatOperation = Injekt.get(),
|
||||||
private val xml: XML = Injekt.get(),
|
private val xml: XML = Injekt.get(),
|
||||||
private val getCategories: GetCategories = Injekt.get(),
|
private val getCategories: GetCategories = Injekt.get(),
|
||||||
) {
|
) {
|
||||||
@ -401,6 +404,13 @@ class Downloader(
|
|||||||
|
|
||||||
DiskUtil.createNoMediaFile(tmpDir, context)
|
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
|
download.status = Download.State.DOWNLOADED
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
if (error is CancellationException) throw error
|
if (error is CancellationException) throw error
|
||||||
|
14
data/src/main/java/tachiyomi/data/stat/DownloadStatMapper.kt
Normal file
14
data/src/main/java/tachiyomi/data/stat/DownloadStatMapper.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
data/src/main/sqldelight/tachiyomi/data/download_stat.sq
Normal file
18
data/src/main/sqldelight/tachiyomi/data/download_stat.sq
Normal 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);
|
10
data/src/main/sqldelight/tachiyomi/migrations/27.sqm
Normal file
10
data/src/main/sqldelight/tachiyomi/migrations/27.sqm
Normal 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
|
||||||
|
);
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
@ -58,7 +58,6 @@
|
|||||||
<!-- reserved for #4048 -->
|
<!-- reserved for #4048 -->
|
||||||
<string name="action_filter_empty">Remove filter</string>
|
<string name="action_filter_empty">Remove filter</string>
|
||||||
<string name="action_sort_alpha">Alphabetically</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_count">Total entries</string>
|
||||||
<string name="action_sort_total">Total chapters</string>
|
<string name="action_sort_total">Total chapters</string>
|
||||||
<string name="action_sort_last_read">Last read</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_copy_to_clipboard">Copy to clipboard</string>
|
||||||
<string name="action_group_by_category">Category</string>
|
<string name="action_group_by_category">Category</string>
|
||||||
<string name="action_group_by_source">Source</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" -->
|
<!-- Do not translate "WebView" -->
|
||||||
<string name="action_open_in_web_view">Open in WebView</string>
|
<string name="action_open_in_web_view">Open in WebView</string>
|
||||||
<string name="action_web_view" translatable="false">WebView</string>
|
<string name="action_web_view" translatable="false">WebView</string>
|
||||||
@ -165,6 +166,23 @@
|
|||||||
<string name="action_faq_and_guides">FAQ and Guides</string>
|
<string name="action_faq_and_guides">FAQ and Guides</string>
|
||||||
<string name="action_not_now">Not now</string>
|
<string name="action_not_now">Not now</string>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
<!-- Memory units -->
|
||||||
<string name="memory_unit_b">B</string>
|
<string name="memory_unit_b">B</string>
|
||||||
<string name="memory_unit_kb">KB</string>
|
<string name="memory_unit_kb">KB</string>
|
||||||
@ -707,6 +725,7 @@
|
|||||||
<string name="error_saving_cover">Error saving cover</string>
|
<string name="error_saving_cover">Error saving cover</string>
|
||||||
<string name="error_sharing_cover">Error sharing 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_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="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="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>
|
<string name="also_set_chapter_settings_for_library">Also apply to all entries in my library</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user