BrowseSourceScreen.kt 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. package eu.kanade.presentation.browse
  2. import androidx.compose.animation.AnimatedVisibility
  3. import androidx.compose.foundation.background
  4. import androidx.compose.foundation.horizontalScroll
  5. import androidx.compose.foundation.layout.Arrangement
  6. import androidx.compose.foundation.layout.Column
  7. import androidx.compose.foundation.layout.PaddingValues
  8. import androidx.compose.foundation.layout.Row
  9. import androidx.compose.foundation.layout.navigationBarsPadding
  10. import androidx.compose.foundation.layout.padding
  11. import androidx.compose.foundation.layout.size
  12. import androidx.compose.foundation.lazy.grid.GridCells
  13. import androidx.compose.foundation.rememberScrollState
  14. import androidx.compose.material.icons.Icons
  15. import androidx.compose.material.icons.filled.HelpOutline
  16. import androidx.compose.material.icons.filled.Public
  17. import androidx.compose.material.icons.filled.Refresh
  18. import androidx.compose.material.icons.outlined.Favorite
  19. import androidx.compose.material.icons.outlined.FilterList
  20. import androidx.compose.material.icons.outlined.NewReleases
  21. import androidx.compose.material3.FilterChip
  22. import androidx.compose.material3.FilterChipDefaults
  23. import androidx.compose.material3.Icon
  24. import androidx.compose.material3.MaterialTheme
  25. import androidx.compose.material3.SnackbarDuration
  26. import androidx.compose.material3.SnackbarHost
  27. import androidx.compose.material3.SnackbarHostState
  28. import androidx.compose.material3.SnackbarResult
  29. import androidx.compose.material3.Text
  30. import androidx.compose.runtime.Composable
  31. import androidx.compose.runtime.LaunchedEffect
  32. import androidx.compose.runtime.State
  33. import androidx.compose.runtime.getValue
  34. import androidx.compose.runtime.remember
  35. import androidx.compose.ui.Modifier
  36. import androidx.compose.ui.platform.LocalContext
  37. import androidx.compose.ui.platform.LocalUriHandler
  38. import androidx.compose.ui.res.stringResource
  39. import androidx.compose.ui.unit.dp
  40. import androidx.paging.LoadState
  41. import androidx.paging.compose.LazyPagingItems
  42. import androidx.paging.compose.collectAsLazyPagingItems
  43. import eu.kanade.data.source.NoResultsException
  44. import eu.kanade.domain.library.model.LibraryDisplayMode
  45. import eu.kanade.domain.manga.model.Manga
  46. import eu.kanade.domain.source.interactor.GetRemoteManga
  47. import eu.kanade.presentation.browse.components.BrowseSourceComfortableGrid
  48. import eu.kanade.presentation.browse.components.BrowseSourceCompactGrid
  49. import eu.kanade.presentation.browse.components.BrowseSourceList
  50. import eu.kanade.presentation.browse.components.BrowseSourceToolbar
  51. import eu.kanade.presentation.components.AppStateBanners
  52. import eu.kanade.presentation.components.Divider
  53. import eu.kanade.presentation.components.EmptyScreen
  54. import eu.kanade.presentation.components.EmptyScreenAction
  55. import eu.kanade.presentation.components.ExtendedFloatingActionButton
  56. import eu.kanade.presentation.components.LoadingScreen
  57. import eu.kanade.presentation.components.Scaffold
  58. import eu.kanade.tachiyomi.R
  59. import eu.kanade.tachiyomi.source.LocalSource
  60. import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
  61. import eu.kanade.tachiyomi.ui.more.MoreController
  62. @Composable
  63. fun BrowseSourceScreen(
  64. presenter: BrowseSourcePresenter,
  65. navigateUp: () -> Unit,
  66. openFilterSheet: () -> Unit,
  67. onMangaClick: (Manga) -> Unit,
  68. onMangaLongClick: (Manga) -> Unit,
  69. onWebViewClick: () -> Unit,
  70. incognitoMode: Boolean,
  71. downloadedOnlyMode: Boolean,
  72. ) {
  73. val columns by presenter.getColumnsPreferenceForCurrentOrientation()
  74. val mangaList = presenter.getMangaList().collectAsLazyPagingItems()
  75. val snackbarHostState = remember { SnackbarHostState() }
  76. val uriHandler = LocalUriHandler.current
  77. val onHelpClick = {
  78. uriHandler.openUri(LocalSource.HELP_URL)
  79. }
  80. Scaffold(
  81. topBar = {
  82. Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
  83. BrowseSourceToolbar(
  84. state = presenter,
  85. source = presenter.source!!,
  86. displayMode = presenter.displayMode,
  87. onDisplayModeChange = { presenter.displayMode = it },
  88. navigateUp = navigateUp,
  89. onWebViewClick = onWebViewClick,
  90. onHelpClick = onHelpClick,
  91. onSearch = { presenter.search(it) },
  92. )
  93. Row(
  94. modifier = Modifier
  95. .horizontalScroll(rememberScrollState())
  96. .padding(horizontal = 8.dp),
  97. horizontalArrangement = Arrangement.spacedBy(8.dp),
  98. ) {
  99. FilterChip(
  100. selected = presenter.currentFilter == BrowseSourcePresenter.Filter.Popular,
  101. onClick = {
  102. presenter.reset()
  103. presenter.search(GetRemoteManga.QUERY_POPULAR)
  104. },
  105. leadingIcon = {
  106. Icon(
  107. imageVector = Icons.Outlined.Favorite,
  108. contentDescription = "",
  109. modifier = Modifier
  110. .size(FilterChipDefaults.IconSize),
  111. )
  112. },
  113. label = {
  114. Text(text = stringResource(R.string.popular))
  115. },
  116. )
  117. if (presenter.source?.supportsLatest == true) {
  118. FilterChip(
  119. selected = presenter.currentFilter == BrowseSourcePresenter.Filter.Latest,
  120. onClick = {
  121. presenter.reset()
  122. presenter.search(GetRemoteManga.QUERY_LATEST)
  123. },
  124. leadingIcon = {
  125. Icon(
  126. imageVector = Icons.Outlined.NewReleases,
  127. contentDescription = "",
  128. modifier = Modifier
  129. .size(FilterChipDefaults.IconSize),
  130. )
  131. },
  132. label = {
  133. Text(text = stringResource(R.string.latest))
  134. },
  135. )
  136. }
  137. if (presenter.filters.isNotEmpty()) {
  138. FilterChip(
  139. selected = presenter.currentFilter is BrowseSourcePresenter.Filter.UserInput,
  140. onClick = openFilterSheet,
  141. leadingIcon = {
  142. Icon(
  143. imageVector = Icons.Outlined.FilterList,
  144. contentDescription = "",
  145. modifier = Modifier
  146. .size(FilterChipDefaults.IconSize),
  147. )
  148. },
  149. label = {
  150. Text(text = stringResource(R.string.action_filter))
  151. },
  152. )
  153. }
  154. }
  155. Divider()
  156. AppStateBanners(downloadedOnlyMode, incognitoMode)
  157. }
  158. },
  159. snackbarHost = {
  160. SnackbarHost(hostState = snackbarHostState)
  161. },
  162. ) { paddingValues ->
  163. BrowseSourceContent(
  164. state = presenter,
  165. mangaList = mangaList,
  166. getMangaState = { presenter.getManga(it) },
  167. columns = columns,
  168. displayMode = presenter.displayMode,
  169. snackbarHostState = snackbarHostState,
  170. contentPadding = paddingValues,
  171. onWebViewClick = onWebViewClick,
  172. onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
  173. onLocalSourceHelpClick = onHelpClick,
  174. onMangaClick = onMangaClick,
  175. onMangaLongClick = onMangaLongClick,
  176. )
  177. }
  178. }
  179. @Composable
  180. fun BrowseSourceFloatingActionButton(
  181. modifier: Modifier = Modifier.navigationBarsPadding(),
  182. isVisible: Boolean,
  183. onFabClick: () -> Unit,
  184. ) {
  185. AnimatedVisibility(visible = isVisible) {
  186. ExtendedFloatingActionButton(
  187. modifier = modifier,
  188. text = { Text(text = stringResource(R.string.action_filter)) },
  189. icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
  190. onClick = onFabClick,
  191. )
  192. }
  193. }
  194. @Composable
  195. fun BrowseSourceContent(
  196. state: BrowseSourceState,
  197. mangaList: LazyPagingItems<Manga>,
  198. getMangaState: @Composable ((Manga) -> State<Manga>),
  199. columns: GridCells,
  200. displayMode: LibraryDisplayMode,
  201. snackbarHostState: SnackbarHostState,
  202. contentPadding: PaddingValues,
  203. onWebViewClick: () -> Unit,
  204. onHelpClick: () -> Unit,
  205. onLocalSourceHelpClick: () -> Unit,
  206. onMangaClick: (Manga) -> Unit,
  207. onMangaLongClick: (Manga) -> Unit,
  208. ) {
  209. val context = LocalContext.current
  210. val errorState = mangaList.loadState.refresh.takeIf { it is LoadState.Error }
  211. ?: mangaList.loadState.append.takeIf { it is LoadState.Error }
  212. val getErrorMessage: (LoadState.Error) -> String = { state ->
  213. when {
  214. state.error is NoResultsException -> context.getString(R.string.no_results_found)
  215. state.error.message.isNullOrEmpty() -> ""
  216. state.error.message.orEmpty().startsWith("HTTP error") -> "${state.error.message}: ${context.getString(R.string.http_error_hint)}"
  217. else -> state.error.message.orEmpty()
  218. }
  219. }
  220. LaunchedEffect(errorState) {
  221. if (mangaList.itemCount > 0 && errorState != null && errorState is LoadState.Error) {
  222. val result = snackbarHostState.showSnackbar(
  223. message = getErrorMessage(errorState),
  224. actionLabel = context.getString(R.string.action_webview_refresh),
  225. duration = SnackbarDuration.Indefinite,
  226. )
  227. when (result) {
  228. SnackbarResult.Dismissed -> snackbarHostState.currentSnackbarData?.dismiss()
  229. SnackbarResult.ActionPerformed -> mangaList.refresh()
  230. }
  231. }
  232. }
  233. if (mangaList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) {
  234. EmptyScreen(
  235. message = getErrorMessage(errorState),
  236. actions = if (state.source is LocalSource) {
  237. listOf(
  238. EmptyScreenAction(
  239. stringResId = R.string.local_source_help_guide,
  240. icon = Icons.Default.HelpOutline,
  241. onClick = onLocalSourceHelpClick,
  242. ),
  243. )
  244. } else {
  245. listOf(
  246. EmptyScreenAction(
  247. stringResId = R.string.action_retry,
  248. icon = Icons.Default.Refresh,
  249. onClick = mangaList::refresh,
  250. ),
  251. EmptyScreenAction(
  252. stringResId = R.string.action_open_in_web_view,
  253. icon = Icons.Default.Public,
  254. onClick = onWebViewClick,
  255. ),
  256. EmptyScreenAction(
  257. stringResId = R.string.label_help,
  258. icon = Icons.Default.HelpOutline,
  259. onClick = onHelpClick,
  260. ),
  261. )
  262. },
  263. )
  264. return
  265. }
  266. if (mangaList.itemCount == 0 && mangaList.loadState.refresh is LoadState.Loading) {
  267. LoadingScreen()
  268. return
  269. }
  270. when (displayMode) {
  271. LibraryDisplayMode.ComfortableGrid -> {
  272. BrowseSourceComfortableGrid(
  273. mangaList = mangaList,
  274. getMangaState = getMangaState,
  275. columns = columns,
  276. contentPadding = contentPadding,
  277. onMangaClick = onMangaClick,
  278. onMangaLongClick = onMangaLongClick,
  279. )
  280. }
  281. LibraryDisplayMode.List -> {
  282. BrowseSourceList(
  283. mangaList = mangaList,
  284. getMangaState = getMangaState,
  285. contentPadding = contentPadding,
  286. onMangaClick = onMangaClick,
  287. onMangaLongClick = onMangaLongClick,
  288. )
  289. }
  290. else -> {
  291. BrowseSourceCompactGrid(
  292. mangaList = mangaList,
  293. getMangaState = getMangaState,
  294. columns = columns,
  295. contentPadding = contentPadding,
  296. onMangaClick = onMangaClick,
  297. onMangaLongClick = onMangaLongClick,
  298. )
  299. }
  300. }
  301. }