From 658c84bef8a95c50ea0f678e63cc0e4b85bd3bee Mon Sep 17 00:00:00 2001 From: Jobobby04 Date: Mon, 28 Nov 2022 22:16:18 -0500 Subject: [PATCH] Use Voyager for source feed --- .../presentation/browse/SourceFeedScreen.kt | 44 ++-- .../presentation/browse/SourceFeedState.kt | 31 --- .../browse/components/SourceFeedDialogs.kt | 81 +++++++ .../source/browse/BrowseSourceController.kt | 2 +- .../browse/source/browse/SourceFilterSheet.kt | 19 +- .../source/feed/SourceFeedController.kt | 221 ++---------------- .../ui/browse/source/feed/SourceFeedScreen.kt | 214 +++++++++++++++++ ...dPresenter.kt => SourceFeedScreenModel.kt} | 154 +++++++----- .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 27 ++- .../java/exh/md/MangaDexFabHeaderAdapter.kt | 19 +- 10 files changed, 469 insertions(+), 343 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/presentation/browse/SourceFeedState.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/SourceFeedDialogs.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/{SourceFeedPresenter.kt => SourceFeedScreenModel.kt} (67%) diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt index db9e98f29..e729f60ea 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt @@ -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, + 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, ) { 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, paddingValues: PaddingValues, getMangaState: @Composable ((Manga) -> State), 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, diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedState.kt b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedState.kt deleted file mode 100644 index 0fb3909e3..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedState.kt +++ /dev/null @@ -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> - val items: List? -} - -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> by derivedStateOf { filters.toItems() } - override var items: List? by mutableStateOf(null) -} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/SourceFeedDialogs.kt b/app/src/main/java/eu/kanade/presentation/browse/components/SourceFeedDialogs.kt new file mode 100644 index 000000000..aea70f951 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/SourceFeedDialogs.kt @@ -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)) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt index af95ce77c..b0916de9b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt @@ -208,7 +208,7 @@ open class BrowseSourceController(bundle: Bundle) : filterSheet = SourceFilterSheet( activity!!, // SY --> - this, + router, presenter.source!!, emptyList(), // SY <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt index 129402dcd..1bad2a3a4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt @@ -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 = 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 = 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() ?.let { - MangaDexFabHeaderAdapter(controller, it) { + MangaDexFabHeaderAdapter(router, it) { dismissSheet?.invoke() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedController.kt index 8ee79ece0..efde60f9a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedController.kt @@ -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 { +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().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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt new file mode 100644 index 000000000..34f2e7466 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt @@ -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()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreenModel.kt similarity index 67% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreenModel.kt index e04e3bd54..130fd902f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreenModel.kt @@ -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(), SourceFeedState by state { +) : StateScreenModel(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) { - 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 = emptyList(), + val filters: FilterList = FilterList(), + val dialog: SourceFeedScreenModel.Dialog? = null, +) { + val filterItems: List> by lazy { filters.toItems() } + + val isLoading + get() = items.isEmpty() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index f9d47eba0..2f8fe3839 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -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) } } diff --git a/app/src/main/java/exh/md/MangaDexFabHeaderAdapter.kt b/app/src/main/java/exh/md/MangaDexFabHeaderAdapter.kt index e3aa8e629..df1aa06c9 100644 --- a/app/src/main/java/exh/md/MangaDexFabHeaderAdapter.kt +++ b/app/src/main/java/exh/md/MangaDexFabHeaderAdapter.kt @@ -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() { 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) + } + } } } }