Use Voyager for source feed

This commit is contained in:
Jobobby04 2022-11-28 22:16:18 -05:00
parent bd73eff732
commit 658c84bef8
10 changed files with 469 additions and 343 deletions

View File

@ -34,7 +34,6 @@ import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.topSmallPaddingValues import eu.kanade.presentation.util.topSmallPaddingValues
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedPresenter
import exh.savedsearches.models.FeedSavedSearch import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch import exh.savedsearches.models.SavedSearch
@ -93,41 +92,49 @@ sealed class SourceFeedUI {
@Composable @Composable
fun SourceFeedScreen( fun SourceFeedScreen(
presenter: SourceFeedPresenter, name: String,
onFabClick: () -> Unit, isLoading: Boolean,
items: List<SourceFeedUI>,
onFabClick: (() -> Unit)?,
onClickBrowse: () -> Unit, onClickBrowse: () -> Unit,
onClickLatest: () -> Unit, onClickLatest: () -> Unit,
onClickSavedSearch: (SavedSearch) -> Unit, onClickSavedSearch: (SavedSearch) -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit, onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit, onClickManga: (Manga) -> Unit,
onClickSearch: (String) -> Unit, onClickSearch: (String) -> Unit,
searchQuery: String?,
onSearchQueryChange: (String?) -> Unit,
isIncognitoMode: Boolean,
isDownloadOnly: Boolean,
getMangaState: @Composable (Manga) -> State<Manga>,
) { ) {
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
SourceFeedToolbar( SourceFeedToolbar(
title = presenter.source.name, title = name,
state = presenter, searchQuery = searchQuery,
onSearchQueryChange = onSearchQueryChange,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
incognitoMode = presenter.isIncognitoMode, incognitoMode = isIncognitoMode,
downloadedOnlyMode = presenter.isDownloadOnly, downloadedOnlyMode = isDownloadOnly,
onClickSearch = onClickSearch, onClickSearch = onClickSearch,
) )
}, },
floatingActionButton = { floatingActionButton = {
BrowseSourceFloatingActionButton( BrowseSourceFloatingActionButton(
isVisible = presenter.filterItems.isNotEmpty(), isVisible = onFabClick != null,
onFabClick = onFabClick, onFabClick = onFabClick ?: {},
) )
}, },
) { paddingValues -> ) { paddingValues ->
Crossfade(targetState = presenter.isLoading) { state -> Crossfade(targetState = isLoading) { state ->
when (state) { when (state) {
true -> LoadingScreen() true -> LoadingScreen()
false -> { false -> {
SourceFeedList( SourceFeedList(
state = presenter, items = items,
paddingValues = paddingValues, paddingValues = paddingValues,
getMangaState = { presenter.getManga(it) }, getMangaState = getMangaState,
onClickBrowse = onClickBrowse, onClickBrowse = onClickBrowse,
onClickLatest = onClickLatest, onClickLatest = onClickLatest,
onClickSavedSearch = onClickSavedSearch, onClickSavedSearch = onClickSavedSearch,
@ -142,7 +149,7 @@ fun SourceFeedScreen(
@Composable @Composable
fun SourceFeedList( fun SourceFeedList(
state: SourceFeedState, items: List<SourceFeedUI>,
paddingValues: PaddingValues, paddingValues: PaddingValues,
getMangaState: @Composable ((Manga) -> State<Manga>), getMangaState: @Composable ((Manga) -> State<Manga>),
onClickBrowse: () -> Unit, onClickBrowse: () -> Unit,
@ -155,7 +162,7 @@ fun SourceFeedList(
contentPadding = paddingValues + topSmallPaddingValues, contentPadding = paddingValues + topSmallPaddingValues,
) { ) {
items( items(
state.items.orEmpty(), items.orEmpty(),
key = { it.id }, key = { it.id },
) { item -> ) { item ->
SourceFeedItem( SourceFeedItem(
@ -248,7 +255,8 @@ fun SourceFeedItem(
@Composable @Composable
fun SourceFeedToolbar( fun SourceFeedToolbar(
title: String, title: String,
state: SourceFeedState, searchQuery: String?,
onSearchQueryChange: (String?) -> Unit,
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
incognitoMode: Boolean, incognitoMode: Boolean,
downloadedOnlyMode: Boolean, downloadedOnlyMode: Boolean,
@ -256,10 +264,10 @@ fun SourceFeedToolbar(
) { ) {
SearchToolbar( SearchToolbar(
titleContent = { AppBarTitle(title) }, titleContent = { AppBarTitle(title) },
searchQuery = state.searchQuery, searchQuery = searchQuery,
onChangeSearchQuery = { state.searchQuery = it }, onChangeSearchQuery = onSearchQueryChange,
onSearch = onClickSearch, onSearch = onClickSearch,
onClickCloseSearch = { state.searchQuery = null }, onClickCloseSearch = { onSearchQueryChange(null) },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
incognitoMode = incognitoMode, incognitoMode = incognitoMode,
downloadedOnlyMode = downloadedOnlyMode, downloadedOnlyMode = downloadedOnlyMode,

View File

@ -1,31 +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.model.FilterList
import eu.kanade.tachiyomi.ui.browse.source.browse.toItems
@Stable
interface SourceFeedState {
val isLoading: Boolean
var searchQuery: String?
val filters: FilterList
val filterItems: List<IFlexible<*>>
val items: List<SourceFeedUI>?
}
fun SourceFeedState(): SourceFeedState {
return SourceFeedStateImpl()
}
class SourceFeedStateImpl : SourceFeedState {
override var isLoading: Boolean by mutableStateOf(true)
override var searchQuery: String? by mutableStateOf(null)
override var filters: FilterList by mutableStateOf(FilterList())
override val filterItems: List<IFlexible<*>> by derivedStateOf { filters.toItems() }
override var items: List<SourceFeedUI>? by mutableStateOf(null)
}

View File

@ -0,0 +1,81 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.kanade.tachiyomi.R
@Composable
fun SourceFeedAddDialog(
onDismissRequest: () -> Unit,
name: String,
addFeed: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = addFeed) {
Text(text = stringResource(R.string.action_add))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(android.R.string.cancel))
}
},
title = {
Text(text = stringResource(R.string.feed))
},
text = {
Text(text = stringResource(R.string.feed_add, name))
},
)
}
@Composable
fun SourceFeedDeleteDialog(
onDismissRequest: () -> Unit,
deleteFeed: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = deleteFeed) {
Text(text = stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(android.R.string.cancel))
}
},
title = {
Text(text = stringResource(R.string.feed))
},
text = {
Text(text = stringResource(R.string.feed_delete))
},
)
}
@Composable
fun SourceFeedFailedToLoadSavedSearchDialog(
onDismissRequest: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(android.R.string.ok))
}
},
title = {
Text(text = stringResource(R.string.save_search_failed_to_load))
},
text = {
Text(text = stringResource(R.string.save_search_failed_to_load_message))
},
)
}

View File

@ -208,7 +208,7 @@ open class BrowseSourceController(bundle: Bundle) :
filterSheet = SourceFilterSheet( filterSheet = SourceFilterSheet(
activity!!, activity!!,
// SY --> // SY -->
this, router,
presenter.source!!, presenter.source!!,
emptyList(), emptyList(),
// SY <-- // SY <--

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
@ -8,13 +7,13 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import com.bluelinelabs.conductor.Router
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.databinding.SourceFilterSheetBinding import eu.kanade.tachiyomi.databinding.SourceFilterSheetBinding
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.widget.SimpleNavigationView import eu.kanade.tachiyomi.widget.SimpleNavigationView
import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
import exh.md.MangaDexFabHeaderAdapter import exh.md.MangaDexFabHeaderAdapter
@ -22,9 +21,9 @@ import exh.savedsearches.EXHSavedSearch
import exh.source.getMainSource import exh.source.getMainSource
class SourceFilterSheet( class SourceFilterSheet(
activity: Activity, context: Context,
// SY --> // SY -->
controller: BaseController<*>, router: Router,
source: CatalogueSource, source: CatalogueSource,
searches: List<EXHSavedSearch> = emptyList(), searches: List<EXHSavedSearch> = emptyList(),
// SY <-- // SY <--
@ -35,14 +34,14 @@ class SourceFilterSheet(
var onSavedSearchClicked: (Long) -> Unit = {}, var onSavedSearchClicked: (Long) -> Unit = {},
var onSavedSearchDeleteClicked: (Long, String) -> Unit = { _, _ -> }, var onSavedSearchDeleteClicked: (Long, String) -> Unit = { _, _ -> },
// EXH <-- // EXH <--
) : BaseBottomSheetDialog(activity) { ) : BaseBottomSheetDialog(context) {
private var filterNavView: FilterNavigationView = FilterNavigationView( private var filterNavView: FilterNavigationView = FilterNavigationView(
activity, context,
// SY --> // SY -->
searches = searches, searches = searches,
source = source, source = source,
controller = controller, router = router,
dismissSheet = ::dismiss, dismissSheet = ::dismiss,
// SY <-- // SY <--
) )
@ -85,7 +84,7 @@ class SourceFilterSheet(
// SY --> // SY -->
searches: List<EXHSavedSearch> = emptyList(), searches: List<EXHSavedSearch> = emptyList(),
source: CatalogueSource? = null, source: CatalogueSource? = null,
controller: BaseController<*>? = null, router: Router? = null,
dismissSheet: (() -> Unit)? = null, dismissSheet: (() -> Unit)? = null,
// SY <-- // SY <--
) : ) :
@ -117,10 +116,10 @@ class SourceFilterSheet(
// SY --> // SY -->
recycler.adapter = ConcatAdapter( recycler.adapter = ConcatAdapter(
listOfNotNull( listOfNotNull(
controller?.let { router?.let {
source?.getMainSource<MangaDex>() source?.getMainSource<MangaDex>()
?.let { ?.let {
MangaDexFabHeaderAdapter(controller, it) { MangaDexFabHeaderAdapter(router, it) {
dismissSheet?.invoke() dismissSheet?.invoke()
} }
} }

View File

@ -1,229 +1,34 @@
package eu.kanade.tachiyomi.ui.browse.source.feed package eu.kanade.tachiyomi.ui.browse.source.feed
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import com.google.android.material.dialog.MaterialAlertDialogBuilder import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.source.interactor.GetRemoteManga
import eu.kanade.presentation.browse.SourceFeedScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterSheet
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.toast
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
import exh.util.nullIfBlank
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
/** class SourceFeedController : BasicFullComposeController {
* This controller shows and manages the different search result in global search.
* This controller should only handle UI actions, IO actions should be done by [SourceFeedPresenter]
* [SourceFeedCardAdapter.OnMangaClickListener] called when manga is clicked in global search
*/
open class SourceFeedController :
FullComposeController<SourceFeedPresenter> {
constructor(source: CatalogueSource?) : super( constructor(source: CatalogueSource) : super(
bundleOf( bundleOf(
SOURCE_EXTRA to (source?.id ?: 0), SOURCE_EXTRA to source.id,
), ),
) { )
this.source = source
}
constructor(sourceId: Long) : this( constructor(sourceId: Long) : super(
Injekt.get<SourceManager>().get(sourceId) as? CatalogueSource, bundleOf(
SOURCE_EXTRA to sourceId,
),
) )
@Suppress("unused") @Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getLong(SOURCE_EXTRA)) constructor(bundle: Bundle) : super(bundle)
var source: CatalogueSource? = null val sourceId = args.getLong(SOURCE_EXTRA)
/**
* Sheet containing filter items.
*/
private var filterSheet: SourceFilterSheet? = null
/**
* Create the [SourceFeedPresenter] used in controller.
*
* @return instance of [SourceFeedPresenter]
*/
override fun createPresenter(): SourceFeedPresenter {
return SourceFeedPresenter(source = source!!)
}
/**
* Called when the view is created
*
* @param view view of controller
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Prepare filter sheet
initFilterSheet()
}
private val filterSerializer = FilterSerializer()
fun initFilterSheet() {
filterSheet = SourceFilterSheet(
activity!!,
// SY -->
this,
presenter.source,
emptyList(),
// SY <--
onFilterClicked = {
val allDefault = presenter.filters == presenter.source.getFilterList()
filterSheet?.dismiss()
if (allDefault) {
onBrowseClick(
presenter.searchQuery?.nullIfBlank(),
)
} else {
onBrowseClick(
presenter.searchQuery?.nullIfBlank(),
filters = Json.encodeToString(filterSerializer.serialize(presenter.filters)),
)
}
},
onResetClicked = {},
onSaveClicked = {},
onSavedSearchClicked = { idOfSearch ->
viewScope.launchUI {
val search = presenter.loadSearch(idOfSearch)
if (search == null) {
filterSheet?.context?.let {
MaterialAlertDialogBuilder(it)
.setTitle(R.string.save_search_failed_to_load)
.setMessage(R.string.save_search_failed_to_load_message)
.show()
}
return@launchUI
}
if (search.filterList == null && presenter.filters.isNotEmpty()) {
activity?.toast(R.string.save_search_invalid)
return@launchUI
}
if (search.filterList != null) {
presenter.setFilters(FilterList(search.filterList))
filterSheet?.setFilters(presenter.filterItems)
}
val allDefault = search.filterList != null && presenter.filters == presenter.source.getFilterList()
filterSheet?.dismiss()
if (!allDefault) {
onBrowseClick(
search = presenter.searchQuery?.nullIfBlank(),
savedSearch = search.id,
)
}
}
},
onSavedSearchDeleteClicked = { idOfSearch, name ->
viewScope.launchUI {
if (presenter.hasTooManyFeeds()) {
activity?.toast(R.string.too_many_in_feed)
return@launchUI
}
withUIContext {
MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.feed)
.setMessage(activity!!.getString(R.string.feed_add, name))
.setPositiveButton(R.string.action_add) { _, _ ->
presenter.createFeed(idOfSearch)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
},
)
launchUI {
filterSheet?.setSavedSearches(presenter.loadSearches())
}
filterSheet?.setFilters(presenter.filterItems)
}
@Composable @Composable
override fun ComposeContent() { override fun ComposeContent() {
SourceFeedScreen( Navigator(screen = SourceFeedScreen(sourceId))
presenter = presenter,
onFabClick = { filterSheet?.show() },
onClickBrowse = ::onBrowseClick,
onClickLatest = ::onLatestClick,
onClickSavedSearch = ::onSavedSearchClick,
onClickDelete = ::onRemoveClick,
onClickManga = ::onMangaClick,
onClickSearch = ::onSearchClick,
)
BackHandler(presenter.searchQuery != null) {
presenter.searchQuery = null
}
}
/**
* Called when manga in global search is clicked, opens manga.
*
* @param manga clicked item containing manga information.
*/
private fun onMangaClick(manga: Manga) {
// Open MangaController.
router.pushController(MangaController(manga.id, true))
}
fun onBrowseClick(search: String? = null, savedSearch: Long? = null, filters: String? = null) {
router.replaceTopController(BrowseSourceController(presenter.source, search, savedSearch = savedSearch, filterList = filters).withFadeTransaction())
}
private fun onLatestClick() {
router.replaceTopController(BrowseSourceController(presenter.source, GetRemoteManga.QUERY_LATEST).withFadeTransaction())
}
private fun onBrowseClick() {
router.replaceTopController(BrowseSourceController(presenter.source, GetRemoteManga.QUERY_POPULAR).withFadeTransaction())
}
private fun onSavedSearchClick(savedSearch: SavedSearch) {
router.replaceTopController(BrowseSourceController(presenter.source, savedSearch = savedSearch.id).withFadeTransaction())
}
private fun onSearchClick(query: String) {
onBrowseClick(query.nullIfBlank())
}
private fun onRemoveClick(feedSavedSearch: FeedSavedSearch) {
MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.feed)
.setMessage(R.string.feed_delete)
.setPositiveButton(R.string.action_delete) { _, _ ->
presenter.deleteFeed(feedSavedSearch)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
} }
companion object { companion object {

View File

@ -0,0 +1,214 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
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.Navigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.bluelinelabs.conductor.Router
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.source.interactor.GetRemoteManga
import eu.kanade.presentation.browse.SourceFeedScreen
import eu.kanade.presentation.browse.components.SourceFeedAddDialog
import eu.kanade.presentation.browse.components.SourceFeedDeleteDialog
import eu.kanade.presentation.browse.components.SourceFeedFailedToLoadSavedSearchDialog
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterSheet
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.toast
import exh.savedsearches.models.SavedSearch
import exh.util.nullIfBlank
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
class SourceFeedScreen(val sourceId: Long) : Screen {
@Transient
private var filterSheet: SourceFilterSheet? = null
@Composable
override fun Content() {
val screenModel = rememberScreenModel { SourceFeedScreenModel(sourceId) }
val state by screenModel.state.collectAsState()
val navigator = LocalNavigator.currentOrThrow
val router = LocalRouter.currentOrThrow
SourceFeedScreen(
name = screenModel.source.name,
isLoading = state.isLoading,
items = state.items,
onFabClick = if (state.filters.isEmpty()) null else { { filterSheet?.show() } },
onClickBrowse = { onBrowseClick(router, screenModel.source) },
onClickLatest = { onLatestClick(router, screenModel.source) },
onClickSavedSearch = { onSavedSearchClick(router, screenModel.source, it) },
onClickDelete = screenModel::openDeleteFeed,
onClickManga = { onMangaClick(navigator, it) },
onClickSearch = { onSearchClick(router, screenModel.source, it) },
searchQuery = state.searchQuery,
onSearchQueryChange = screenModel::search,
isIncognitoMode = screenModel.isIncognitoMode,
isDownloadOnly = screenModel.isDownloadOnly,
getMangaState = { screenModel.getManga(initialManga = it) },
)
val onDismissRequest = screenModel::dismissDialog
when (val dialog = state.dialog) {
is SourceFeedScreenModel.Dialog.AddFeed -> {
SourceFeedAddDialog(
onDismissRequest = onDismissRequest,
name = dialog.name,
addFeed = {
screenModel.createFeed(dialog.feedId)
onDismissRequest()
},
)
}
is SourceFeedScreenModel.Dialog.DeleteFeed -> {
SourceFeedDeleteDialog(
onDismissRequest = onDismissRequest,
deleteFeed = {
screenModel.deleteFeed(dialog.feed)
onDismissRequest()
},
)
}
SourceFeedScreenModel.Dialog.FailedToLoadSavedSearch -> {
SourceFeedFailedToLoadSavedSearchDialog(onDismissRequest)
}
null -> Unit
}
BackHandler(state.searchQuery != null) {
screenModel.search(null)
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(state.filters) {
initFilterSheet(state, screenModel, scope, context, router)
}
}
fun initFilterSheet(
state: SourceFeedState,
screenModel: SourceFeedScreenModel,
viewScope: CoroutineScope,
context: Context,
router: Router,
) {
val filterSerializer = FilterSerializer()
filterSheet = SourceFilterSheet(
context,
// SY -->
router,
screenModel.source,
emptyList(),
// SY <--
onFilterClicked = {
val allDefault = state.filters == screenModel.source.getFilterList()
filterSheet?.dismiss()
if (allDefault) {
onBrowseClick(
router,
screenModel.source.id,
state.searchQuery?.nullIfBlank(),
)
} else {
onBrowseClick(
router,
screenModel.source.id,
state.searchQuery?.nullIfBlank(),
filters = Json.encodeToString(filterSerializer.serialize(state.filters)),
)
}
},
onResetClicked = {},
onSaveClicked = {},
onSavedSearchClicked = { idOfSearch ->
viewScope.launchUI {
val search = screenModel.loadSearch(idOfSearch)
if (search == null) {
screenModel.openFailedToLoadSavedSearch()
return@launchUI
}
if (search.filterList == null && state.filters.isNotEmpty()) {
context.toast(R.string.save_search_invalid)
return@launchUI
}
if (search.filterList != null) {
screenModel.setFilters(FilterList(search.filterList))
filterSheet?.setFilters(state.filterItems)
}
val allDefault = search.filterList != null && state.filters == screenModel.source.getFilterList()
filterSheet?.dismiss()
if (!allDefault) {
onBrowseClick(
router,
screenModel.source.id,
search = state.searchQuery?.nullIfBlank(),
savedSearch = search.id,
)
}
}
},
onSavedSearchDeleteClicked = { idOfSearch, name ->
viewScope.launchUI {
if (screenModel.hasTooManyFeeds()) {
context.toast(R.string.too_many_in_feed)
return@launchUI
}
screenModel.openAddFeed(idOfSearch, name)
}
},
)
viewScope.launchUI {
filterSheet?.setSavedSearches(screenModel.loadSearches())
}
filterSheet?.setFilters(state.filterItems)
}
private fun onMangaClick(navigator: Navigator, manga: Manga) {
navigator.push(MangaScreen(manga.id, true))
}
fun onBrowseClick(router: Router, sourceId: Long, search: String? = null, savedSearch: Long? = null, filters: String? = null) {
router.replaceTopController(BrowseSourceController(sourceId, search, savedSearch = savedSearch, filterList = filters).withFadeTransaction())
}
private fun onLatestClick(router: Router, source: CatalogueSource) {
router.replaceTopController(BrowseSourceController(source, GetRemoteManga.QUERY_LATEST).withFadeTransaction())
}
fun onBrowseClick(router: Router, source: CatalogueSource) {
router.replaceTopController(BrowseSourceController(source, GetRemoteManga.QUERY_POPULAR).withFadeTransaction())
}
private fun onSavedSearchClick(router: Router, source: CatalogueSource, savedSearch: SavedSearch) {
router.replaceTopController(BrowseSourceController(source, savedSearch = savedSearch.id).withFadeTransaction())
}
private fun onSearchClick(router: Router, source: CatalogueSource, query: String) {
onBrowseClick(router, source.id, query.nullIfBlank())
}
}

View File

@ -1,10 +1,14 @@
package eu.kanade.tachiyomi.ui.browse.source.feed package eu.kanade.tachiyomi.ui.browse.source.feed
import android.os.Bundle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.core.prefs.asState
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.manga.interactor.GetManga import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.interactor.NetworkToLocalManga import eu.kanade.domain.manga.interactor.NetworkToLocalManga
@ -17,33 +21,32 @@ import eu.kanade.domain.source.interactor.GetExhSavedSearch
import eu.kanade.domain.source.interactor.GetFeedSavedSearchBySourceId import eu.kanade.domain.source.interactor.GetFeedSavedSearchBySourceId
import eu.kanade.domain.source.interactor.GetSavedSearchBySourceIdFeed import eu.kanade.domain.source.interactor.GetSavedSearchBySourceIdFeed
import eu.kanade.domain.source.interactor.InsertFeedSavedSearch import eu.kanade.domain.source.interactor.InsertFeedSavedSearch
import eu.kanade.presentation.browse.SourceFeedState
import eu.kanade.presentation.browse.SourceFeedStateImpl
import eu.kanade.presentation.browse.SourceFeedUI import eu.kanade.presentation.browse.SourceFeedUI
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.ui.browse.source.browse.toItems
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.util.lang.awaitSingle
import eu.kanade.tachiyomi.util.lang.launchNonCancellable import eu.kanade.tachiyomi.util.lang.launchNonCancellable
import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import exh.savedsearches.models.FeedSavedSearch import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch import exh.savedsearches.models.SavedSearch
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority import logcat.LogPriority
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import xyz.nulldev.ts.api.http.serializer.FilterSerializer import xyz.nulldev.ts.api.http.serializer.FilterSerializer
import java.util.concurrent.Executors
import eu.kanade.domain.manga.model.Manga as DomainManga import eu.kanade.domain.manga.model.Manga as DomainManga
/** /**
@ -52,9 +55,9 @@ import eu.kanade.domain.manga.model.Manga as DomainManga
* *
* @param source the source. * @param source the source.
*/ */
open class SourceFeedPresenter( open class SourceFeedScreenModel(
private val state: SourceFeedStateImpl = SourceFeedState() as SourceFeedStateImpl, val sourceId: Long,
val source: CatalogueSource, private val sourceManager: SourceManager = Injekt.get(),
private val preferences: BasePreferences = Injekt.get(), private val preferences: BasePreferences = Injekt.get(),
private val getManga: GetManga = Injekt.get(), private val getManga: GetManga = Injekt.get(),
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
@ -65,38 +68,33 @@ open class SourceFeedPresenter(
private val insertFeedSavedSearch: InsertFeedSavedSearch = Injekt.get(), private val insertFeedSavedSearch: InsertFeedSavedSearch = Injekt.get(),
private val deleteFeedSavedSearchById: DeleteFeedSavedSearchById = Injekt.get(), private val deleteFeedSavedSearchById: DeleteFeedSavedSearchById = Injekt.get(),
private val getExhSavedSearch: GetExhSavedSearch = Injekt.get(), private val getExhSavedSearch: GetExhSavedSearch = Injekt.get(),
) : BasePresenter<SourceFeedController>(), SourceFeedState by state { ) : StateScreenModel<SourceFeedState>(SourceFeedState()) {
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState() val source = sourceManager.getOrStub(sourceId) as CatalogueSource
val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
/** val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope)
* Fetches the different sources by user settings. val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope)
*/
private var fetchSourcesSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) { private val coroutineDispatcher = Executors.newFixedThreadPool(5).asCoroutineDispatcher()
super.onCreate(savedState)
init {
setFilters(source.getFilterList()) setFilters(source.getFilterList())
getFeedSavedSearchBySourceId.subscribe(source.id) getFeedSavedSearchBySourceId.subscribe(source.id)
.onEach { .onEach {
val items = getSourcesToGetFeed(it) val items = getSourcesToGetFeed(it)
state.items = items mutableState.update { state ->
state.isLoading = false state.copy(
items = items,
)
}
getFeed(items) getFeed(items)
} }
.launchIn(presenterScope) .launchIn(coroutineScope)
}
override fun onDestroy() {
fetchSourcesSubscription?.unsubscribe()
super.onDestroy()
} }
fun setFilters(filters: FilterList) { fun setFilters(filters: FilterList) {
state.filters = filters mutableState.update { it.copy(filters = filters) }
} }
suspend fun hasTooManyFeeds(): Boolean { suspend fun hasTooManyFeeds(): Boolean {
@ -104,7 +102,7 @@ open class SourceFeedPresenter(
} }
fun createFeed(savedSearchId: Long) { fun createFeed(savedSearchId: Long) {
presenterScope.launchNonCancellable { coroutineScope.launchNonCancellable {
insertFeedSavedSearch.await( insertFeedSavedSearch.await(
FeedSavedSearch( FeedSavedSearch(
id = -1, id = -1,
@ -117,7 +115,7 @@ open class SourceFeedPresenter(
} }
fun deleteFeed(feed: FeedSavedSearch) { fun deleteFeed(feed: FeedSavedSearch) {
presenterScope.launchNonCancellable { coroutineScope.launchNonCancellable {
deleteFeedSavedSearchById.await(feed.id) deleteFeedSavedSearchById.await(feed.id)
} }
} }
@ -141,11 +139,10 @@ open class SourceFeedPresenter(
* Initiates get manga per feed. * Initiates get manga per feed.
*/ */
private fun getFeed(feedSavedSearch: List<SourceFeedUI>) { private fun getFeed(feedSavedSearch: List<SourceFeedUI>) {
fetchSourcesSubscription?.unsubscribe() coroutineScope.launch {
fetchSourcesSubscription = Observable.from(feedSavedSearch) feedSavedSearch.forEach { sourceFeed ->
.flatMap( val page = try {
{ sourceFeed -> withContext(coroutineDispatcher) {
Observable.defer {
when (sourceFeed) { when (sourceFeed) {
is SourceFeedUI.Browse -> source.fetchPopularManga(1) is SourceFeedUI.Browse -> source.fetchPopularManga(1)
is SourceFeedUI.Latest -> source.fetchLatestUpdates(1) is SourceFeedUI.Latest -> source.fetchLatestUpdates(1)
@ -154,30 +151,25 @@ open class SourceFeedPresenter(
query = sourceFeed.savedSearch.query.orEmpty(), query = sourceFeed.savedSearch.query.orEmpty(),
filters = getFilterList(sourceFeed.savedSearch, source), filters = getFilterList(sourceFeed.savedSearch, source),
) )
} }.awaitSingle()
}.mangas
} catch (e: Exception) {
emptyList()
}
val titles = page.map {
withIOContext {
networkToLocalManga.await(it.toDomainManga(source.id))
} }
.subscribeOn(Schedulers.io()) }
.onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions
.map { it.mangas } // Get manga from search result. mutableState.update { state ->
.map { list -> runBlocking { list.map { networkToLocalManga.await(it.toDomainManga(source.id)) } } } // Convert to local manga. state.copy(
.map { list -> sourceFeed.withResults(list) } items = state.items.map { item -> if (item.id == sourceFeed.id) sourceFeed.withResults(titles) else item },
}, )
5,
)
.observeOn(AndroidSchedulers.mainThread())
// Update matching source with the obtained results
.doOnNext { result ->
synchronized(state) {
state.items = state.items?.map { item -> if (item.id == result.id) result else item }
} }
} }
// Deliver initial state }
.subscribe(
{},
{ error ->
logcat(LogPriority.ERROR, error)
},
)
} }
private val filterSerializer = FilterSerializer() private val filterSerializer = FilterSerializer()
@ -233,4 +225,48 @@ open class SourceFeedPresenter(
suspend fun loadSearches() = suspend fun loadSearches() =
getExhSavedSearch.await(source.id, source::getFilterList) getExhSavedSearch.await(source.id, source::getFilterList)
fun search(query: String?) {
mutableState.update { it.copy(searchQuery = query) }
}
fun openDeleteFeed(feed: FeedSavedSearch) {
mutableState.update { it.copy(dialog = Dialog.DeleteFeed(feed)) }
}
fun openAddFeed(feedId: Long, name: String) {
mutableState.update { it.copy(dialog = Dialog.AddFeed(feedId, name)) }
}
fun openFailedToLoadSavedSearch() {
mutableState.update { it.copy(dialog = Dialog.FailedToLoadSavedSearch) }
}
fun dismissDialog() {
mutableState.update { it.copy(dialog = null) }
}
sealed class Dialog {
data class DeleteFeed(val feed: FeedSavedSearch) : Dialog()
data class AddFeed(val feedId: Long, val name: String) : Dialog()
object FailedToLoadSavedSearch : Dialog()
}
override fun onDispose() {
super.onDispose()
coroutineDispatcher.close()
}
}
@Immutable
data class SourceFeedState(
val searchQuery: String? = null,
val items: List<SourceFeedUI> = emptyList(),
val filters: FilterList = FilterList(),
val dialog: SourceFeedScreenModel.Dialog? = null,
) {
val filterItems: List<IFlexible<*>> by lazy { filters.toItems() }
val isLoading
get() = items.isEmpty()
} }

View File

@ -64,6 +64,7 @@ import eu.kanade.tachiyomi.ui.browse.source.SourcesController
import eu.kanade.tachiyomi.ui.browse.source.SourcesController.Companion.SMART_SEARCH_SOURCE_TAG import eu.kanade.tachiyomi.ui.browse.source.SourcesController.Companion.SMART_SEARCH_SOURCE_TAG
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedScreen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.history.HistoryController import eu.kanade.tachiyomi.ui.history.HistoryController
@ -160,11 +161,11 @@ class MangaScreen(
// SY <-- // SY <--
onWebViewLongClicked = { copyMangaUrl(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource }, onWebViewLongClicked = { copyMangaUrl(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
onTrackingClicked = screenModel::showTrackDialog.takeIf { successState.trackingAvailable }, onTrackingClicked = screenModel::showTrackDialog.takeIf { successState.trackingAvailable },
onTagClicked = { performGenreSearch(router, it, screenModel.source!!) }, onTagClicked = { performGenreSearch(router, navigator, it, screenModel.source!!) },
onFilterButtonClicked = screenModel::showSettingsDialog, onFilterButtonClicked = screenModel::showSettingsDialog,
onRefresh = screenModel::fetchAllFromSource, onRefresh = screenModel::fetchAllFromSource,
onContinueReading = { continueReading(context, screenModel.getNextUnreadChapter()) }, onContinueReading = { continueReading(context, screenModel.getNextUnreadChapter()) },
onSearch = { query, global -> performSearch(router, query, global) }, onSearch = { query, global -> performSearch(router, navigator, query, global) },
onCoverClicked = screenModel::showCoverDialog, onCoverClicked = screenModel::showCoverDialog,
onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource }, onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() },
@ -368,12 +369,25 @@ class MangaScreen(
* *
* @param query the search query to the parent controller * @param query the search query to the parent controller
*/ */
private fun performSearch(router: Router, query: String, global: Boolean) { private fun performSearch(router: Router, navigator: Navigator, query: String, global: Boolean) {
if (global) { if (global) {
router.pushController(GlobalSearchController(query)) router.pushController(GlobalSearchController(query))
return return
} }
// SY -->
if (navigator.canPop) {
when (val previousScreen = navigator.items[navigator.items.size - 2]) {
is SourceFeedScreen -> {
navigator.pop()
previousScreen.onBrowseClick(router, previousScreen.sourceId, query)
}
}
return
}
// SY <--
if (router.backstackSize < 2) { if (router.backstackSize < 2) {
return return
} }
@ -399,7 +413,8 @@ class MangaScreen(
// SY --> // SY -->
is SourceFeedController -> { is SourceFeedController -> {
router.handleBack() router.handleBack()
previousController.onBrowseClick(query) router.handleBack()
router.pushController(BrowseSourceController(previousController.sourceId, query))
} }
// SY <-- // SY <--
} }
@ -410,7 +425,7 @@ class MangaScreen(
* *
* @param genreName the search genre to the parent controller * @param genreName the search genre to the parent controller
*/ */
private fun performGenreSearch(router: Router, genreName: String, source: Source) { private fun performGenreSearch(router: Router, navigator: Navigator, genreName: String, source: Source) {
if (router.backstackSize < 2) { if (router.backstackSize < 2) {
return return
} }
@ -423,7 +438,7 @@ class MangaScreen(
router.handleBack() router.handleBack()
previousController.searchWithGenre(genreName) previousController.searchWithGenre(genreName)
} else { } else {
performSearch(router, genreName, global = false) performSearch(router, navigator, genreName, global = false)
} }
} }

View File

@ -4,19 +4,17 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bluelinelabs.conductor.Router
import eu.kanade.tachiyomi.databinding.SourceFilterMangadexHeaderBinding import eu.kanade.tachiyomi.databinding.SourceFilterMangadexHeaderBinding
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.online.RandomMangaSource import eu.kanade.tachiyomi.source.online.RandomMangaSource
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withIOContext
import exh.md.follows.MangaDexFollowsController import exh.md.follows.MangaDexFollowsController
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
class MangaDexFabHeaderAdapter(val controller: BaseController<*>, val source: CatalogueSource, val onClick: () -> Unit) : class MangaDexFabHeaderAdapter(val router: Router, val source: CatalogueSource, val onClick: () -> Unit) :
RecyclerView.Adapter<MangaDexFabHeaderAdapter.SavedSearchesViewHolder>() { RecyclerView.Adapter<MangaDexFabHeaderAdapter.SavedSearchesViewHolder>() {
private lateinit var binding: SourceFilterMangadexHeaderBinding private lateinit var binding: SourceFilterMangadexHeaderBinding
@ -35,22 +33,23 @@ class MangaDexFabHeaderAdapter(val controller: BaseController<*>, val source: Ca
inner class SavedSearchesViewHolder(view: View) : RecyclerView.ViewHolder(view) { inner class SavedSearchesViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind() { fun bind() {
binding.mangadexFollows.setOnClickListener { binding.mangadexFollows.setOnClickListener {
controller.router.replaceTopController(MangaDexFollowsController(source).withFadeTransaction()) router.replaceTopController(MangaDexFollowsController(source).withFadeTransaction())
onClick() onClick()
} }
binding.mangadexRandom.clicks() binding.mangadexRandom.setOnClickListener {
.onEach { launchUI {
val randomMangaUrl = withIOContext { val randomMangaUrl = withIOContext {
(source as? RandomMangaSource)?.fetchRandomMangaUrl() (source as? RandomMangaSource)?.fetchRandomMangaUrl()
} }
controller.router.replaceTopController( router.replaceTopController(
BrowseSourceController( BrowseSourceController(
source, source,
"id:$randomMangaUrl", "id:$randomMangaUrl",
).withFadeTransaction(), ).withFadeTransaction(),
) )
onClick() onClick()
}.launchIn(controller.viewScope) }
}
} }
} }
} }