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

View File

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

View File

@ -1,229 +1,34 @@
package eu.kanade.tachiyomi.ui.browse.source.feed
import android.os.Bundle
import android.view.View
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.core.os.bundleOf
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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 cafe.adriel.voyager.navigator.Navigator
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.SourceManager
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
import eu.kanade.tachiyomi.ui.base.controller.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> {
class SourceFeedController : BasicFullComposeController {
constructor(source: CatalogueSource?) : super(
constructor(source: CatalogueSource) : super(
bundleOf(
SOURCE_EXTRA to (source?.id ?: 0),
SOURCE_EXTRA to source.id,
),
) {
this.source = source
}
)
constructor(sourceId: Long) : this(
Injekt.get<SourceManager>().get(sourceId) as? CatalogueSource,
constructor(sourceId: Long) : super(
bundleOf(
SOURCE_EXTRA to sourceId,
),
)
@Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getLong(SOURCE_EXTRA))
constructor(bundle: Bundle) : super(bundle)
var source: CatalogueSource? = null
/**
* 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)
}
val sourceId = args.getLong(SOURCE_EXTRA)
@Composable
override fun ComposeContent() {
SourceFeedScreen(
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()
Navigator(screen = SourceFeedScreen(sourceId))
}
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
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
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.manga.interactor.GetManga
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.GetSavedSearchBySourceIdFeed
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.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.toItems
import eu.kanade.tachiyomi.util.lang.awaitSingle
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.logcat
import exh.savedsearches.models.FeedSavedSearch
import exh.savedsearches.models.SavedSearch
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
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.json.Json
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.api.get
import xyz.nulldev.ts.api.http.serializer.FilterSerializer
import java.util.concurrent.Executors
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.
*/
open class SourceFeedPresenter(
private val state: SourceFeedStateImpl = SourceFeedState() as SourceFeedStateImpl,
val source: CatalogueSource,
open class SourceFeedScreenModel(
val sourceId: Long,
private val sourceManager: SourceManager = Injekt.get(),
private val preferences: BasePreferences = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
@ -65,38 +68,33 @@ open class SourceFeedPresenter(
private val insertFeedSavedSearch: InsertFeedSavedSearch = Injekt.get(),
private val deleteFeedSavedSearchById: DeleteFeedSavedSearchById = Injekt.get(),
private val getExhSavedSearch: GetExhSavedSearch = Injekt.get(),
) : BasePresenter<SourceFeedController>(), SourceFeedState by state {
) : StateScreenModel<SourceFeedState>(SourceFeedState()) {
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
val source = sourceManager.getOrStub(sourceId) as CatalogueSource
/**
* Fetches the different sources by user settings.
*/
private var fetchSourcesSubscription: Subscription? = null
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope)
val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope)
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
private val coroutineDispatcher = Executors.newFixedThreadPool(5).asCoroutineDispatcher()
init {
setFilters(source.getFilterList())
getFeedSavedSearchBySourceId.subscribe(source.id)
.onEach {
val items = getSourcesToGetFeed(it)
state.items = items
state.isLoading = false
mutableState.update { state ->
state.copy(
items = items,
)
}
getFeed(items)
}
.launchIn(presenterScope)
}
override fun onDestroy() {
fetchSourcesSubscription?.unsubscribe()
super.onDestroy()
.launchIn(coroutineScope)
}
fun setFilters(filters: FilterList) {
state.filters = filters
mutableState.update { it.copy(filters = filters) }
}
suspend fun hasTooManyFeeds(): Boolean {
@ -104,7 +102,7 @@ open class SourceFeedPresenter(
}
fun createFeed(savedSearchId: Long) {
presenterScope.launchNonCancellable {
coroutineScope.launchNonCancellable {
insertFeedSavedSearch.await(
FeedSavedSearch(
id = -1,
@ -117,7 +115,7 @@ open class SourceFeedPresenter(
}
fun deleteFeed(feed: FeedSavedSearch) {
presenterScope.launchNonCancellable {
coroutineScope.launchNonCancellable {
deleteFeedSavedSearchById.await(feed.id)
}
}
@ -141,11 +139,10 @@ open class SourceFeedPresenter(
* Initiates get manga per feed.
*/
private fun getFeed(feedSavedSearch: List<SourceFeedUI>) {
fetchSourcesSubscription?.unsubscribe()
fetchSourcesSubscription = Observable.from(feedSavedSearch)
.flatMap(
{ sourceFeed ->
Observable.defer {
coroutineScope.launch {
feedSavedSearch.forEach { sourceFeed ->
val page = try {
withContext(coroutineDispatcher) {
when (sourceFeed) {
is SourceFeedUI.Browse -> source.fetchPopularManga(1)
is SourceFeedUI.Latest -> source.fetchLatestUpdates(1)
@ -154,30 +151,25 @@ open class SourceFeedPresenter(
query = sourceFeed.savedSearch.query.orEmpty(),
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.
.map { list -> runBlocking { list.map { networkToLocalManga.await(it.toDomainManga(source.id)) } } } // Convert to local manga.
.map { list -> sourceFeed.withResults(list) }
},
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 }
}
mutableState.update { state ->
state.copy(
items = state.items.map { item -> if (item.id == sourceFeed.id) sourceFeed.withResults(titles) else item },
)
}
}
// Deliver initial state
.subscribe(
{},
{ error ->
logcat(LogPriority.ERROR, error)
},
)
}
}
private val filterSerializer = FilterSerializer()
@ -233,4 +225,48 @@ open class SourceFeedPresenter(
suspend fun loadSearches() =
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.browse.BrowseSourceController
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.category.CategoryScreen
import eu.kanade.tachiyomi.ui.history.HistoryController
@ -160,11 +161,11 @@ class MangaScreen(
// SY <--
onWebViewLongClicked = { copyMangaUrl(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
onTrackingClicked = screenModel::showTrackDialog.takeIf { successState.trackingAvailable },
onTagClicked = { performGenreSearch(router, it, screenModel.source!!) },
onTagClicked = { performGenreSearch(router, navigator, it, screenModel.source!!) },
onFilterButtonClicked = screenModel::showSettingsDialog,
onRefresh = screenModel::fetchAllFromSource,
onContinueReading = { continueReading(context, screenModel.getNextUnreadChapter()) },
onSearch = { query, global -> performSearch(router, query, global) },
onSearch = { query, global -> performSearch(router, navigator, query, global) },
onCoverClicked = screenModel::showCoverDialog,
onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() },
@ -368,12 +369,25 @@ class MangaScreen(
*
* @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) {
router.pushController(GlobalSearchController(query))
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) {
return
}
@ -399,7 +413,8 @@ class MangaScreen(
// SY -->
is SourceFeedController -> {
router.handleBack()
previousController.onBrowseClick(query)
router.handleBack()
router.pushController(BrowseSourceController(previousController.sourceId, query))
}
// SY <--
}
@ -410,7 +425,7 @@ class MangaScreen(
*
* @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) {
return
}
@ -423,7 +438,7 @@ class MangaScreen(
router.handleBack()
previousController.searchWithGenre(genreName)
} 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.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bluelinelabs.conductor.Router
import eu.kanade.tachiyomi.databinding.SourceFilterMangadexHeaderBinding
import eu.kanade.tachiyomi.source.CatalogueSource
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.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
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>() {
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) {
fun bind() {
binding.mangadexFollows.setOnClickListener {
controller.router.replaceTopController(MangaDexFollowsController(source).withFadeTransaction())
router.replaceTopController(MangaDexFollowsController(source).withFadeTransaction())
onClick()
}
binding.mangadexRandom.clicks()
.onEach {
binding.mangadexRandom.setOnClickListener {
launchUI {
val randomMangaUrl = withIOContext {
(source as? RandomMangaSource)?.fetchRandomMangaUrl()
}
controller.router.replaceTopController(
router.replaceTopController(
BrowseSourceController(
source,
"id:$randomMangaUrl",
).withFadeTransaction(),
)
onClick()
}.launchIn(controller.viewScope)
}
}
}
}
}