LibraryTab.kt 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. package eu.kanade.tachiyomi.ui.library
  2. import androidx.activity.compose.BackHandler
  3. import androidx.compose.animation.graphics.res.animatedVectorResource
  4. import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
  5. import androidx.compose.animation.graphics.vector.AnimatedImageVector
  6. import androidx.compose.foundation.layout.padding
  7. import androidx.compose.material.icons.Icons
  8. import androidx.compose.material.icons.outlined.HelpOutline
  9. import androidx.compose.material3.SnackbarHost
  10. import androidx.compose.material3.SnackbarHostState
  11. import androidx.compose.runtime.Composable
  12. import androidx.compose.runtime.LaunchedEffect
  13. import androidx.compose.runtime.collectAsState
  14. import androidx.compose.runtime.getValue
  15. import androidx.compose.runtime.remember
  16. import androidx.compose.runtime.rememberCoroutineScope
  17. import androidx.compose.ui.Modifier
  18. import androidx.compose.ui.hapticfeedback.HapticFeedbackType
  19. import androidx.compose.ui.platform.LocalContext
  20. import androidx.compose.ui.platform.LocalHapticFeedback
  21. import androidx.compose.ui.platform.LocalUriHandler
  22. import androidx.compose.ui.res.stringResource
  23. import androidx.compose.ui.util.fastAll
  24. import cafe.adriel.voyager.core.model.rememberScreenModel
  25. import cafe.adriel.voyager.navigator.LocalNavigator
  26. import cafe.adriel.voyager.navigator.Navigator
  27. import cafe.adriel.voyager.navigator.currentOrThrow
  28. import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
  29. import cafe.adriel.voyager.navigator.tab.TabOptions
  30. import eu.kanade.domain.category.model.Category
  31. import eu.kanade.domain.library.model.LibraryManga
  32. import eu.kanade.domain.library.model.display
  33. import eu.kanade.domain.manga.model.Manga
  34. import eu.kanade.domain.manga.model.isLocal
  35. import eu.kanade.presentation.components.ChangeCategoryDialog
  36. import eu.kanade.presentation.components.DeleteLibraryMangaDialog
  37. import eu.kanade.presentation.components.EmptyScreen
  38. import eu.kanade.presentation.components.EmptyScreenAction
  39. import eu.kanade.presentation.components.LibraryBottomActionMenu
  40. import eu.kanade.presentation.components.LoadingScreen
  41. import eu.kanade.presentation.components.Scaffold
  42. import eu.kanade.presentation.library.components.LibraryContent
  43. import eu.kanade.presentation.library.components.LibraryToolbar
  44. import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog
  45. import eu.kanade.presentation.util.Tab
  46. import eu.kanade.tachiyomi.R
  47. import eu.kanade.tachiyomi.data.library.LibraryUpdateService
  48. import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
  49. import eu.kanade.tachiyomi.ui.category.CategoryScreen
  50. import eu.kanade.tachiyomi.ui.home.HomeScreen
  51. import eu.kanade.tachiyomi.ui.main.MainActivity
  52. import eu.kanade.tachiyomi.ui.manga.MangaScreen
  53. import eu.kanade.tachiyomi.ui.reader.ReaderActivity
  54. import eu.kanade.tachiyomi.util.lang.launchIO
  55. import kotlinx.coroutines.channels.Channel
  56. import kotlinx.coroutines.flow.collectLatest
  57. import kotlinx.coroutines.flow.receiveAsFlow
  58. import kotlinx.coroutines.launch
  59. object LibraryTab : Tab {
  60. override val options: TabOptions
  61. @Composable
  62. get() {
  63. val isSelected = LocalTabNavigator.current.current.key == key
  64. val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_library_enter)
  65. return TabOptions(
  66. index = 0u,
  67. title = stringResource(R.string.label_library),
  68. icon = rememberAnimatedVectorPainter(image, isSelected),
  69. )
  70. }
  71. override suspend fun onReselect(navigator: Navigator) {
  72. requestOpenSettingsSheet()
  73. }
  74. @Composable
  75. override fun Content() {
  76. val navigator = LocalNavigator.currentOrThrow
  77. val context = LocalContext.current
  78. val scope = rememberCoroutineScope()
  79. val haptic = LocalHapticFeedback.current
  80. val screenModel = rememberScreenModel { LibraryScreenModel() }
  81. val state by screenModel.state.collectAsState()
  82. val snackbarHostState = remember { SnackbarHostState() }
  83. val onClickRefresh: (Category?) -> Boolean = {
  84. val started = LibraryUpdateService.start(context, it)
  85. scope.launch {
  86. val msgRes = if (started) R.string.updating_category else R.string.update_already_running
  87. snackbarHostState.showSnackbar(context.getString(msgRes))
  88. }
  89. started
  90. }
  91. val onClickFilter: () -> Unit = {
  92. scope.launch { sendSettingsSheetIntent(state.categories[screenModel.activeCategoryIndex]) }
  93. }
  94. Scaffold(
  95. topBar = { scrollBehavior ->
  96. val title = state.getToolbarTitle(
  97. defaultTitle = stringResource(R.string.label_library),
  98. defaultCategoryTitle = stringResource(R.string.label_default),
  99. page = screenModel.activeCategoryIndex,
  100. )
  101. val tabVisible = state.showCategoryTabs && state.categories.size > 1
  102. LibraryToolbar(
  103. hasActiveFilters = state.hasActiveFilters,
  104. selectedCount = state.selection.size,
  105. title = title,
  106. incognitoMode = !tabVisible && screenModel.isIncognitoMode,
  107. downloadedOnlyMode = !tabVisible && screenModel.isDownloadOnly,
  108. onClickUnselectAll = screenModel::clearSelection,
  109. onClickSelectAll = { screenModel.selectAll(screenModel.activeCategoryIndex) },
  110. onClickInvertSelection = { screenModel.invertSelection(screenModel.activeCategoryIndex) },
  111. onClickFilter = onClickFilter,
  112. onClickRefresh = { onClickRefresh(null) },
  113. onClickOpenRandomManga = {
  114. scope.launch {
  115. val randomItem = screenModel.getRandomLibraryItemForCurrentCategory()
  116. if (randomItem != null) {
  117. navigator.push(MangaScreen(randomItem.libraryManga.manga.id))
  118. } else {
  119. snackbarHostState.showSnackbar(context.getString(R.string.information_no_entries_found))
  120. }
  121. }
  122. },
  123. searchQuery = state.searchQuery,
  124. onSearchQueryChange = screenModel::search,
  125. scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab
  126. )
  127. },
  128. bottomBar = {
  129. LibraryBottomActionMenu(
  130. visible = state.selectionMode,
  131. onChangeCategoryClicked = screenModel::openChangeCategoryDialog,
  132. onMarkAsReadClicked = { screenModel.markReadSelection(true) },
  133. onMarkAsUnreadClicked = { screenModel.markReadSelection(false) },
  134. onDownloadClicked = screenModel::runDownloadActionSelection
  135. .takeIf { state.selection.fastAll { !it.manga.isLocal() } },
  136. onDeleteClicked = screenModel::openDeleteMangaDialog,
  137. )
  138. },
  139. snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
  140. ) { contentPadding ->
  141. when {
  142. state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
  143. state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.libraryCount == 0 -> {
  144. val handler = LocalUriHandler.current
  145. EmptyScreen(
  146. textResource = R.string.information_empty_library,
  147. modifier = Modifier.padding(contentPadding),
  148. actions = listOf(
  149. EmptyScreenAction(
  150. stringResId = R.string.getting_started_guide,
  151. icon = Icons.Outlined.HelpOutline,
  152. onClick = { handler.openUri("https://tachiyomi.org/help/guides/getting-started") },
  153. ),
  154. ),
  155. )
  156. }
  157. else -> {
  158. LibraryContent(
  159. categories = state.categories,
  160. searchQuery = state.searchQuery,
  161. selection = state.selection,
  162. contentPadding = contentPadding,
  163. currentPage = { screenModel.activeCategoryIndex },
  164. showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(),
  165. onChangeCurrentPage = { screenModel.activeCategoryIndex = it },
  166. onMangaClicked = { navigator.push(MangaScreen(it)) },
  167. onContinueReadingClicked = { it: LibraryManga ->
  168. scope.launchIO {
  169. val chapter = screenModel.getNextUnreadChapter(it.manga)
  170. if (chapter != null) {
  171. context.startActivity(ReaderActivity.newIntent(context, chapter.mangaId, chapter.id))
  172. } else {
  173. snackbarHostState.showSnackbar(context.getString(R.string.no_next_chapter))
  174. }
  175. }
  176. Unit
  177. }.takeIf { state.showMangaContinueButton },
  178. onToggleSelection = { screenModel.toggleSelection(it) },
  179. onToggleRangeSelection = {
  180. screenModel.toggleRangeSelection(it)
  181. haptic.performHapticFeedback(HapticFeedbackType.LongPress)
  182. },
  183. onRefresh = onClickRefresh,
  184. onGlobalSearchClicked = {
  185. navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: ""))
  186. },
  187. getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) },
  188. getDisplayModeForPage = { state.categories[it].display },
  189. getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) },
  190. getLibraryForPage = { state.getLibraryItemsByPage(it) },
  191. isDownloadOnly = screenModel.isDownloadOnly,
  192. isIncognitoMode = screenModel.isIncognitoMode,
  193. )
  194. }
  195. }
  196. }
  197. val onDismissRequest = screenModel::closeDialog
  198. when (val dialog = state.dialog) {
  199. is LibraryScreenModel.Dialog.ChangeCategory -> {
  200. ChangeCategoryDialog(
  201. initialSelection = dialog.initialSelection,
  202. onDismissRequest = onDismissRequest,
  203. onEditCategories = {
  204. screenModel.clearSelection()
  205. navigator.push(CategoryScreen())
  206. },
  207. onConfirm = { include, exclude ->
  208. screenModel.clearSelection()
  209. screenModel.setMangaCategories(dialog.manga, include, exclude)
  210. },
  211. )
  212. }
  213. is LibraryScreenModel.Dialog.DeleteManga -> {
  214. DeleteLibraryMangaDialog(
  215. containsLocalManga = dialog.manga.any(Manga::isLocal),
  216. onDismissRequest = onDismissRequest,
  217. onConfirm = { deleteManga, deleteChapter ->
  218. screenModel.removeMangas(dialog.manga, deleteManga, deleteChapter)
  219. screenModel.clearSelection()
  220. },
  221. )
  222. }
  223. is LibraryScreenModel.Dialog.DownloadCustomAmount -> {
  224. DownloadCustomAmountDialog(
  225. maxAmount = dialog.max,
  226. onDismissRequest = onDismissRequest,
  227. onConfirm = { amount ->
  228. screenModel.downloadUnreadChapters(dialog.manga, amount)
  229. screenModel.clearSelection()
  230. },
  231. )
  232. }
  233. null -> {}
  234. }
  235. BackHandler(enabled = state.selectionMode || state.searchQuery != null) {
  236. when {
  237. state.selectionMode -> screenModel.clearSelection()
  238. state.searchQuery != null -> screenModel.search(null)
  239. }
  240. }
  241. LaunchedEffect(state.selectionMode) {
  242. HomeScreen.showBottomNav(!state.selectionMode)
  243. }
  244. LaunchedEffect(state.isLoading) {
  245. if (!state.isLoading) {
  246. (context as? MainActivity)?.ready = true
  247. }
  248. }
  249. LaunchedEffect(Unit) {
  250. launch { queryEvent.receiveAsFlow().collect(screenModel::search) }
  251. launch { requestSettingsSheetEvent.receiveAsFlow().collectLatest { onClickFilter() } }
  252. }
  253. }
  254. // For invoking search from other screen
  255. private val queryEvent = Channel<String>()
  256. suspend fun search(query: String) = queryEvent.send(query)
  257. // For opening settings sheet in LibraryController
  258. private val requestSettingsSheetEvent = Channel<Unit>()
  259. private val openSettingsSheetEvent_ = Channel<Category>()
  260. val openSettingsSheetEvent = openSettingsSheetEvent_.receiveAsFlow()
  261. private suspend fun sendSettingsSheetIntent(category: Category) = openSettingsSheetEvent_.send(category)
  262. suspend fun requestOpenSettingsSheet() = requestSettingsSheetEvent.send(Unit)
  263. }