Use Voyager on BrowseSource and SourceSearch screen (#8650)

Some navigation janks will be dealt with when the migration is complete
This commit is contained in:
Ivan Iskandar 2022-12-01 11:05:11 +07:00 committed by GitHub
parent 8eda4df71f
commit 94d1b68598
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 653 additions and 637 deletions

View File

@ -1,213 +1,37 @@
package eu.kanade.presentation.browse package eu.kanade.presentation.browse
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import eu.kanade.data.source.NoResultsException import eu.kanade.data.source.NoResultsException
import eu.kanade.domain.library.model.LibraryDisplayMode import eu.kanade.domain.library.model.LibraryDisplayMode
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.source.interactor.GetRemoteManga
import eu.kanade.presentation.browse.components.BrowseSourceComfortableGrid import eu.kanade.presentation.browse.components.BrowseSourceComfortableGrid
import eu.kanade.presentation.browse.components.BrowseSourceCompactGrid import eu.kanade.presentation.browse.components.BrowseSourceCompactGrid
import eu.kanade.presentation.browse.components.BrowseSourceList import eu.kanade.presentation.browse.components.BrowseSourceList
import eu.kanade.presentation.browse.components.BrowseSourceToolbar
import eu.kanade.presentation.components.AppStateBanners
import eu.kanade.presentation.components.Divider
import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.EmptyScreenAction import eu.kanade.presentation.components.EmptyScreenAction
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.Scaffold
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter import kotlinx.coroutines.flow.StateFlow
import eu.kanade.tachiyomi.ui.more.MoreController
@Composable
fun BrowseSourceScreen(
presenter: BrowseSourcePresenter,
navigateUp: () -> Unit,
openFilterSheet: () -> Unit,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
onWebViewClick: () -> Unit,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
) {
val columns by presenter.getColumnsPreferenceForCurrentOrientation()
val mangaList = presenter.getMangaList().collectAsLazyPagingItems()
val snackbarHostState = remember { SnackbarHostState() }
val uriHandler = LocalUriHandler.current
val onHelpClick = {
uriHandler.openUri(LocalSource.HELP_URL)
}
Scaffold(
topBar = {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
BrowseSourceToolbar(
state = presenter,
source = presenter.source,
displayMode = presenter.displayMode,
onDisplayModeChange = { presenter.displayMode = it },
navigateUp = navigateUp,
onWebViewClick = onWebViewClick,
onHelpClick = onHelpClick,
onSearch = { presenter.search(it) },
)
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = presenter.currentFilter == BrowseSourcePresenter.Filter.Popular,
onClick = {
presenter.reset()
presenter.search(GetRemoteManga.QUERY_POPULAR)
},
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Favorite,
contentDescription = "",
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(R.string.popular))
},
)
if (presenter.source?.supportsLatest == true) {
FilterChip(
selected = presenter.currentFilter == BrowseSourcePresenter.Filter.Latest,
onClick = {
presenter.reset()
presenter.search(GetRemoteManga.QUERY_LATEST)
},
leadingIcon = {
Icon(
imageVector = Icons.Outlined.NewReleases,
contentDescription = "",
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(R.string.latest))
},
)
}
if (presenter.filters.isNotEmpty()) {
FilterChip(
selected = presenter.currentFilter is BrowseSourcePresenter.Filter.UserInput,
onClick = openFilterSheet,
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterList,
contentDescription = "",
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(R.string.action_filter))
},
)
}
}
Divider()
AppStateBanners(downloadedOnlyMode, incognitoMode)
}
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
) { paddingValues ->
BrowseSourceContent(
state = presenter,
mangaList = mangaList,
getMangaState = { presenter.getManga(it) },
columns = columns,
displayMode = presenter.displayMode,
snackbarHostState = snackbarHostState,
contentPadding = paddingValues,
onWebViewClick = onWebViewClick,
onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
onLocalSourceHelpClick = onHelpClick,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
}
@Composable
fun BrowseSourceFloatingActionButton(
modifier: Modifier = Modifier.navigationBarsPadding(),
isVisible: Boolean,
onFabClick: () -> Unit,
) {
AnimatedVisibility(visible = isVisible) {
ExtendedFloatingActionButton(
modifier = modifier,
text = { Text(text = stringResource(R.string.action_filter)) },
icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
onClick = onFabClick,
)
}
}
@Composable @Composable
fun BrowseSourceContent( fun BrowseSourceContent(
state: BrowseSourceState, source: CatalogueSource?,
mangaList: LazyPagingItems<Manga>, mangaList: LazyPagingItems<StateFlow<Manga>>,
getMangaState: @Composable ((Manga) -> State<Manga>),
columns: GridCells, columns: GridCells,
displayMode: LibraryDisplayMode, displayMode: LibraryDisplayMode,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
@ -249,7 +73,7 @@ fun BrowseSourceContent(
if (mangaList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) { if (mangaList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) {
EmptyScreen( EmptyScreen(
message = getErrorMessage(errorState), message = getErrorMessage(errorState),
actions = if (state.source is LocalSource) { actions = if (source is LocalSource) {
listOf( listOf(
EmptyScreenAction( EmptyScreenAction(
stringResId = R.string.local_source_help_guide, stringResId = R.string.local_source_help_guide,
@ -290,7 +114,6 @@ fun BrowseSourceContent(
LibraryDisplayMode.ComfortableGrid -> { LibraryDisplayMode.ComfortableGrid -> {
BrowseSourceComfortableGrid( BrowseSourceComfortableGrid(
mangaList = mangaList, mangaList = mangaList,
getMangaState = getMangaState,
columns = columns, columns = columns,
contentPadding = contentPadding, contentPadding = contentPadding,
onMangaClick = onMangaClick, onMangaClick = onMangaClick,
@ -300,16 +123,14 @@ fun BrowseSourceContent(
LibraryDisplayMode.List -> { LibraryDisplayMode.List -> {
BrowseSourceList( BrowseSourceList(
mangaList = mangaList, mangaList = mangaList,
getMangaState = getMangaState,
contentPadding = contentPadding, contentPadding = contentPadding,
onMangaClick = onMangaClick, onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick, onMangaLongClick = onMangaLongClick,
) )
} }
else -> { LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
BrowseSourceCompactGrid( BrowseSourceCompactGrid(
mangaList = mangaList, mangaList = mangaList,
getMangaState = getMangaState,
columns = columns, columns = columns,
contentPadding = contentPadding, contentPadding = contentPadding,
onMangaClick = onMangaClick, onMangaClick = onMangaClick,

View File

@ -1,41 +0,0 @@
package eu.kanade.presentation.browse
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Filter
import eu.kanade.tachiyomi.ui.browse.source.browse.toItems
@Stable
interface BrowseSourceState {
val source: CatalogueSource?
var searchQuery: String?
val currentFilter: Filter
val isUserQuery: Boolean
val filters: FilterList
val filterItems: List<IFlexible<*>>
var dialog: BrowseSourcePresenter.Dialog?
}
fun BrowseSourceState(initialQuery: String?): BrowseSourceState {
return when (val filter = Filter.valueOf(initialQuery ?: "")) {
Filter.Latest, Filter.Popular -> BrowseSourceStateImpl(initialCurrentFilter = filter)
is Filter.UserInput -> BrowseSourceStateImpl(initialQuery = initialQuery, initialCurrentFilter = filter)
}
}
class BrowseSourceStateImpl(initialQuery: String? = null, initialCurrentFilter: Filter) : BrowseSourceState {
override var source: CatalogueSource? by mutableStateOf(null)
override var searchQuery: String? by mutableStateOf(initialQuery)
override var currentFilter: Filter by mutableStateOf(initialCurrentFilter)
override val isUserQuery: Boolean by derivedStateOf { currentFilter is Filter.UserInput && currentFilter.query.isNotEmpty() }
override var filters: FilterList by mutableStateOf(FilterList())
override val filterItems: List<IFlexible<*>> by derivedStateOf { filters.toItems() }
override var dialog: BrowseSourcePresenter.Dialog? by mutableStateOf(null)
}

View File

@ -1,72 +0,0 @@
package eu.kanade.presentation.browse
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalUriHandler
import androidx.paging.compose.collectAsLazyPagingItems
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.more.MoreController
@Composable
fun SourceSearchScreen(
presenter: BrowseSourcePresenter,
navigateUp: () -> Unit,
onFabClick: () -> Unit,
onMangaClick: (Manga) -> Unit,
onWebViewClick: () -> Unit,
) {
val columns by presenter.getColumnsPreferenceForCurrentOrientation()
val mangaList = presenter.getMangaList().collectAsLazyPagingItems()
val snackbarHostState = remember { SnackbarHostState() }
val uriHandler = LocalUriHandler.current
val onHelpClick = {
uriHandler.openUri(LocalSource.HELP_URL)
}
Scaffold(
topBar = { scrollBehavior ->
SearchToolbar(
searchQuery = presenter.searchQuery ?: "",
onChangeSearchQuery = { presenter.searchQuery = it },
onClickCloseSearch = navigateUp,
onSearch = { presenter.search(it) },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
BrowseSourceFloatingActionButton(
isVisible = presenter.filters.isNotEmpty(),
onFabClick = onFabClick,
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
) { paddingValues ->
BrowseSourceContent(
state = presenter,
mangaList = mangaList,
getMangaState = { presenter.getManga(it) },
columns = columns,
displayMode = presenter.displayMode,
snackbarHostState = snackbarHostState,
contentPadding = paddingValues,
onWebViewClick = onWebViewClick,
onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
onLocalSourceHelpClick = onHelpClick,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaClick,
)
}
}

View File

@ -6,7 +6,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
@ -17,11 +17,11 @@ import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.components.CommonMangaItemDefaults import eu.kanade.presentation.components.CommonMangaItemDefaults
import eu.kanade.presentation.components.MangaComfortableGridItem import eu.kanade.presentation.components.MangaComfortableGridItem
import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.plus
import kotlinx.coroutines.flow.StateFlow
@Composable @Composable
fun BrowseSourceComfortableGrid( fun BrowseSourceComfortableGrid(
mangaList: LazyPagingItems<Manga>, mangaList: LazyPagingItems<StateFlow<Manga>>,
getMangaState: @Composable ((Manga) -> State<Manga>),
columns: GridCells, columns: GridCells,
contentPadding: PaddingValues, contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit, onMangaClick: (Manga) -> Unit,
@ -40,8 +40,7 @@ fun BrowseSourceComfortableGrid(
} }
items(mangaList.itemCount) { index -> items(mangaList.itemCount) { index ->
val initialManga = mangaList[index] ?: return@items val manga by mangaList[index]?.collectAsState() ?: return@items
val manga by getMangaState(initialManga)
BrowseSourceComfortableGridItem( BrowseSourceComfortableGridItem(
manga = manga, manga = manga,
onClick = { onMangaClick(manga) }, onClick = { onMangaClick(manga) },

View File

@ -6,7 +6,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
@ -17,11 +17,11 @@ import eu.kanade.presentation.browse.InLibraryBadge
import eu.kanade.presentation.components.CommonMangaItemDefaults import eu.kanade.presentation.components.CommonMangaItemDefaults
import eu.kanade.presentation.components.MangaCompactGridItem import eu.kanade.presentation.components.MangaCompactGridItem
import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.plus
import kotlinx.coroutines.flow.StateFlow
@Composable @Composable
fun BrowseSourceCompactGrid( fun BrowseSourceCompactGrid(
mangaList: LazyPagingItems<Manga>, mangaList: LazyPagingItems<StateFlow<Manga>>,
getMangaState: @Composable ((Manga) -> State<Manga>),
columns: GridCells, columns: GridCells,
contentPadding: PaddingValues, contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit, onMangaClick: (Manga) -> Unit,
@ -40,8 +40,7 @@ fun BrowseSourceCompactGrid(
} }
items(mangaList.itemCount) { index -> items(mangaList.itemCount) { index ->
val initialManga = mangaList[index] ?: return@items val manga by mangaList[index]?.collectAsState() ?: return@items
val manga by getMangaState(initialManga)
BrowseSourceCompactGridItem( BrowseSourceCompactGridItem(
manga = manga, manga = manga,
onClick = { onMangaClick(manga) }, onClick = { onMangaClick(manga) },

View File

@ -2,7 +2,7 @@ package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
@ -15,11 +15,11 @@ import eu.kanade.presentation.components.CommonMangaItemDefaults
import eu.kanade.presentation.components.LazyColumn import eu.kanade.presentation.components.LazyColumn
import eu.kanade.presentation.components.MangaListItem import eu.kanade.presentation.components.MangaListItem
import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.plus
import kotlinx.coroutines.flow.StateFlow
@Composable @Composable
fun BrowseSourceList( fun BrowseSourceList(
mangaList: LazyPagingItems<Manga>, mangaList: LazyPagingItems<StateFlow<Manga>>,
getMangaState: @Composable ((Manga) -> State<Manga>),
contentPadding: PaddingValues, contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit, onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit, onMangaLongClick: (Manga) -> Unit,
@ -33,9 +33,9 @@ fun BrowseSourceList(
} }
} }
items(mangaList) { initialManga -> items(mangaList) { mangaflow ->
initialManga ?: return@items mangaflow ?: return@items
val manga by getMangaState(initialManga) val manga by mangaflow.collectAsState()
BrowseSourceListItem( BrowseSourceListItem(
manga = manga, manga = manga,
onClick = { onMangaClick(manga) }, onClick = { onMangaClick(manga) },

View File

@ -14,7 +14,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import eu.kanade.domain.library.model.LibraryDisplayMode import eu.kanade.domain.library.model.LibraryDisplayMode
import eu.kanade.presentation.browse.BrowseSourceState
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.AppBarTitle import eu.kanade.presentation.components.AppBarTitle
@ -27,7 +26,8 @@ import eu.kanade.tachiyomi.source.LocalSource
@Composable @Composable
fun BrowseSourceToolbar( fun BrowseSourceToolbar(
state: BrowseSourceState, searchQuery: String?,
onSearchQueryChange: (String?) -> Unit,
source: CatalogueSource?, source: CatalogueSource?,
displayMode: LibraryDisplayMode, displayMode: LibraryDisplayMode,
onDisplayModeChange: (LibraryDisplayMode) -> Unit, onDisplayModeChange: (LibraryDisplayMode) -> Unit,
@ -44,8 +44,8 @@ fun BrowseSourceToolbar(
SearchToolbar( SearchToolbar(
navigateUp = navigateUp, navigateUp = navigateUp,
titleContent = { AppBarTitle(title) }, titleContent = { AppBarTitle(title) },
searchQuery = state.searchQuery, searchQuery = searchQuery,
onChangeSearchQuery = { state.searchQuery = it }, onChangeSearchQuery = onSearchQueryChange,
onSearch = onSearch, onSearch = onSearch,
onClickCloseSearch = navigateUp, onClickCloseSearch = navigateUp,
actions = { actions = {

View File

@ -2,26 +2,14 @@ package eu.kanade.tachiyomi.ui.browse.migration.search
import android.os.Bundle import android.os.Bundle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.browse.SourceSearchScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.setRoot
import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.getSerializableCompat import eu.kanade.tachiyomi.util.system.getSerializableCompat
class SourceSearchController( class SourceSearchController(bundle: Bundle) : BasicFullComposeController(bundle) {
bundle: Bundle,
) : BrowseSourceController(bundle) {
constructor(manga: Manga? = null, source: CatalogueSource, searchQuery: String? = null) : this( constructor(manga: Manga? = null, source: CatalogueSource, searchQuery: String? = null) : this(
bundleOf( bundleOf(
@ -31,49 +19,16 @@ class SourceSearchController(
), ),
) )
private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY) private var oldManga: Manga = args.getSerializableCompat(MANGA_KEY)!!
private val sourceId = args.getLong(SOURCE_ID_KEY)
private val query = args.getString(SEARCH_QUERY_KEY)
@Composable @Composable
override fun ComposeContent() { override fun ComposeContent() {
SourceSearchScreen( Navigator(screen = SourceSearchScreen(oldManga, sourceId, query))
presenter = presenter,
navigateUp = { router.popCurrentController() },
onFabClick = { filterSheet?.show() },
onMangaClick = {
presenter.dialog = BrowseSourcePresenter.Dialog.Migrate(it)
},
onWebViewClick = f@{
val source = presenter.source as? HttpSource ?: return@f
activity?.let { context ->
val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
context.startActivity(intent)
}
},
)
when (val dialog = presenter.dialog) {
is BrowseSourcePresenter.Dialog.Migrate -> {
MigrateDialog(
oldManga = oldManga!!,
newManga = dialog.newManga,
// TODO: Move screen model down into Dialog when this screen is using Voyager
screenModel = remember { MigrateDialogScreenModel() },
onDismissRequest = { presenter.dialog = null },
onClickTitle = { router.pushController(MangaController(dialog.newManga.id)) },
onPopScreen = {
// TODO: Push to manga screen and remove this and the previous screen when it moves to Voyager
router.setRoot(BrowseController(toExtensions = false), R.id.nav_browse)
router.pushController(MangaController(dialog.newManga.id))
},
)
}
else -> {}
}
LaunchedEffect(presenter.filters) {
initFilterSheet()
}
} }
} }
private const val MANGA_KEY = "oldManga" private const val MANGA_KEY = "oldManga"
private const val SOURCE_ID_KEY = "sourceId"
private const val SEARCH_QUERY_KEY = "searchQuery"

View File

@ -0,0 +1,134 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.paging.compose.collectAsLazyPagingItems
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.setRoot
import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
data class SourceSearchScreen(
private val oldManga: Manga,
private val sourceId: Long,
private val query: String? = null,
) : Screen {
@Composable
override fun Content() {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val router = LocalRouter.currentOrThrow
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId = sourceId, searchQuery = query) }
val state by screenModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val navigateUp: () -> Unit = {
when {
navigator.canPop -> navigator.pop()
router.backstackSize > 1 -> router.popCurrentController()
}
}
Scaffold(
topBar = { scrollBehavior ->
SearchToolbar(
searchQuery = state.toolbarQuery ?: "",
onChangeSearchQuery = screenModel::setToolbarQuery,
onClickCloseSearch = navigateUp,
onSearch = { screenModel.search(it) },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
AnimatedVisibility(visible = state.filters.isNotEmpty()) {
ExtendedFloatingActionButton(
text = { Text(text = stringResource(R.string.action_filter)) },
icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
onClick = screenModel::openFilterSheet,
)
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues ->
val mangaList = remember(state.currentFilter) {
screenModel.getMangaListFlow(state.currentFilter)
}.collectAsLazyPagingItems()
val openMigrateDialog: (Manga) -> Unit = {
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(it))
}
BrowseSourceContent(
source = screenModel.source,
mangaList = mangaList,
columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation),
displayMode = screenModel.displayMode,
snackbarHostState = snackbarHostState,
contentPadding = paddingValues,
onWebViewClick = {
val source = screenModel.source as? HttpSource ?: return@BrowseSourceContent
val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
context.startActivity(intent)
},
onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
onLocalSourceHelpClick = { uriHandler.openUri(LocalSource.HELP_URL) },
onMangaClick = openMigrateDialog,
onMangaLongClick = openMigrateDialog,
)
}
when (val dialog = state.dialog) {
is BrowseSourceScreenModel.Dialog.Migrate -> {
MigrateDialog(
oldManga = oldManga,
newManga = dialog.newManga,
screenModel = rememberScreenModel { MigrateDialogScreenModel() },
onDismissRequest = { screenModel.setDialog(null) },
onClickTitle = { router.pushController(MangaController(dialog.newManga.id)) },
onPopScreen = {
// TODO: Push to manga screen and remove this and the previous screen when it moves to Voyager
router.setRoot(BrowseController(toExtensions = false), R.id.nav_browse)
router.pushController(MangaController(dialog.newManga.id))
},
)
}
else -> {}
}
LaunchedEffect(state.filters) {
screenModel.initFilterSheet(context)
}
}
}

View File

@ -49,7 +49,7 @@ fun Screen.sourcesTab(): TabContent {
contentPadding = contentPadding, contentPadding = contentPadding,
onClickItem = { source, query -> onClickItem = { source, query ->
screenModel.onOpenSource(source) screenModel.onOpenSource(source)
router.pushController(BrowseSourceController(source, query)) router.pushController(BrowseSourceController(source.id, query))
}, },
onClickPin = screenModel::togglePin, onClickPin = screenModel::togglePin,
onLongClickItem = screenModel::showSourceDialog, onLongClickItem = screenModel::showSourceDialog,

View File

@ -1,32 +1,18 @@
package eu.kanade.tachiyomi.ui.browse.source.browse package eu.kanade.tachiyomi.ui.browse.source.browse
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import eu.kanade.domain.source.model.Source import cafe.adriel.voyager.navigator.CurrentScreen
import eu.kanade.presentation.browse.BrowseSourceScreen import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.presentation.browse.components.RemoveMangaDialog import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.presentation.components.ChangeCategoryDialog import kotlinx.coroutines.channels.Channel
import eu.kanade.presentation.components.DuplicateMangaDialog import kotlinx.coroutines.flow.collectLatest
import eu.kanade.tachiyomi.source.CatalogueSource import kotlinx.coroutines.flow.consumeAsFlow
import eu.kanade.tachiyomi.source.model.Filter import kotlinx.coroutines.launch
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Dialog
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.lang.launchIO
open class BrowseSourceController(bundle: Bundle) : class BrowseSourceController(bundle: Bundle) : BasicFullComposeController(bundle) {
FullComposeController<BrowseSourcePresenter>(bundle) {
constructor(sourceId: Long, query: String? = null) : this( constructor(sourceId: Long, query: String? = null) : this(
bundleOf( bundleOf(
@ -35,117 +21,27 @@ open class BrowseSourceController(bundle: Bundle) :
), ),
) )
constructor(source: CatalogueSource, query: String? = null) : this(source.id, query) private val sourceId = args.getLong(SOURCE_ID_KEY)
private val initialQuery = args.getString(SEARCH_QUERY_KEY)
constructor(source: Source, query: String? = null) : this(source.id, query) private val queryEvent = Channel<BrowseSourceScreen.SearchType>()
/**
* Sheet containing filter items.
*/
protected var filterSheet: SourceFilterSheet? = null
override fun createPresenter(): BrowseSourcePresenter {
return BrowseSourcePresenter(args.getLong(SOURCE_ID_KEY), args.getString(SEARCH_QUERY_KEY))
}
@Composable @Composable
override fun ComposeContent() { override fun ComposeContent() {
val scope = rememberCoroutineScope() Navigator(screen = BrowseSourceScreen(sourceId = sourceId, query = initialQuery)) { navigator ->
val context = LocalContext.current CurrentScreen()
val haptic = LocalHapticFeedback.current
BrowseSourceScreen( LaunchedEffect(Unit) {
presenter = presenter, queryEvent.consumeAsFlow()
navigateUp = ::navigateUp, .collectLatest {
openFilterSheet = { filterSheet?.show() }, val screen = (navigator.lastItem as? BrowseSourceScreen)
onMangaClick = { router.pushController(MangaController(it.id, true)) }, when (it) {
onMangaLongClick = { manga -> is BrowseSourceScreen.SearchType.Genre -> screen?.searchGenre(it.txt)
scope.launchIO { is BrowseSourceScreen.SearchType.Text -> screen?.search(it.txt)
val duplicateManga = presenter.getDuplicateLibraryManga(manga) }
when {
manga.favorite -> presenter.dialog = Dialog.RemoveManga(manga)
duplicateManga != null -> presenter.dialog = Dialog.AddDuplicateManga(manga, duplicateManga)
else -> presenter.addFavorite(manga)
} }
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
},
onWebViewClick = f@{
val source = presenter.source as? HttpSource ?: return@f
val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
context.startActivity(intent)
},
incognitoMode = presenter.isIncognitoMode,
downloadedOnlyMode = presenter.isDownloadOnly,
)
val onDismissRequest = { presenter.dialog = null }
when (val dialog = presenter.dialog) {
null -> {}
is Dialog.Migrate -> {}
is Dialog.AddDuplicateManga -> {
DuplicateMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = { presenter.addFavorite(dialog.manga) },
onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) },
duplicateFrom = presenter.getSourceOrStub(dialog.duplicate),
)
}
is Dialog.RemoveManga -> {
RemoveMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
presenter.changeMangaFavorite(dialog.manga)
},
mangaToRemove = dialog.manga,
)
}
is Dialog.ChangeMangaCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
router.pushController(CategoryController())
},
onConfirm = { include, _ ->
presenter.changeMangaFavorite(dialog.manga)
presenter.moveMangaToCategories(dialog.manga, include)
},
)
} }
} }
BackHandler(onBack = ::navigateUp)
LaunchedEffect(presenter.filters) {
initFilterSheet()
}
}
private fun navigateUp() {
when {
!presenter.isUserQuery && presenter.searchQuery != null -> presenter.searchQuery = null
else -> router.popCurrentController()
}
}
open fun initFilterSheet() {
if (presenter.filters.isEmpty()) {
return
}
filterSheet = SourceFilterSheet(
activity!!,
onFilterClicked = {
presenter.search(filters = presenter.filters)
},
onResetClicked = {
presenter.reset()
filterSheet?.setFilters(presenter.filterItems)
},
)
filterSheet?.setFilters(presenter.filterItems)
} }
/** /**
@ -154,7 +50,7 @@ open class BrowseSourceController(bundle: Bundle) :
* @param newQuery the new query. * @param newQuery the new query.
*/ */
fun searchWithQuery(newQuery: String) { fun searchWithQuery(newQuery: String) {
presenter.search(newQuery) viewScope.launch { queryEvent.send(BrowseSourceScreen.SearchType.Text(newQuery)) }
} }
/** /**
@ -165,46 +61,9 @@ open class BrowseSourceController(bundle: Bundle) :
* @param genreName the name of the genre * @param genreName the name of the genre
*/ */
fun searchWithGenre(genreName: String) { fun searchWithGenre(genreName: String) {
val defaultFilters = presenter.source!!.getFilterList() viewScope.launch { queryEvent.send(BrowseSourceScreen.SearchType.Genre(genreName)) }
var genreExists = false
filter@ for (sourceFilter in defaultFilters) {
if (sourceFilter is Filter.Group<*>) {
for (filter in sourceFilter.state) {
if (filter is Filter<*> && filter.name.equals(genreName, true)) {
when (filter) {
is Filter.TriState -> filter.state = 1
is Filter.CheckBox -> filter.state = true
else -> {}
}
genreExists = true
break@filter
}
}
} else if (sourceFilter is Filter.Select<*>) {
val index = sourceFilter.values.filterIsInstance<String>()
.indexOfFirst { it.equals(genreName, true) }
if (index != -1) {
sourceFilter.state = index
genreExists = true
break
}
}
}
if (genreExists) {
filterSheet?.setFilters(defaultFilters.toItems())
presenter.search(filters = defaultFilters)
} else {
searchWithQuery(genreName)
}
}
protected companion object {
const val SOURCE_ID_KEY = "sourceId"
const val SEARCH_QUERY_KEY = "searchQuery"
} }
} }
private const val SOURCE_ID_KEY = "sourceId"
private const val SEARCH_QUERY_KEY = "searchQuery"

View File

@ -0,0 +1,283 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.compose.collectAsLazyPagingItems
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.uniqueScreenKey
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.source.interactor.GetRemoteManga
import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.browse.components.BrowseSourceToolbar
import eu.kanade.presentation.browse.components.RemoveMangaDialog
import eu.kanade.presentation.components.AppStateBanners
import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.Divider
import eu.kanade.presentation.components.DuplicateMangaDialog
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
data class BrowseSourceScreen(
private val sourceId: Long,
private val query: String? = null,
) : Screen {
override val key = uniqueScreenKey
@Composable
override fun Content() {
val router = LocalRouter.currentOrThrow
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val uriHandler = LocalUriHandler.current
val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId = sourceId, searchQuery = query) }
val state by screenModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val onHelpClick = { uriHandler.openUri(LocalSource.HELP_URL) }
val onWebViewClick = f@{
val source = screenModel.source as? HttpSource ?: return@f
val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
context.startActivity(intent)
}
val navigateUp: () -> Unit = {
when {
navigator.canPop -> navigator.pop()
router.backstackSize > 1 -> router.popCurrentController()
}
}
Scaffold(
topBar = {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
BrowseSourceToolbar(
searchQuery = state.toolbarQuery,
onSearchQueryChange = screenModel::setToolbarQuery,
source = screenModel.source,
displayMode = screenModel.displayMode,
onDisplayModeChange = { screenModel.displayMode = it },
navigateUp = navigateUp,
onWebViewClick = onWebViewClick,
onHelpClick = onHelpClick,
onSearch = { screenModel.search(it) },
)
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = state.currentFilter == BrowseSourceScreenModel.Filter.Popular,
onClick = {
screenModel.reset()
screenModel.search(GetRemoteManga.QUERY_POPULAR)
},
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Favorite,
contentDescription = "",
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(R.string.popular))
},
)
if (screenModel.source.supportsLatest) {
FilterChip(
selected = state.currentFilter == BrowseSourceScreenModel.Filter.Latest,
onClick = {
screenModel.reset()
screenModel.search(GetRemoteManga.QUERY_LATEST)
},
leadingIcon = {
Icon(
imageVector = Icons.Outlined.NewReleases,
contentDescription = "",
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(R.string.latest))
},
)
}
if (state.filters.isNotEmpty()) {
FilterChip(
selected = state.currentFilter is BrowseSourceScreenModel.Filter.UserInput,
onClick = screenModel::openFilterSheet,
leadingIcon = {
Icon(
imageVector = Icons.Outlined.FilterList,
contentDescription = "",
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(R.string.action_filter))
},
)
}
}
Divider()
AppStateBanners(screenModel.isDownloadOnly, screenModel.isIncognitoMode)
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues ->
val mangaList = remember(state.currentFilter) {
screenModel.getMangaListFlow(state.currentFilter)
}.collectAsLazyPagingItems()
BrowseSourceContent(
source = screenModel.source,
mangaList = mangaList,
columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation),
displayMode = screenModel.displayMode,
snackbarHostState = snackbarHostState,
contentPadding = paddingValues,
onWebViewClick = onWebViewClick,
onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
onLocalSourceHelpClick = onHelpClick,
onMangaClick = { router.pushController(MangaController(it.id, true)) },
onMangaLongClick = { manga ->
scope.launchIO {
val duplicateManga = screenModel.getDuplicateLibraryManga(manga)
when {
manga.favorite -> screenModel.setDialog(BrowseSourceScreenModel.Dialog.RemoveManga(manga))
duplicateManga != null -> screenModel.setDialog(
BrowseSourceScreenModel.Dialog.AddDuplicateManga(
manga,
duplicateManga,
),
)
else -> screenModel.addFavorite(manga)
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
},
)
}
val onDismissRequest = { screenModel.setDialog(null) }
when (val dialog = state.dialog) {
is BrowseSourceScreenModel.Dialog.Migrate -> {}
is BrowseSourceScreenModel.Dialog.AddDuplicateManga -> {
DuplicateMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = { screenModel.addFavorite(dialog.manga) },
onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) },
duplicateFrom = screenModel.getSourceOrStub(dialog.duplicate),
)
}
is BrowseSourceScreenModel.Dialog.RemoveManga -> {
RemoveMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
screenModel.changeMangaFavorite(dialog.manga)
},
mangaToRemove = dialog.manga,
)
}
is BrowseSourceScreenModel.Dialog.ChangeMangaCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
router.pushController(CategoryController())
},
onConfirm = { include, _ ->
screenModel.changeMangaFavorite(dialog.manga)
screenModel.moveMangaToCategories(dialog.manga, include)
},
)
}
else -> {}
}
BackHandler(onBack = navigateUp)
LaunchedEffect(state.filters) {
screenModel.initFilterSheet(context)
}
LaunchedEffect(Unit) {
queryEvent.receiveAsFlow()
.collectLatest {
when (it) {
is SearchType.Genre -> screenModel.searchGenre(it.txt)
is SearchType.Text -> screenModel.search(it.txt)
}
}
}
}
private val queryEvent = Channel<SearchType>()
suspend fun search(query: String) = queryEvent.send(SearchType.Text(query))
suspend fun searchGenre(name: String) = queryEvent.send(SearchType.Genre(name))
sealed class SearchType(val txt: String) {
class Text(txt: String) : SearchType(txt)
class Genre(txt: String) : SearchType(txt)
}
}

View File

@ -1,23 +1,22 @@
package eu.kanade.tachiyomi.ui.browse.source.browse package eu.kanade.tachiyomi.ui.browse.source.browse
import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map import androidx.paging.map
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.core.prefs.CheckboxState import eu.kanade.core.prefs.CheckboxState
import eu.kanade.core.prefs.asState
import eu.kanade.core.prefs.mapAsCheckboxState import eu.kanade.core.prefs.mapAsCheckboxState
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.category.interactor.GetCategories import eu.kanade.domain.category.interactor.GetCategories
@ -39,8 +38,6 @@ import eu.kanade.domain.source.interactor.GetRemoteManga
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.track.interactor.InsertTrack import eu.kanade.domain.track.interactor.InsertTrack
import eu.kanade.domain.track.model.toDomainTrack import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.presentation.browse.BrowseSourceState
import eu.kanade.presentation.browse.BrowseSourceStateImpl
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
@ -48,9 +45,7 @@ import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxItem import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxItem
import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxSectionItem import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxSectionItem
import eu.kanade.tachiyomi.ui.browse.source.filter.GroupItem import eu.kanade.tachiyomi.ui.browse.source.filter.GroupItem
@ -70,19 +65,23 @@ import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.Date import java.util.Date
import eu.kanade.tachiyomi.source.model.Filter as SourceModelFilter
open class BrowseSourcePresenter( class BrowseSourceScreenModel(
private val sourceId: Long, private val sourceId: Long,
searchQuery: String? = null, searchQuery: String?,
private val state: BrowseSourceStateImpl = BrowseSourceState(searchQuery) as BrowseSourceStateImpl,
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
preferences: BasePreferences = Injekt.get(), preferences: BasePreferences = Injekt.get(),
sourcePreferences: SourcePreferences = Injekt.get(), sourcePreferences: SourcePreferences = Injekt.get(),
@ -99,86 +98,122 @@ open class BrowseSourcePresenter(
private val updateManga: UpdateManga = Injekt.get(), private val updateManga: UpdateManga = Injekt.get(),
private val insertTrack: InsertTrack = Injekt.get(), private val insertTrack: InsertTrack = Injekt.get(),
private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(), private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(),
) : BasePresenter<BrowseSourceController>(), BrowseSourceState by state { ) : StateScreenModel<BrowseSourceScreenModel.State>(State(Filter.valueOf(searchQuery))) {
private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } } private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
var displayMode by sourcePreferences.sourceDisplayMode().asState() var displayMode by sourcePreferences.sourceDisplayMode().asState(coroutineScope)
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState() val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope)
val isIncognitoMode: Boolean by preferences.incognitoMode().asState() val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope)
@Composable val source = sourceManager.get(sourceId) as CatalogueSource
fun getColumnsPreferenceForCurrentOrientation(): State<GridCells> {
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE /**
return produceState<GridCells>(initialValue = GridCells.Adaptive(128.dp), isLandscape) { * Sheet containing filter items.
(if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()) */
.changes() private var filterSheet: SourceFilterSheet? = null
.collectLatest { columns ->
value = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns) init {
} mutableState.update { it.copy(filters = source.getFilterList()) }
}
} }
@Composable fun getColumnsPreference(orientation: Int): GridCells {
fun getMangaList(): Flow<PagingData<Manga>> { val isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE
return remember(currentFilter) { val columns = if (isLandscape) {
Pager( libraryPreferences.landscapeColumns()
PagingConfig(pageSize = 25), } else {
) { libraryPreferences.portraitColumns()
getRemoteManga.subscribe(sourceId, currentFilter.query, currentFilter.filters) }.get()
}.flow return if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns)
.map {
it.map { sManga ->
withIOContext {
networkToLocalManga.await(sManga.toDomainManga(sourceId))
}
}
}
.cachedIn(presenterScope)
}
} }
@Composable fun getMangaListFlow(currentFilter: Filter): Flow<PagingData<StateFlow<Manga>>> {
fun getManga(initialManga: Manga): State<Manga> { return Pager(
return produceState(initialValue = initialManga) { PagingConfig(pageSize = 25),
getManga.subscribe(initialManga.url, initialManga.source) ) {
.collectLatest { manga -> getRemoteManga.subscribe(sourceId, currentFilter.query ?: "", currentFilter.filters)
if (manga == null) return@collectLatest }.flow
withIOContext { .map { pagingData ->
initializeManga(manga) pagingData.map { sManga ->
} val dbManga = withIOContext { networkToLocalManga.await(sManga.toDomainManga(sourceId)) }
value = manga getManga.subscribe(dbManga.url, dbManga.source)
.filterNotNull()
.onEach { initializeManga(it) }
.stateIn(coroutineScope)
} }
} }
.cachedIn(coroutineScope)
} }
fun reset() { fun reset() {
val source = source ?: return mutableState.update { it.copy(filters = source.getFilterList()) }
state.filters = source.getFilterList()
} }
fun search(query: String? = null, filters: FilterList? = null) { fun search(query: String? = null, filters: FilterList? = null) {
Filter.valueOf(query ?: "").let { Filter.valueOf(query).let {
if (it !is Filter.UserInput) { if (it !is Filter.UserInput) {
state.currentFilter = it mutableState.update { state -> state.copy(currentFilter = it) }
state.searchQuery = null
return return
} }
} }
val input: Filter.UserInput = if (currentFilter is Filter.UserInput) currentFilter as Filter.UserInput else Filter.UserInput() val input = if (state.value.currentFilter is Filter.UserInput) {
state.currentFilter = input.copy( state.value.currentFilter as Filter.UserInput
query = query ?: input.query, } else {
filters = filters ?: input.filters, Filter.UserInput()
) }
mutableState.update {
it.copy(
currentFilter = input.copy(
query = query ?: input.query,
filters = filters ?: input.filters,
),
toolbarQuery = query ?: input.query,
)
}
} }
override fun onCreate(savedState: Bundle?) { fun searchGenre(genreName: String) {
super.onCreate(savedState) val defaultFilters = source.getFilterList()
var genreExists = false
state.source = sourceManager.get(sourceId) as? CatalogueSource ?: return filter@ for (sourceFilter in defaultFilters) {
state.filters = source!!.getFilterList() if (sourceFilter is SourceModelFilter.Group<*>) {
for (filter in sourceFilter.state) {
if (filter is SourceModelFilter<*> && filter.name.equals(genreName, true)) {
when (filter) {
is SourceModelFilter.TriState -> filter.state = 1
is SourceModelFilter.CheckBox -> filter.state = true
else -> {}
}
genreExists = true
break@filter
}
}
} else if (sourceFilter is SourceModelFilter.Select<*>) {
val index = sourceFilter.values.filterIsInstance<String>()
.indexOfFirst { it.equals(genreName, true) }
if (index != -1) {
sourceFilter.state = index
genreExists = true
break
}
}
}
mutableState.update {
val filter = if (genreExists) {
Filter.UserInput(filters = defaultFilters)
} else {
Filter.UserInput(query = genreName)
}
it.copy(
filters = defaultFilters,
currentFilter = filter,
)
}
} }
/** /**
@ -190,7 +225,7 @@ open class BrowseSourcePresenter(
if (manga.thumbnailUrl != null || manga.initialized) return if (manga.thumbnailUrl != null || manga.initialized) return
withNonCancellableContext { withNonCancellableContext {
try { try {
val networkManga = source!!.getMangaDetails(manga.toSManga()) val networkManga = source.getMangaDetails(manga.toSManga())
val updatedManga = manga.copyFrom(networkManga) val updatedManga = manga.copyFrom(networkManga)
.copy(initialized = true) .copy(initialized = true)
@ -207,7 +242,7 @@ open class BrowseSourcePresenter(
* @param manga the manga to update. * @param manga the manga to update.
*/ */
fun changeMangaFavorite(manga: Manga) { fun changeMangaFavorite(manga: Manga) {
presenterScope.launch { coroutineScope.launch {
var new = manga.copy( var new = manga.copy(
favorite = !manga.favorite, favorite = !manga.favorite,
dateAdded = when (manga.favorite) { dateAdded = when (manga.favorite) {
@ -233,7 +268,7 @@ open class BrowseSourcePresenter(
} }
fun addFavorite(manga: Manga) { fun addFavorite(manga: Manga) {
presenterScope.launch { coroutineScope.launch {
val categories = getCategories() val categories = getCategories()
val defaultCategoryId = libraryPreferences.defaultCategory().get() val defaultCategoryId = libraryPreferences.defaultCategory().get()
val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() } val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
@ -256,7 +291,7 @@ open class BrowseSourcePresenter(
// Choose a category // Choose a category
else -> { else -> {
val preselectedIds = getCategories.await(manga.id).map { it.id } val preselectedIds = getCategories.await(manga.id).map { it.id }
state.dialog = Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }) setDialog(Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }))
} }
} }
} }
@ -265,7 +300,7 @@ open class BrowseSourcePresenter(
private suspend fun autoAddTrack(manga: Manga) { private suspend fun autoAddTrack(manga: Manga) {
loggedServices loggedServices
.filterIsInstance<EnhancedTrackService>() .filterIsInstance<EnhancedTrackService>()
.filter { it.accept(source!!) } .filter { it.accept(source) }
.forEach { service -> .forEach { service ->
try { try {
service.match(manga.toDbManga())?.let { track -> service.match(manga.toDbManga())?.let { track ->
@ -303,7 +338,7 @@ open class BrowseSourcePresenter(
} }
fun moveMangaToCategories(manga: Manga, categoryIds: List<Long>) { fun moveMangaToCategories(manga: Manga, categoryIds: List<Long>) {
presenterScope.launchIO { coroutineScope.launchIO {
setMangaCategories.await( setMangaCategories.await(
mangaId = manga.id, mangaId = manga.id,
categoryIds = categoryIds.toList(), categoryIds = categoryIds.toList(),
@ -311,13 +346,43 @@ open class BrowseSourcePresenter(
} }
} }
sealed class Filter(open val query: String, open val filters: FilterList) { fun openFilterSheet() {
filterSheet?.show()
}
fun setDialog(dialog: Dialog?) {
mutableState.update { it.copy(dialog = dialog) }
}
fun setToolbarQuery(query: String?) {
mutableState.update { it.copy(toolbarQuery = query) }
}
fun initFilterSheet(context: Context) {
val state = state.value
if (state.filters.isEmpty()) {
return
}
filterSheet = SourceFilterSheet(
context = context,
onFilterClicked = { search(filters = state.filters) },
onResetClicked = {
reset()
filterSheet?.setFilters(state.filterItems)
},
)
filterSheet?.setFilters(state.filterItems)
}
sealed class Filter(open val query: String?, open val filters: FilterList) {
object Popular : Filter(query = GetRemoteManga.QUERY_POPULAR, filters = FilterList()) object Popular : Filter(query = GetRemoteManga.QUERY_POPULAR, filters = FilterList())
object Latest : Filter(query = GetRemoteManga.QUERY_LATEST, filters = FilterList()) object Latest : Filter(query = GetRemoteManga.QUERY_LATEST, filters = FilterList())
data class UserInput(override val query: String = "", override val filters: FilterList = FilterList()) : Filter(query = query, filters = filters) data class UserInput(override val query: String? = null, override val filters: FilterList = FilterList()) : Filter(query = query, filters = filters)
companion object { companion object {
fun valueOf(query: String): Filter { fun valueOf(query: String?): Filter {
return when (query) { return when (query) {
GetRemoteManga.QUERY_POPULAR -> Popular GetRemoteManga.QUERY_POPULAR -> Popular
GetRemoteManga.QUERY_LATEST -> Latest GetRemoteManga.QUERY_LATEST -> Latest
@ -336,25 +401,40 @@ open class BrowseSourcePresenter(
) : Dialog() ) : Dialog()
data class Migrate(val newManga: Manga) : Dialog() data class Migrate(val newManga: Manga) : Dialog()
} }
@Immutable
data class State(
val currentFilter: Filter,
val filters: FilterList = FilterList(),
val toolbarQuery: String? = null,
val dialog: Dialog? = null,
) {
val filterItems = filters.toItems()
val isUserQuery = currentFilter is Filter.UserInput && !currentFilter.query.isNullOrEmpty()
val searchQuery = when (currentFilter) {
is Filter.UserInput -> currentFilter.query
Filter.Latest, Filter.Popular -> null
}
}
} }
fun FilterList.toItems(): List<IFlexible<*>> { private fun FilterList.toItems(): List<IFlexible<*>> {
return mapNotNull { filter -> return mapNotNull { filter ->
when (filter) { when (filter) {
is Filter.Header -> HeaderItem(filter) is SourceModelFilter.Header -> HeaderItem(filter)
is Filter.Separator -> SeparatorItem(filter) is SourceModelFilter.Separator -> SeparatorItem(filter)
is Filter.CheckBox -> CheckboxItem(filter) is SourceModelFilter.CheckBox -> CheckboxItem(filter)
is Filter.TriState -> TriStateItem(filter) is SourceModelFilter.TriState -> TriStateItem(filter)
is Filter.Text -> TextItem(filter) is SourceModelFilter.Text -> TextItem(filter)
is Filter.Select<*> -> SelectItem(filter) is SourceModelFilter.Select<*> -> SelectItem(filter)
is Filter.Group<*> -> { is SourceModelFilter.Group<*> -> {
val group = GroupItem(filter) val group = GroupItem(filter)
val subItems = filter.state.mapNotNull { val subItems = filter.state.mapNotNull {
when (it) { when (it) {
is Filter.CheckBox -> CheckboxSectionItem(it) is SourceModelFilter.CheckBox -> CheckboxSectionItem(it)
is Filter.TriState -> TriStateSectionItem(it) is SourceModelFilter.TriState -> TriStateSectionItem(it)
is Filter.Text -> TextSectionItem(it) is SourceModelFilter.Text -> TextSectionItem(it)
is Filter.Select<*> -> SelectSectionItem(it) is SourceModelFilter.Select<*> -> SelectSectionItem(it)
else -> null else -> null
} }
} }
@ -362,7 +442,7 @@ fun FilterList.toItems(): List<IFlexible<*>> {
group.subItems = subItems group.subItems = subItems
group group
} }
is Filter.Sort -> { is SourceModelFilter.Sort -> {
val group = SortGroup(filter) val group = SortGroup(filter)
val subItems = filter.values.map { val subItems = filter.values.map {
SortItem(it, group) SortItem(it, group)

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.ui.browse.source.browse package eu.kanade.tachiyomi.ui.browse.source.browse
import android.app.Activity
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
@ -13,12 +12,12 @@ import eu.kanade.tachiyomi.widget.SimpleNavigationView
import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
class SourceFilterSheet( class SourceFilterSheet(
activity: Activity, context: Context,
private val onFilterClicked: () -> Unit, private val onFilterClicked: () -> Unit,
private val onResetClicked: () -> Unit, private val onResetClicked: () -> Unit,
) : BaseBottomSheetDialog(activity) { ) : BaseBottomSheetDialog(context) {
private var filterNavView: FilterNavigationView = FilterNavigationView(activity) private var filterNavView: FilterNavigationView = FilterNavigationView(context)
override fun createView(inflater: LayoutInflater): View { override fun createView(inflater: LayoutInflater): View {
filterNavView.onFilterClicked = { filterNavView.onFilterClicked = {

View File

@ -44,7 +44,7 @@ class GlobalSearchScreen(
if (!screenModel.incognitoMode.get()) { if (!screenModel.incognitoMode.get()) {
screenModel.lastUsedSourceId.set(it.id) screenModel.lastUsedSourceId.set(it.id)
} }
router.pushController(BrowseSourceController(it, state.searchQuery)) router.pushController(BrowseSourceController(it.id, state.searchQuery))
}, },
onClickItem = { router.pushController(MangaController(it.id, true)) }, onClickItem = { router.pushController(MangaController(it.id, true)) },
onLongClickItem = { router.pushController(MangaController(it.id, true)) }, onLongClickItem = { router.pushController(MangaController(it.id, true)) },