From f99b62a069e8e987318a0144090560795d59e3ff Mon Sep 17 00:00:00 2001 From: Andreas Date: Sun, 27 Nov 2022 20:56:21 +0100 Subject: [PATCH] Use Compose on Global/Migrate Search screen (#8631) * Use Compose on Global/Migrate Search screen - Refactor to use Voyager and Compose - Use sealed class for state - Somethings are broken/missing due to screens using different navigation libraries * Review changes --- .../kanade/domain/manga/model/MangaCover.kt | 10 + .../presentation/browse/BrowseBadges.kt | 13 + .../presentation/browse/GlobalSearchScreen.kt | 111 ++++++ .../browse/MigrateSearchScreen.kt | 100 ++++++ .../components/BrowseSourceComfortableGrid.kt | 8 +- .../components/BrowseSourceCompactGrid.kt | 8 +- .../browse/components/BrowseSourceList.kt | 8 +- .../browse/components/GlobalSearchCardRow.kt | 40 +++ .../components/GlobalSearchResultItems.kt | 101 ++++++ .../browse/components/GlobalSearchToolbar.kt | 36 ++ .../browse/components/GlobalSerachCard.kt | 33 ++ .../eu/kanade/presentation/util/Constants.kt | 2 + .../migration/manga/MigrationMangaScreen.kt | 11 +- .../migration/search/MigrateSearchScreen.kt | 326 ++++++++++++++++++ .../search/MigrateSearchScreenModel.kt | 93 +++++ .../migration/search/SearchController.kt | 154 --------- .../migration/search/SearchPresenter.kt | 204 ----------- .../search/SourceSearchController.kt | 33 +- .../source/browse/BrowseSourceController.kt | 3 +- .../source/browse/BrowseSourcePresenter.kt | 1 + .../globalsearch/GlobalSearchAdapter.kt | 79 ----- .../globalsearch/GlobalSearchCardAdapter.kt | 27 -- .../globalsearch/GlobalSearchCardHolder.kt | 58 ---- .../globalsearch/GlobalSearchCardItem.kt | 40 --- .../globalsearch/GlobalSearchController.kt | 237 +------------ .../source/globalsearch/GlobalSearchHolder.kt | 110 ------ .../source/globalsearch/GlobalSearchItem.kt | 71 ---- .../globalsearch/GlobalSearchPresenter.kt | 265 -------------- .../source/globalsearch/GlobalSearchScreen.kt | 53 +++ .../globalsearch/GlobalSearchScreenModel.kt | 83 +++++ .../source/globalsearch/SearchScreenModel.kt | 167 +++++++++ .../tachiyomi/ui/library/LibraryScreen.kt | 2 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 2 +- .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 12 +- .../res/layout/global_search_controller.xml | 52 --- .../layout/global_search_controller_card.xml | 86 ----- .../global_search_controller_card_item.xml | 84 ----- app/src/main/res/menu/global_search.xml | 12 - 38 files changed, 1229 insertions(+), 1506 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/browse/BrowseBadges.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchResultItems.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchToolbar.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/GlobalSerachCard.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchPresenter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreenModel.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt delete mode 100644 app/src/main/res/layout/global_search_controller.xml delete mode 100644 app/src/main/res/layout/global_search_controller_card.xml delete mode 100644 app/src/main/res/layout/global_search_controller_card_item.xml delete mode 100644 app/src/main/res/menu/global_search.xml diff --git a/app/src/main/java/eu/kanade/domain/manga/model/MangaCover.kt b/app/src/main/java/eu/kanade/domain/manga/model/MangaCover.kt index 748926bb6..6b2d6a42f 100644 --- a/app/src/main/java/eu/kanade/domain/manga/model/MangaCover.kt +++ b/app/src/main/java/eu/kanade/domain/manga/model/MangaCover.kt @@ -10,3 +10,13 @@ data class MangaCover( val url: String?, val lastModified: Long, ) + +fun Manga.asMangaCover(): MangaCover { + return MangaCover( + mangaId = id, + sourceId = source, + isMangaFavorite = favorite, + url = thumbnailUrl, + lastModified = coverLastModified, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/BrowseBadges.kt b/app/src/main/java/eu/kanade/presentation/browse/BrowseBadges.kt new file mode 100644 index 000000000..3c7ab0c27 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/BrowseBadges.kt @@ -0,0 +1,13 @@ +package eu.kanade.presentation.browse + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.kanade.presentation.components.Badge +import eu.kanade.tachiyomi.R + +@Composable +fun InLibraryBadge(enabled: Boolean) { + if (enabled) { + Badge(text = stringResource(R.string.in_library)) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt new file mode 100644 index 000000000..014039448 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt @@ -0,0 +1,111 @@ +package eu.kanade.presentation.browse + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import eu.kanade.domain.manga.model.Manga +import eu.kanade.presentation.browse.components.GlobalSearchCardRow +import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem +import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem +import eu.kanade.presentation.browse.components.GlobalSearchResultItem +import eu.kanade.presentation.browse.components.GlobalSearchToolbar +import eu.kanade.presentation.components.LazyColumn +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.util.padding +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItemResult +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchState +import eu.kanade.tachiyomi.util.system.LocaleHelper + +@Composable +fun GlobalSearchScreen( + state: GlobalSearchState, + navigateUp: () -> Unit, + onChangeSearchQuery: (String?) -> Unit, + onSearch: (String) -> Unit, + getManga: @Composable (CatalogueSource, Manga) -> State, + onClickSource: (CatalogueSource) -> Unit, + onClickItem: (Manga) -> Unit, + onLongClickItem: (Manga) -> Unit, +) { + Scaffold( + topBar = { + GlobalSearchToolbar( + searchQuery = state.searchQuery, + progress = state.progress, + total = state.total, + navigateUp = navigateUp, + onChangeSearchQuery = onChangeSearchQuery, + onSearch = onSearch, + ) + }, + ) { paddingValues -> + GlobalSearchContent( + items = state.items, + contentPadding = paddingValues, + getManga = getManga, + onClickSource = onClickSource, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + ) + } +} + +@Composable +fun GlobalSearchContent( + items: Map, + contentPadding: PaddingValues, + getManga: @Composable (CatalogueSource, Manga) -> State, + onClickSource: (CatalogueSource) -> Unit, + onClickItem: (Manga) -> Unit, + onLongClickItem: (Manga) -> Unit, +) { + LazyColumn( + contentPadding = contentPadding, + ) { + items.forEach { (source, result) -> + item { + GlobalSearchResultItem( + title = source.name, + subtitle = LocaleHelper.getDisplayName(source.lang), + onClick = { onClickSource(source) }, + ) { + when (result) { + is GlobalSearchItemResult.Error -> { + GlobalSearchErrorResultItem(message = result.throwable.message) + } + GlobalSearchItemResult.Loading -> { + GlobalSearchLoadingResultItem() + } + is GlobalSearchItemResult.Success -> { + if (result.isEmpty) { + Text( + text = stringResource(id = R.string.no_results_found), + modifier = Modifier + .padding( + horizontal = MaterialTheme.padding.medium, + vertical = MaterialTheme.padding.small, + ), + ) + return@GlobalSearchResultItem + } + + GlobalSearchCardRow( + titles = result.result, + getManga = { getManga(source, it) }, + onClick = onClickItem, + onLongClick = onLongClickItem, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt new file mode 100644 index 000000000..436c9bc2b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt @@ -0,0 +1,100 @@ +package eu.kanade.presentation.browse + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import eu.kanade.domain.manga.model.Manga +import eu.kanade.presentation.browse.components.GlobalSearchCardRow +import eu.kanade.presentation.browse.components.GlobalSearchEmptyResultItem +import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem +import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem +import eu.kanade.presentation.browse.components.GlobalSearchResultItem +import eu.kanade.presentation.browse.components.GlobalSearchToolbar +import eu.kanade.presentation.components.LazyColumn +import eu.kanade.presentation.components.Scaffold +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchState +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItemResult +import eu.kanade.tachiyomi.util.system.LocaleHelper + +@Composable +fun MigrateSearchScreen( + navigateUp: () -> Unit, + state: MigrateSearchState, + getManga: @Composable (CatalogueSource, Manga) -> State, + onChangeSearchQuery: (String?) -> Unit, + onSearch: (String) -> Unit, + onClickSource: (CatalogueSource) -> Unit, + onClickItem: (Manga) -> Unit, + onLongClickItem: (Manga) -> Unit, +) { + Scaffold( + topBar = { + GlobalSearchToolbar( + searchQuery = state.searchQuery, + progress = state.progress, + total = state.total, + navigateUp = navigateUp, + onChangeSearchQuery = onChangeSearchQuery, + onSearch = onSearch, + ) + }, + ) { paddingValues -> + MigrateSearchContent( + sourceId = state.manga?.source ?: -1, + items = state.items, + contentPadding = paddingValues, + getManga = getManga, + onClickSource = onClickSource, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + ) + } +} + +@Composable +fun MigrateSearchContent( + sourceId: Long, + items: Map, + contentPadding: PaddingValues, + getManga: @Composable (CatalogueSource, Manga) -> State, + onClickSource: (CatalogueSource) -> Unit, + onClickItem: (Manga) -> Unit, + onLongClickItem: (Manga) -> Unit, +) { + LazyColumn( + contentPadding = contentPadding, + ) { + items.forEach { (source, result) -> + item { + GlobalSearchResultItem( + title = if (source.id == sourceId) "▶ ${source.name}" else source.name, + subtitle = LocaleHelper.getDisplayName(source.lang), + onClick = { onClickSource(source) }, + ) { + when (result) { + is GlobalSearchItemResult.Error -> { + GlobalSearchErrorResultItem(message = result.throwable.message) + } + GlobalSearchItemResult.Loading -> { + GlobalSearchLoadingResultItem() + } + is GlobalSearchItemResult.Success -> { + if (result.isEmpty) { + GlobalSearchEmptyResultItem() + return@GlobalSearchResultItem + } + + GlobalSearchCardRow( + titles = result.result, + getManga = { getManga(source, it) }, + onClick = onClickItem, + onLongClick = onLongClickItem, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt index 3d4d0a0e2..e84afa94f 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt @@ -8,17 +8,15 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.MangaCover -import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.components.CommonMangaItemDefaults import eu.kanade.presentation.components.MangaComfortableGridItem import eu.kanade.presentation.util.plus -import eu.kanade.tachiyomi.R @Composable fun BrowseSourceComfortableGrid( @@ -76,9 +74,7 @@ fun BrowseSourceComfortableGridItem( ), coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f, coverBadgeStart = { - if (manga.favorite) { - Badge(text = stringResource(R.string.in_library)) - } + InLibraryBadge(enabled = manga.favorite) }, onLongClick = onLongClick, onClick = onClick, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt index 7c987ca91..b062b956f 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt @@ -8,17 +8,15 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.MangaCover -import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.components.CommonMangaItemDefaults import eu.kanade.presentation.components.MangaCompactGridItem import eu.kanade.presentation.util.plus -import eu.kanade.tachiyomi.R @Composable fun BrowseSourceCompactGrid( @@ -76,9 +74,7 @@ private fun BrowseSourceCompactGridItem( ), coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f, coverBadgeStart = { - if (manga.favorite) { - Badge(text = stringResource(R.string.in_library)) - } + InLibraryBadge(enabled = manga.favorite) }, onLongClick = onLongClick, onClick = onClick, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt index ebb712998..4fd4134d7 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt @@ -4,19 +4,17 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.items import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.MangaCover -import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.components.CommonMangaItemDefaults import eu.kanade.presentation.components.LazyColumn import eu.kanade.presentation.components.MangaListItem import eu.kanade.presentation.util.plus -import eu.kanade.tachiyomi.R @Composable fun BrowseSourceList( @@ -70,9 +68,7 @@ fun BrowseSourceListItem( ), coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f, badge = { - if (manga.favorite) { - Badge(text = stringResource(R.string.in_library)) - } + InLibraryBadge(enabled = manga.favorite) }, onLongClick = onLongClick, onClick = onClick, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt new file mode 100644 index 000000000..7b6887fc5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt @@ -0,0 +1,40 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.asMangaCover +import eu.kanade.presentation.util.padding + +@Composable +fun GlobalSearchCardRow( + titles: List, + getManga: @Composable (Manga) -> State, + onClick: (Manga) -> Unit, + onLongClick: (Manga) -> Unit, +) { + LazyRow( + contentPadding = PaddingValues( + horizontal = MaterialTheme.padding.medium, + vertical = MaterialTheme.padding.small, + ), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + ) { + items(titles) { title -> + val title by getManga(title) + GlobalSearchCard( + title = title.title, + cover = title.asMangaCover(), + isFavorite = title.favorite, + onClick = { onClick(title) }, + onLongClick = { onLongClick(title) }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchResultItems.kt b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchResultItems.kt new file mode 100644 index 000000000..ba4ec27b7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchResultItems.kt @@ -0,0 +1,101 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowForward +import androidx.compose.material.icons.outlined.Error +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.util.padding +import eu.kanade.tachiyomi.R + +@Composable +fun GlobalSearchResultItem( + title: String, + subtitle: String, + onClick: () -> Unit, + content: @Composable () -> Unit, +) { + Column { + Row( + modifier = Modifier + .padding( + start = MaterialTheme.padding.medium, + end = MaterialTheme.padding.tiny, + ) + .fillMaxWidth() + .clickable(onClick = onClick), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + Text(text = subtitle) + } + IconButton(onClick = onClick) { + Icon(imageVector = Icons.Outlined.ArrowForward, contentDescription = null) + } + } + content() + } +} + +@Composable +fun GlobalSearchEmptyResultItem() { + Text( + text = stringResource(id = R.string.no_results_found), + modifier = Modifier + .padding( + horizontal = MaterialTheme.padding.medium, + vertical = MaterialTheme.padding.small, + ), + ) +} + +@Composable +fun GlobalSearchLoadingResultItem() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = MaterialTheme.padding.medium), + ) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + .align(Alignment.Center), + strokeWidth = 2.dp, + ) + } +} + +@Composable +fun GlobalSearchErrorResultItem(message: String?) { + Column( + modifier = Modifier + .padding(vertical = MaterialTheme.padding.medium) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon(imageVector = Icons.Outlined.Error, contentDescription = null) + Text(text = message ?: stringResource(id = R.string.unknown_error)) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchToolbar.kt b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchToolbar.kt new file mode 100644 index 000000000..c05a1c9e4 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchToolbar.kt @@ -0,0 +1,36 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import eu.kanade.presentation.components.SearchToolbar + +@Composable +fun GlobalSearchToolbar( + searchQuery: String?, + progress: Int, + total: Int, + navigateUp: () -> Unit, + onChangeSearchQuery: (String?) -> Unit, + onSearch: (String) -> Unit, +) { + Box { + SearchToolbar( + searchQuery = searchQuery, + onChangeSearchQuery = onChangeSearchQuery, + onSearch = onSearch, + navigateUp = navigateUp, + ) + if (progress in 1 until total) { + LinearProgressIndicator( + progress = progress / total.toFloat(), + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth(), + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSerachCard.kt b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSerachCard.kt new file mode 100644 index 000000000..7279b8bb1 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSerachCard.kt @@ -0,0 +1,33 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import eu.kanade.domain.manga.model.MangaCover +import eu.kanade.presentation.browse.InLibraryBadge +import eu.kanade.presentation.components.CommonMangaItemDefaults +import eu.kanade.presentation.components.MangaComfortableGridItem + +@Composable +fun GlobalSearchCard( + title: String, + cover: MangaCover, + isFavorite: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Box(modifier = Modifier.width(128.dp)) { + MangaComfortableGridItem( + title = title, + coverData = cover, + coverBadgeStart = { + InLibraryBadge(enabled = isFavorite) + }, + coverAlpha = if (isFavorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f, + onClick = onClick, + onLongClick = onLongClick, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/util/Constants.kt b/app/src/main/java/eu/kanade/presentation/util/Constants.kt index 246e7f071..651ad0853 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Constants.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Constants.kt @@ -18,6 +18,8 @@ class Padding { val medium = 16.dp val small = 8.dp + + val tiny = 4.dp } val MaterialTheme.padding: Padding diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaScreen.kt index 4e3ebbdc4..098c0d2b8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaScreen.kt @@ -13,8 +13,7 @@ import eu.kanade.presentation.browse.MigrateMangaScreen import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.util.LocalRouter import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController +import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.flow.collectLatest @@ -41,12 +40,8 @@ data class MigrationMangaScreen( navigateUp = navigator::pop, title = state.source!!.name, state = state, - onClickItem = { - router.pushController(SearchController(it.id)) - }, - onClickCover = { - navigator.push(MangaScreen(it.id)) - }, + onClickItem = { navigator.push(MigrateSearchScreen(it.id)) }, + onClickCover = { navigator.push(MangaScreen(it.id)) }, ) LaunchedEffect(Unit) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt new file mode 100644 index 000000000..7b57e423b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt @@ -0,0 +1,326 @@ +package eu.kanade.tachiyomi.ui.browse.migration.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.util.fastForEachIndexed +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.category.interactor.GetCategories +import eu.kanade.domain.category.interactor.SetMangaCategories +import eu.kanade.domain.chapter.interactor.GetChapterByMangaId +import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource +import eu.kanade.domain.chapter.interactor.UpdateChapter +import eu.kanade.domain.chapter.model.toChapterUpdate +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.MangaUpdate +import eu.kanade.domain.manga.model.hasCustomCover +import eu.kanade.domain.track.interactor.GetTracks +import eu.kanade.domain.track.interactor.InsertTrack +import eu.kanade.presentation.browse.MigrateSearchScreen +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.preference.Preference +import eu.kanade.tachiyomi.core.preference.PreferenceStore +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.track.EnhancedTrackService +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.manga.MangaScreen +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.launchUI +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Date + +class MigrateSearchScreen(private val mangaId: Long) : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val router = LocalRouter.currentOrThrow + val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) } + val state by screenModel.state.collectAsState() + + MigrateSearchScreen( + navigateUp = navigator::pop, + state = state, + getManga = { source, manga -> + screenModel.getManga(source = source, initialManga = manga) + }, + onChangeSearchQuery = screenModel::updateSearchQuery, + onSearch = screenModel::search, + onClickSource = { + if (!screenModel.incognitoMode.get()) { + screenModel.lastUsedSourceId.set(it.id) + } + router.pushController(SourceSearchController(state.manga, it, state.searchQuery)) + }, + onClickItem = { screenModel.setDialog(MigrateSearchDialog.Migrate(it)) }, + onLongClickItem = { navigator.push(MangaScreen(it.id, true)) }, + ) + + when (val dialog = state.dialog) { + null -> {} + is MigrateSearchDialog.Migrate -> { + MigrateDialog( + oldManga = state.manga!!, + newManga = dialog.manga, + screenModel = rememberScreenModel { MigrateDialogScreenModel() }, + onDismissRequest = { screenModel.setDialog(null) }, + onClickTitle = { + navigator.push(MangaScreen(dialog.manga.id, true)) + }, + onPopScreen = { + if (navigator.lastItem is MangaScreen) { + val lastItem = navigator.lastItem + navigator.popUntil { navigator.items.contains(lastItem) } + navigator.push(MangaScreen(dialog.manga.id)) + } else { + navigator.pop() + router.pushController(MangaController(dialog.manga.id)) + } + }, + ) + } + } + } +} + +@Composable +fun MigrateDialog( + oldManga: Manga, + newManga: Manga, + screenModel: MigrateDialogScreenModel, + onDismissRequest: () -> Unit, + onClickTitle: () -> Unit, + onPopScreen: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val activeFlags = remember { MigrationFlags.getEnabledFlagsPositions(screenModel.migrateFlags.get()) } + val items = remember { + MigrationFlags.titles(oldManga) + .map { context.getString(it) } + .toList() + } + val selected = remember { + mutableStateListOf(*List(items.size) { i -> activeFlags.contains(i) }.toTypedArray()) + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(id = R.string.migration_dialog_what_to_include)) + }, + text = { + Column { + items.forEachIndexed { index, title -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = selected[index], onCheckedChange = { selected[index] = !selected[index] }) + Text(text = title) + } + } + } + }, + confirmButton = { + Row { + TextButton( + modifier = Modifier.weight(1f), + onClick = { + onClickTitle() + onDismissRequest() + }, + ) { + Text(text = stringResource(id = R.string.action_show_manga)) + } + TextButton(onClick = { + scope.launchIO { + screenModel.migrateManga(oldManga, newManga, false) + launchUI { + onPopScreen() + } + } + },) { + Text(text = stringResource(id = R.string.copy)) + } + TextButton(onClick = { + scope.launchIO { + val selectedIndices = mutableListOf() + selected.fastForEachIndexed { i, b -> if (b) selectedIndices.add(i) } + val newValue = MigrationFlags.getFlagsFromPositions(selectedIndices.toTypedArray()) + screenModel.migrateFlags.set(newValue) + screenModel.migrateManga(oldManga, newManga, true) + launchUI { + onPopScreen() + } + } + },) { + Text(text = stringResource(id = R.string.migrate)) + } + } + }, + ) +} + +class MigrateDialogScreenModel( + private val sourceManager: SourceManager = Injekt.get(), + private val updateManga: UpdateManga = Injekt.get(), + private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(), + private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(), + private val updateChapter: UpdateChapter = Injekt.get(), + private val getCategories: GetCategories = Injekt.get(), + private val setMangaCategories: SetMangaCategories = Injekt.get(), + private val getTracks: GetTracks = Injekt.get(), + private val insertTrack: InsertTrack = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val preferenceStore: PreferenceStore = Injekt.get(), +) : ScreenModel { + + val migrateFlags: Preference by lazy { + preferenceStore.getInt("migrate_flags", Int.MAX_VALUE) + } + + private val enhancedServices by lazy { Injekt.get().services.filterIsInstance() } + + suspend fun migrateManga(oldManga: Manga, newManga: Manga, replace: Boolean) { + val source = sourceManager.get(newManga.source) ?: return + val prevSource = sourceManager.get(oldManga.source) + + try { + val chapters = source.getChapterList(newManga.toSManga()) + + migrateMangaInternal( + oldSource = prevSource, + newSource = source, + oldManga = oldManga, + newManga = newManga, + sourceChapters = chapters, + replace = replace, + ) + } catch (e: Throwable) { + } + } + + private suspend fun migrateMangaInternal( + oldSource: Source?, + newSource: Source, + oldManga: Manga, + newManga: Manga, + sourceChapters: List, + replace: Boolean, + ) { + val flags = migrateFlags.get() + + val migrateChapters = MigrationFlags.hasChapters(flags) + val migrateCategories = MigrationFlags.hasCategories(flags) + val migrateTracks = MigrationFlags.hasTracks(flags) + val migrateCustomCover = MigrationFlags.hasCustomCover(flags) + + try { + syncChaptersWithSource.await(sourceChapters, newManga, newSource) + } catch (e: Exception) { + // Worst case, chapters won't be synced + } + + // Update chapters read, bookmark and dateFetch + if (migrateChapters) { + val prevMangaChapters = getChapterByMangaId.await(oldManga.id) + val mangaChapters = getChapterByMangaId.await(newManga.id) + + val maxChapterRead = prevMangaChapters + .filter { it.read } + .maxOfOrNull { it.chapterNumber } + + val updatedMangaChapters = mangaChapters.map { mangaChapter -> + var updatedChapter = mangaChapter + if (updatedChapter.isRecognizedNumber) { + val prevChapter = prevMangaChapters + .find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber } + + if (prevChapter != null) { + updatedChapter = updatedChapter.copy( + dateFetch = prevChapter.dateFetch, + bookmark = prevChapter.bookmark, + ) + } + + if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) { + updatedChapter = updatedChapter.copy(read = true) + } + } + + updatedChapter + } + + val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() } + updateChapter.awaitAll(chapterUpdates) + } + + // Update categories + if (migrateCategories) { + val categoryIds = getCategories.await(oldManga.id).map { it.id } + setMangaCategories.await(newManga.id, categoryIds) + } + + // Update track + if (migrateTracks) { + val tracks = getTracks.await(oldManga.id).mapNotNull { track -> + val updatedTrack = track.copy(mangaId = newManga.id) + + val service = enhancedServices + .firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) } + + if (service != null) { + service.migrateTrack(updatedTrack, newManga, newSource) + } else { + updatedTrack + } + } + insertTrack.awaitAll(tracks) + } + + if (replace) { + updateManga.await(MangaUpdate(oldManga.id, favorite = false, dateAdded = 0)) + } + + // Update custom cover (recheck if custom cover exists) + if (migrateCustomCover && oldManga.hasCustomCover()) { + @Suppress("BlockingMethodInNonBlockingContext") + coverCache.setCustomCoverToCache(newManga, coverCache.getCustomCoverFile(oldManga.id).inputStream()) + } + + updateManga.await( + MangaUpdate( + id = newManga.id, + favorite = true, + chapterFlags = oldManga.chapterFlags, + viewerFlags = oldManga.viewerFlags, + dateAdded = if (replace) oldManga.dateAdded else Date().time, + ), + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt new file mode 100644 index 000000000..ea4b39dcc --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt @@ -0,0 +1,93 @@ +package eu.kanade.tachiyomi.ui.browse.migration.search + +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.coroutineScope +import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.manga.interactor.GetManga +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItemResult +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class MigrateSearchScreenModel( + val mangaId: Long, + initialExtensionFilter: String = "", + preferences: BasePreferences = Injekt.get(), + private val sourcePreferences: SourcePreferences = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), + private val getManga: GetManga = Injekt.get(), +) : SearchScreenModel(MigrateSearchState()) { + + init { + extensionFilter = initialExtensionFilter + coroutineScope.launch { + val manga = getManga.await(mangaId)!! + + mutableState.update { + it.copy(manga = manga, searchQuery = manga.title) + } + + search(manga.title) + } + } + + val incognitoMode = preferences.incognitoMode() + val lastUsedSourceId = sourcePreferences.lastUsedSource() + + override fun getEnabledSources(): List { + val enabledLanguages = sourcePreferences.enabledLanguages().get() + val disabledSources = sourcePreferences.disabledSources().get() + val pinnedSources = sourcePreferences.pinnedSources().get() + + return sourceManager.getCatalogueSources() + .filter { it.lang in enabledLanguages } + .filterNot { "${it.id}" in disabledSources } + .sortedWith(compareBy({ "${it.id}" !in pinnedSources }, { "${it.name.lowercase()} (${it.lang})" })) + .sortedByDescending { it.id == state.value.manga!!.id } + } + + override fun updateSearchQuery(query: String?) { + mutableState.update { + it.copy(searchQuery = query) + } + } + + override fun updateItems(items: Map) { + mutableState.update { + it.copy(items = items) + } + } + + override fun getItems(): Map { + return mutableState.value.items + } + + fun setDialog(dialog: MigrateSearchDialog?) { + mutableState.update { + it.copy(dialog = dialog) + } + } +} + +sealed class MigrateSearchDialog { + data class Migrate(val manga: Manga) : MigrateSearchDialog() +} + +@Immutable +data class MigrateSearchState( + val manga: Manga? = null, + val searchQuery: String? = null, + val items: Map = emptyMap(), + val dialog: MigrateSearchDialog? = null, +) { + + val progress: Int = items.count { it.value !is GlobalSearchItemResult.Loading } + + val total: Int = items.size +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt deleted file mode 100644 index 551a66d55..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt +++ /dev/null @@ -1,154 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.migration.search - -import android.app.Dialog -import android.os.Bundle -import androidx.core.view.isVisible -import com.bluelinelabs.conductor.Controller -import com.bluelinelabs.conductor.RouterTransaction -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import eu.kanade.domain.manga.interactor.GetManga -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags -import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController -import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.util.system.getSerializableCompat -import kotlinx.coroutines.runBlocking -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class SearchController( - private var manga: Manga? = null, -) : GlobalSearchController(manga?.title) { - - constructor(mangaId: Long) : this( - runBlocking { - Injekt.get() - .await(mangaId) - }, - ) - - private var newManga: Manga? = null - - override fun createPresenter(): GlobalSearchPresenter { - return SearchPresenter( - initialQuery, - manga!!, - ) - } - - override fun onSaveInstanceState(outState: Bundle) { - outState.putSerializable(::manga.name, manga) - outState.putSerializable(::newManga.name, newManga) - super.onSaveInstanceState(outState) - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - manga = savedInstanceState.getSerializableCompat(::manga.name) - newManga = savedInstanceState.getSerializableCompat(::newManga.name) - } - - fun migrateManga(manga: Manga? = null, newManga: Manga?) { - manga ?: return - newManga ?: return - - (presenter as? SearchPresenter)?.migrateManga(manga, newManga, true) - } - - fun copyManga(manga: Manga? = null, newManga: Manga?) { - manga ?: return - newManga ?: return - - (presenter as? SearchPresenter)?.migrateManga(manga, newManga, false) - } - - override fun onMangaClick(manga: Manga) { - newManga = manga - val dialog = - MigrationDialog(this.manga, newManga, this) - dialog.targetController = this - dialog.showDialog(router) - } - - override fun onMangaLongClick(manga: Manga) { - // Call parent's default click listener - super.onMangaClick(manga) - } - - fun renderIsReplacingManga(isReplacingManga: Boolean, newManga: Manga?) { - binding.progress.isVisible = isReplacingManga - if (!isReplacingManga) { - router.popController(this) - if (newManga?.id != null) { - val newMangaController = RouterTransaction.with(MangaController(newManga.id)) - if (router.backstack.lastOrNull()?.controller is MangaController) { - // Replace old MangaController - router.replaceTopController(newMangaController) - } else { - // Push MangaController on top of MigrationController - router.pushController(newMangaController) - } - } - } - } - - class MigrationDialog(private val manga: Manga? = null, private val newManga: Manga? = null, private val callingController: Controller? = null) : DialogController() { - - @Suppress("DEPRECATION") - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val migrateFlags = ((targetController as SearchController).presenter as SearchPresenter).migrateFlags - val prefValue = migrateFlags.get() - val enabledFlagsPositions = MigrationFlags.getEnabledFlagsPositions(prefValue) - val items = MigrationFlags.titles(manga) - .map { resources?.getString(it) } - .toTypedArray() - val selected = items - .mapIndexed { i, _ -> enabledFlagsPositions.contains(i) } - .toBooleanArray() - - return MaterialAlertDialogBuilder(activity!!) - .setTitle(R.string.migration_dialog_what_to_include) - .setMultiChoiceItems(items, selected) { _, which, checked -> - selected[which] = checked - } - .setPositiveButton(R.string.migrate) { _, _ -> - // Save current settings for the next time - val selectedIndices = mutableListOf() - selected.forEachIndexed { i, b -> if (b) selectedIndices.add(i) } - val newValue = MigrationFlags.getFlagsFromPositions(selectedIndices.toTypedArray()) - migrateFlags.set(newValue) - - if (callingController != null) { - if (callingController.javaClass == SourceSearchController::class.java) { - router.popController(callingController) - } - } - (targetController as? SearchController)?.migrateManga(manga, newManga) - } - .setNegativeButton(R.string.copy) { _, _ -> - if (callingController != null) { - if (callingController.javaClass == SourceSearchController::class.java) { - router.popController(callingController) - } - } - (targetController as? SearchController)?.copyManga(manga, newManga) - } - .setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ -> - dismissDialog() - router.pushController(MangaController(newManga!!.id)) - } - .create() - } - } - - override fun onTitleClick(source: CatalogueSource) { - presenter.sourcePreferences.lastUsedSource().set(source.id) - - router.pushController(SourceSearchController(manga, source, presenter.query)) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchPresenter.kt deleted file mode 100644 index 7ec6cb4e9..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchPresenter.kt +++ /dev/null @@ -1,204 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.migration.search - -import android.os.Bundle -import com.jakewharton.rxrelay.BehaviorRelay -import eu.kanade.domain.category.interactor.GetCategories -import eu.kanade.domain.category.interactor.SetMangaCategories -import eu.kanade.domain.chapter.interactor.GetChapterByMangaId -import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource -import eu.kanade.domain.chapter.interactor.UpdateChapter -import eu.kanade.domain.chapter.model.toChapterUpdate -import eu.kanade.domain.manga.interactor.UpdateManga -import eu.kanade.domain.manga.model.Manga -import eu.kanade.domain.manga.model.MangaUpdate -import eu.kanade.domain.manga.model.hasCustomCover -import eu.kanade.domain.track.interactor.GetTracks -import eu.kanade.domain.track.interactor.InsertTrack -import eu.kanade.tachiyomi.core.preference.Preference -import eu.kanade.tachiyomi.core.preference.PreferenceStore -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.track.EnhancedTrackService -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags -import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchCardItem -import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItem -import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter -import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.withUIContext -import eu.kanade.tachiyomi.util.system.toast -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy -import java.util.Date - -class SearchPresenter( - initialQuery: String? = "", - private val manga: Manga, - private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(), - private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(), - private val updateChapter: UpdateChapter = Injekt.get(), - private val updateManga: UpdateManga = Injekt.get(), - private val getCategories: GetCategories = Injekt.get(), - private val getTracks: GetTracks = Injekt.get(), - private val insertTrack: InsertTrack = Injekt.get(), - private val setMangaCategories: SetMangaCategories = Injekt.get(), - preferenceStore: PreferenceStore = Injekt.get(), -) : GlobalSearchPresenter(initialQuery) { - - private val replacingMangaRelay = BehaviorRelay.create>() - private val coverCache: CoverCache by injectLazy() - private val enhancedServices by lazy { Injekt.get().services.filterIsInstance() } - - val migrateFlags: Preference by lazy { - preferenceStore.getInt("migrate_flags", Int.MAX_VALUE) - } - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - replacingMangaRelay.subscribeLatestCache( - { controller, (isReplacingManga, newManga) -> - (controller as? SearchController)?.renderIsReplacingManga(isReplacingManga, newManga) - }, - ) - } - - override fun getEnabledSources(): List { - // Put the source of the selected manga at the top - return super.getEnabledSources() - .sortedByDescending { it.id == manga.source } - } - - override fun createCatalogueSearchItem(source: CatalogueSource, results: List?): GlobalSearchItem { - // Set the catalogue search item as highlighted if the source matches that of the selected manga - return GlobalSearchItem(source, results, source.id == manga.source) - } - - override suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga { - val localManga = super.networkToLocalManga(sManga, sourceId) - // For migration, displayed title should always match source rather than local DB - return localManga.copy(title = sManga.title) - } - - fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean) { - val source = sourceManager.get(manga.source) ?: return - val prevSource = sourceManager.get(prevManga.source) - - replacingMangaRelay.call(Pair(true, null)) - - presenterScope.launchIO { - try { - val chapters = source.getChapterList(manga.toSManga()) - - migrateMangaInternal(prevSource, source, chapters, prevManga, manga, replace) - } catch (e: Throwable) { - withUIContext { view?.applicationContext?.toast(e.message) } - } - - withUIContext { replacingMangaRelay.call(Pair(false, manga)) } - } - } - - private suspend fun migrateMangaInternal( - prevSource: Source?, - source: Source, - sourceChapters: List, - prevManga: Manga, - manga: Manga, - replace: Boolean, - ) { - val flags = migrateFlags.get() - - val migrateChapters = MigrationFlags.hasChapters(flags) - val migrateCategories = MigrationFlags.hasCategories(flags) - val migrateTracks = MigrationFlags.hasTracks(flags) - val migrateCustomCover = MigrationFlags.hasCustomCover(flags) - - try { - syncChaptersWithSource.await(sourceChapters, manga, source) - } catch (e: Exception) { - // Worst case, chapters won't be synced - } - - // Update chapters read, bookmark and dateFetch - if (migrateChapters) { - val prevMangaChapters = getChapterByMangaId.await(prevManga.id) - val mangaChapters = getChapterByMangaId.await(manga.id) - - val maxChapterRead = prevMangaChapters - .filter { it.read } - .maxOfOrNull { it.chapterNumber } - - val updatedMangaChapters = mangaChapters.map { mangaChapter -> - var updatedChapter = mangaChapter - if (updatedChapter.isRecognizedNumber) { - val prevChapter = prevMangaChapters - .find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber } - - if (prevChapter != null) { - updatedChapter = updatedChapter.copy( - dateFetch = prevChapter.dateFetch, - bookmark = prevChapter.bookmark, - ) - } - - if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) { - updatedChapter = updatedChapter.copy(read = true) - } - } - - updatedChapter - } - - val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() } - updateChapter.awaitAll(chapterUpdates) - } - - // Update categories - if (migrateCategories) { - val categoryIds = getCategories.await(prevManga.id).map { it.id } - setMangaCategories.await(manga.id, categoryIds) - } - - // Update track - if (migrateTracks) { - val tracks = getTracks.await(prevManga.id).mapNotNull { track -> - val updatedTrack = track.copy(mangaId = manga.id) - - val service = enhancedServices - .firstOrNull { it.isTrackFrom(updatedTrack, prevManga, prevSource) } - - if (service != null) { - service.migrateTrack(updatedTrack, manga, source) - } else { - updatedTrack - } - } - insertTrack.awaitAll(tracks) - } - - if (replace) { - updateManga.await(MangaUpdate(prevManga.id, favorite = false, dateAdded = 0)) - } - - // Update custom cover (recheck if custom cover exists) - if (migrateCustomCover && prevManga.hasCustomCover()) { - @Suppress("BlockingMethodInNonBlockingContext") - coverCache.setCustomCoverToCache(manga, coverCache.getCustomCoverFile(prevManga.id).inputStream()) - } - - updateManga.await( - MangaUpdate( - id = manga.id, - favorite = true, - chapterFlags = prevManga.chapterFlags, - viewerFlags = prevManga.viewerFlags, - dateAdded = if (replace) prevManga.dateAdded else Date().time, - ), - ) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt index cc5854ba7..5c2d15a04 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt @@ -3,12 +3,19 @@ package eu.kanade.tachiyomi.ui.browse.migration.search import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.core.os.bundleOf import eu.kanade.domain.manga.model.Manga import eu.kanade.presentation.browse.SourceSearchScreen +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.base.controller.setRoot +import eu.kanade.tachiyomi.ui.browse.BrowseController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter +import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.util.system.getSerializableCompat @@ -25,7 +32,6 @@ class SourceSearchController( ) private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY) - private var newManga: Manga? = null @Composable override fun ComposeContent() { @@ -34,11 +40,7 @@ class SourceSearchController( navigateUp = { router.popCurrentController() }, onFabClick = { filterSheet?.show() }, onMangaClick = { - newManga = it - val searchController = router.backstack.findLast { it.controller.javaClass == SearchController::class.java }?.controller as SearchController? - val dialog = SearchController.MigrationDialog(oldManga, newManga, this) - dialog.targetController = searchController - dialog.showDialog(router) + presenter.dialog = BrowseSourcePresenter.Dialog.Migrate(it) }, onWebViewClick = f@{ val source = presenter.source as? HttpSource ?: return@f @@ -49,6 +51,25 @@ class SourceSearchController( }, ) + when (val dialog = presenter.dialog) { + is BrowseSourcePresenter.Dialog.Migrate -> { + MigrateDialog( + oldManga = oldManga!!, + newManga = dialog.newManga, + // TODO: Move screen model down into Dialog when this screen is using Voyager + screenModel = remember { MigrateDialogScreenModel() }, + onDismissRequest = { presenter.dialog = null }, + onClickTitle = { router.pushController(MangaController(dialog.newManga.id)) }, + onPopScreen = { + // TODO: Push to manga screen and remove this and the previous screen when it moves to Voyager + router.setRoot(BrowseController(toExtensions = false), R.id.nav_browse) + router.pushController(MangaController(dialog.newManga.id)) + }, + ) + } + else -> {} + } + LaunchedEffect(presenter.filters) { initFilterSheet() } 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 b58a372b0..0447a8f8d 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 @@ -81,6 +81,8 @@ open class BrowseSourceController(bundle: Bundle) : val onDismissRequest = { presenter.dialog = null } when (val dialog = presenter.dialog) { + null -> {} + is Dialog.Migrate -> {} is Dialog.AddDuplicateManga -> { DuplicateMangaDialog( onDismissRequest = onDismissRequest, @@ -111,7 +113,6 @@ open class BrowseSourceController(bundle: Bundle) : }, ) } - null -> {} } BackHandler(onBack = ::navigateUp) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt index 98992f195..179eb375d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt @@ -334,6 +334,7 @@ open class BrowseSourcePresenter( val manga: Manga, val initialSelection: List>, ) : Dialog() + data class Migrate(val newManga: Manga) : Dialog() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchAdapter.kt deleted file mode 100644 index d0fc3e29c..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchAdapter.kt +++ /dev/null @@ -1,79 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.globalsearch - -import android.os.Bundle -import android.os.Parcelable -import android.util.SparseArray -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.source.CatalogueSource - -/** - * Adapter that holds the search cards. - * - * @param controller instance of [GlobalSearchController]. - */ -class GlobalSearchAdapter(val controller: GlobalSearchController) : - FlexibleAdapter(null, controller, true) { - - val titleClickListener: OnTitleClickListener = controller - - /** - * Bundle where the view state of the holders is saved. - */ - private var bundle = Bundle() - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { - super.onBindViewHolder(holder, position, payloads) - restoreHolderState(holder) - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - super.onViewRecycled(holder) - saveHolderState(holder, bundle) - } - - override fun onSaveInstanceState(outState: Bundle) { - val holdersBundle = Bundle() - allBoundViewHolders.forEach { saveHolderState(it, holdersBundle) } - outState.putBundle(HOLDER_BUNDLE_KEY, holdersBundle) - super.onSaveInstanceState(outState) - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!! - } - - /** - * Saves the view state of the given holder. - * - * @param holder The holder to save. - * @param outState The bundle where the state is saved. - */ - private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) { - val key = "holder_${holder.bindingAdapterPosition}" - val holderState = SparseArray() - holder.itemView.saveHierarchyState(holderState) - outState.putSparseParcelableArray(key, holderState) - } - - /** - * Restores the view state of the given holder. - * - * @param holder The holder to restore. - */ - private fun restoreHolderState(holder: RecyclerView.ViewHolder) { - val key = "holder_${holder.bindingAdapterPosition}" - val holderState = bundle.getSparseParcelableArray(key) - if (holderState != null) { - holder.itemView.restoreHierarchyState(holderState) - bundle.remove(key) - } - } - - interface OnTitleClickListener { - fun onTitleClick(source: CatalogueSource) - } -} - -private const val HOLDER_BUNDLE_KEY = "holder_bundle" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardAdapter.kt deleted file mode 100644 index 3c620a6ff..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardAdapter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.globalsearch - -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.domain.manga.model.Manga - -/** - * Adapter that holds the manga items from search results. - * - * @param controller instance of [GlobalSearchController]. - */ -class GlobalSearchCardAdapter(controller: GlobalSearchController) : - FlexibleAdapter(null, controller, true) { - - /** - * Listen for browse item clicks. - */ - val mangaClickListener: OnMangaClickListener = controller - - /** - * Listener which should be called when user clicks browse. - * Note: Should only be handled by [GlobalSearchController] - */ - interface OnMangaClickListener { - fun onMangaClick(manga: Manga) - fun onMangaLongClick(manga: Manga) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardHolder.kt deleted file mode 100644 index 4a5222063..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardHolder.kt +++ /dev/null @@ -1,58 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.globalsearch - -import android.view.View -import androidx.core.view.isVisible -import coil.dispose -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher -import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardItemBinding -import eu.kanade.tachiyomi.util.view.loadAutoPause - -class GlobalSearchCardHolder(view: View, adapter: GlobalSearchCardAdapter) : - FlexibleViewHolder(view, adapter) { - - private val binding = GlobalSearchControllerCardItemBinding.bind(view) - - init { - // Call onMangaClickListener when item is pressed. - itemView.setOnClickListener { - val item = adapter.getItem(bindingAdapterPosition) - if (item != null) { - adapter.mangaClickListener.onMangaClick(item.manga) - } - } - itemView.setOnLongClickListener { - val item = adapter.getItem(bindingAdapterPosition) - if (item != null) { - adapter.mangaClickListener.onMangaLongClick(item.manga) - } - true - } - } - - fun bind(manga: Manga) { - binding.card.clipToOutline = true - - // Set manga title - binding.title.text = manga.title - - // Set alpha of thumbnail. - binding.cover.alpha = if (manga.favorite) 0.3f else 1.0f - - // For rounded corners - binding.badges.clipToOutline = true - - // Set favorite badge - binding.favoriteText.isVisible = manga.favorite - - setImage(manga) - } - - fun setImage(manga: Manga) { - binding.cover.dispose() - binding.cover.loadAutoPause(manga) { - setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardItem.kt deleted file mode 100644 index 5296d2d6d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardItem.kt +++ /dev/null @@ -1,40 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.globalsearch - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.R - -class GlobalSearchCardItem(val manga: Manga) : AbstractFlexibleItem() { - - override fun getLayoutRes(): Int { - return R.layout.global_search_controller_card_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): GlobalSearchCardHolder { - return GlobalSearchCardHolder(view, adapter as GlobalSearchCardAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: GlobalSearchCardHolder, - position: Int, - payloads: List?, - ) { - holder.bind(manga) - } - - override fun equals(other: Any?): Boolean { - if (other is GlobalSearchCardItem) { - return manga.id == other.manga.id - } - return false - } - - override fun hashCode(): Int { - return manga.id.hashCode() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt index 02d8a9d9d..94c534112 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt @@ -1,226 +1,25 @@ package eu.kanade.tachiyomi.ui.browse.source.globalsearch -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.appcompat.widget.SearchView -import androidx.core.view.isVisible -import androidx.recyclerview.widget.LinearLayoutManager -import dev.chrisbanes.insetter.applyInsetter -import eu.kanade.domain.base.BasePreferences -import eu.kanade.domain.manga.model.Manga -import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.GlobalSearchControllerBinding -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController -import eu.kanade.tachiyomi.ui.manga.MangaController -import uy.kohesive.injekt.injectLazy +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import cafe.adriel.voyager.navigator.Navigator +import eu.kanade.presentation.util.LocalRouter +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 [GlobalSearchPresenter] - * [GlobalSearchCardAdapter.OnMangaClickListener] called when manga is clicked in global search - */ -open class GlobalSearchController( - protected val initialQuery: String? = null, - private val extensionFilter: String? = null, -) : SearchableNucleusController(), - GlobalSearchCardAdapter.OnMangaClickListener, - GlobalSearchAdapter.OnTitleClickListener { +class GlobalSearchController( + val searchQuery: String = "", + val extensionFilter: String = "", +) : BasicFullComposeController() { - private val preferences: BasePreferences by injectLazy() - private val sourcePreferences: SourcePreferences by injectLazy() - - /** - * Adapter containing search results grouped by lang. - */ - protected var adapter: GlobalSearchAdapter? = null - - /** - * Ref to the OptionsMenu.SearchItem created in onCreateOptionsMenu - */ - private var optionsMenuSearchItem: MenuItem? = null - - init { - setHasOptionsMenu(true) - } - - override fun createBinding(inflater: LayoutInflater) = GlobalSearchControllerBinding.inflate(inflater) - - override fun getTitle(): String? { - return presenter.query - } - - override fun createPresenter(): GlobalSearchPresenter { - return GlobalSearchPresenter(initialQuery, extensionFilter) - } - - /** - * Called when manga in global search is clicked, opens manga. - * - * @param manga clicked item containing manga information. - */ - override fun onMangaClick(manga: Manga) { - router.pushController(MangaController(manga.id, true)) - } - - /** - * Called when manga in global search is long clicked. - * - * @param manga clicked item containing manga information. - */ - override fun onMangaLongClick(manga: Manga) { - // Delegate to single click by default. - onMangaClick(manga) - } - - /** - * Adds items to the options menu. - * - * @param menu menu containing options. - * @param inflater used to load the menu xml. - */ - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - createOptionsMenu( - menu, - inflater, - R.menu.global_search, - R.id.action_search, - ) - - optionsMenuSearchItem = menu.findItem(R.id.action_search) - - // Focus search on launch from browse screen - if (initialQuery.isNullOrEmpty()) { - optionsMenuSearchItem?.expandActionView() + @Composable + override fun ComposeContent() { + CompositionLocalProvider(LocalRouter provides router) { + Navigator( + screen = GlobalSearchScreen( + searchQuery = searchQuery, + extensionFilter = extensionFilter, + ), + ) } } - - override fun onSearchMenuItemActionCollapse(item: MenuItem?) { - super.onSearchMenuItemActionCollapse(item) - // Close this screen if query is empty - // i.e. launch from browse screen and clicking the back button icon without making any search - if (presenter.query.isEmpty()) { - router.popCurrentController() - } - } - - override fun onSearchMenuItemActionExpand(item: MenuItem?) { - super.onSearchMenuItemActionExpand(item) - val searchView = optionsMenuSearchItem?.actionView as SearchView - searchView.onActionViewExpanded() // Required to show the query in the view - - if (nonSubmittedQuery.isBlank()) { - searchView.setQuery(presenter.query, false) - } - } - - override fun onSearchViewQueryTextSubmit(query: String?) { - presenter.search(query ?: "") - optionsMenuSearchItem?.collapseActionView() - setTitle() // Update toolbar title - } - - /** - * Called when the view is created - * - * @param view view of controller - */ - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - binding.recycler.applyInsetter { - type(navigationBars = true) { - padding() - } - } - - adapter = GlobalSearchAdapter(this) - - // Create recycler and set adapter. - binding.recycler.layoutManager = LinearLayoutManager(view.context) - binding.recycler.adapter = adapter - } - - override fun onDestroyView(view: View) { - adapter = null - super.onDestroyView(view) - } - - override fun onSaveViewState(view: View, outState: Bundle) { - super.onSaveViewState(view, outState) - adapter?.onSaveInstanceState(outState) - } - - override fun onRestoreViewState(view: View, savedViewState: Bundle) { - super.onRestoreViewState(view, savedViewState) - adapter?.onRestoreInstanceState(savedViewState) - } - - /** - * Returns the view holder for the given manga. - * - * @param source used to find holder containing source - * @return the holder of the manga or null if it's not bound. - */ - private fun getHolder(source: CatalogueSource): GlobalSearchHolder? { - val adapter = adapter ?: return null - - adapter.allBoundViewHolders.forEach { holder -> - val item = adapter.getItem(holder.bindingAdapterPosition) - if (item != null && source.id == item.source.id) { - return holder as GlobalSearchHolder - } - } - - return null - } - - /** - * Add search result to adapter. - * - * @param searchResult result of search. - */ - fun setItems(searchResult: List) { - if (searchResult.isEmpty() && sourcePreferences.searchPinnedSourcesOnly().get()) { - binding.emptyView.show(R.string.no_pinned_sources) - } else { - binding.emptyView.hide() - } - - adapter?.updateDataSet(searchResult) - - val progress = searchResult.mapNotNull { it.results }.size.toDouble() / searchResult.size - if (progress < 1) { - binding.progressBar.isVisible = true - binding.progressBar.progress = (progress * 100).toInt() - } else { - binding.progressBar.isVisible = false - } - } - - /** - * Called from the presenter when a manga is initialized. - * - * @param manga the initialized manga. - */ - fun onMangaInitialized(source: CatalogueSource, manga: Manga) { - getHolder(source)?.setImage(manga) - } - - /** - * Opens a catalogue with the given search. - */ - override fun onTitleClick(source: CatalogueSource) { - if (!preferences.incognitoMode().get()) { - sourcePreferences.lastUsedSource().set(source.id) - } - router.pushController(BrowseSourceController(source, presenter.query)) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchHolder.kt deleted file mode 100644 index 7bfd9327b..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchHolder.kt +++ /dev/null @@ -1,110 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.globalsearch - -import android.view.View -import androidx.core.view.isVisible -import androidx.recyclerview.widget.LinearLayoutManager -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.databinding.GlobalSearchControllerCardBinding -import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.util.system.LocaleHelper - -/** - * Holder that binds the [GlobalSearchItem] containing catalogue cards. - * - * @param view view of [GlobalSearchItem] - * @param adapter instance of [GlobalSearchAdapter] - */ -class GlobalSearchHolder(view: View, val adapter: GlobalSearchAdapter) : - FlexibleViewHolder(view, adapter) { - - private val binding = GlobalSearchControllerCardBinding.bind(view) - - /** - * Adapter containing manga from search results. - */ - private val mangaAdapter = GlobalSearchCardAdapter(adapter.controller) - - private var lastBoundResults: List? = null - - init { - // Set layout horizontal. - binding.recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false) - binding.recycler.adapter = mangaAdapter - - binding.titleWrapper.setOnClickListener { - adapter.getItem(bindingAdapterPosition)?.let { - adapter.titleClickListener.onTitleClick(it.source) - } - } - } - - /** - * Show the loading of source search result. - * - * @param item item of card. - */ - fun bind(item: GlobalSearchItem) { - val source = item.source - val results = item.results - - val titlePrefix = if (item.highlighted) "▶ " else "" - - binding.title.text = titlePrefix + source.name - binding.subtitle.isVisible = source !is LocalSource - binding.subtitle.text = LocaleHelper.getDisplayName(source.lang) - - when { - results == null -> { - binding.progress.isVisible = true - showResultsHolder() - } - results.isEmpty() -> { - binding.progress.isVisible = false - showNoResults() - } - else -> { - binding.progress.isVisible = false - showResultsHolder() - } - } - if (results !== lastBoundResults) { - mangaAdapter.updateDataSet(results) - lastBoundResults = results - } - } - - /** - * Called from the presenter when a manga is initialized. - * - * @param manga the initialized manga. - */ - fun setImage(manga: Manga) { - getHolder(manga)?.setImage(manga) - } - - /** - * Returns the view holder for the given manga. - * - * @param manga the manga to find. - * @return the holder of the manga or null if it's not bound. - */ - private fun getHolder(manga: Manga): GlobalSearchCardHolder? { - mangaAdapter.allBoundViewHolders.forEach { holder -> - val item = mangaAdapter.getItem(holder.bindingAdapterPosition) - if (item != null && item.manga.id == manga.id) { - return holder as GlobalSearchCardHolder - } - } - - return null - } - - private fun showResultsHolder() { - binding.noResultsFound.isVisible = false - } - - private fun showNoResults() { - binding.noResultsFound.isVisible = true - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchItem.kt deleted file mode 100644 index b2df301c4..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchItem.kt +++ /dev/null @@ -1,71 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.globalsearch - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.source.CatalogueSource - -/** - * Item that contains search result information. - * - * @param source the source for the search results. - * @param results the search results. - * @param highlighted whether this search item should be highlighted/marked in the catalogue search view. - */ -class GlobalSearchItem(val source: CatalogueSource, val results: List?, val highlighted: Boolean = false) : - AbstractFlexibleItem() { - - /** - * Set view. - * - * @return id of view - */ - override fun getLayoutRes(): Int { - return R.layout.global_search_controller_card - } - - /** - * Create view holder (see [GlobalSearchAdapter]. - * - * @return holder of view. - */ - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): GlobalSearchHolder { - return GlobalSearchHolder(view, adapter as GlobalSearchAdapter) - } - - /** - * Bind item to view. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: GlobalSearchHolder, - position: Int, - payloads: List?, - ) { - holder.bind(this) - } - - /** - * Used to check if two items are equal. - * - * @return items are equal? - */ - override fun equals(other: Any?): Boolean { - if (other is GlobalSearchItem) { - return source.id == other.source.id - } - return false - } - - /** - * Return hash code of item. - * - * @return hashcode - */ - override fun hashCode(): Int { - return source.id.toInt() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt deleted file mode 100644 index 6f8c11da2..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt +++ /dev/null @@ -1,265 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.globalsearch - -import android.os.Bundle -import eu.kanade.domain.base.BasePreferences -import eu.kanade.domain.manga.interactor.NetworkToLocalManga -import eu.kanade.domain.manga.interactor.UpdateManga -import eu.kanade.domain.manga.model.toDbManga -import eu.kanade.domain.manga.model.toDomainManga -import eu.kanade.domain.manga.model.toMangaUpdate -import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.toDomainManga -import eu.kanade.tachiyomi.extension.ExtensionManager -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter -import eu.kanade.tachiyomi.util.lang.runAsObservable -import eu.kanade.tachiyomi.util.system.logcat -import kotlinx.coroutines.runBlocking -import logcat.LogPriority -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers -import rx.subjects.PublishSubject -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy -import eu.kanade.domain.manga.model.Manga as DomainManga - -open class GlobalSearchPresenter( - private val initialQuery: String? = "", - private val initialExtensionFilter: String? = null, - val sourceManager: SourceManager = Injekt.get(), - val preferences: BasePreferences = Injekt.get(), - val sourcePreferences: SourcePreferences = Injekt.get(), - private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), - private val updateManga: UpdateManga = Injekt.get(), -) : BasePresenter() { - - /** - * Enabled sources. - */ - val sources by lazy { getSourcesToQuery() } - - /** - * Fetches the different sources by user settings. - */ - private var fetchSourcesSubscription: Subscription? = null - - /** - * Subject which fetches image of given manga. - */ - private val fetchImageSubject = PublishSubject.create, Source>>() - - /** - * Subscription for fetching images of manga. - */ - private var fetchImageSubscription: Subscription? = null - - private val extensionManager: ExtensionManager by injectLazy() - - private var extensionFilter: String? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - extensionFilter = savedState?.getString(GlobalSearchPresenter::extensionFilter.name) - ?: initialExtensionFilter - - // Perform a search with previous or initial state - search( - savedState?.getString(BrowseSourcePresenter::query.name) - ?: initialQuery.orEmpty(), - ) - } - - override fun onDestroy() { - fetchSourcesSubscription?.unsubscribe() - fetchImageSubscription?.unsubscribe() - super.onDestroy() - } - - override fun onSave(state: Bundle) { - state.putString(BrowseSourcePresenter::query.name, query) - state.putString(GlobalSearchPresenter::extensionFilter.name, extensionFilter) - super.onSave(state) - } - - /** - * Returns a list of enabled sources ordered by language and name, with pinned sources - * prioritized. - * - * @return list containing enabled sources. - */ - protected open fun getEnabledSources(): List { - val languages = sourcePreferences.enabledLanguages().get() - val disabledSourceIds = sourcePreferences.disabledSources().get() - val pinnedSourceIds = sourcePreferences.pinnedSources().get() - - return sourceManager.getCatalogueSources() - .filter { it.lang in languages } - .filterNot { it.id.toString() in disabledSourceIds } - .sortedWith(compareBy({ it.id.toString() !in pinnedSourceIds }, { "${it.name.lowercase()} (${it.lang})" })) - } - - private fun getSourcesToQuery(): List { - val filter = extensionFilter - val enabledSources = getEnabledSources() - var filteredSources: List? = null - - if (!filter.isNullOrEmpty()) { - filteredSources = extensionManager.installedExtensionsFlow.value - .filter { it.pkgName == filter } - .flatMap { it.sources } - .filter { it in enabledSources } - .filterIsInstance() - } - - if (filteredSources != null && filteredSources.isNotEmpty()) { - return filteredSources - } - - val onlyPinnedSources = sourcePreferences.searchPinnedSourcesOnly().get() - val pinnedSourceIds = sourcePreferences.pinnedSources().get() - - return enabledSources - .filter { if (onlyPinnedSources) it.id.toString() in pinnedSourceIds else true } - } - - /** - * Creates a catalogue search item - */ - protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List?): GlobalSearchItem { - return GlobalSearchItem(source, results) - } - - /** - * Initiates a search for manga per catalogue. - * - * @param query query on which to search. - */ - fun search(query: String) { - // Return if there's nothing to do - if (this.query == query) return - - // Update query - this.query = query - - // Create image fetch subscription - initializeFetchImageSubscription() - - // Create items with the initial state - val initialItems = sources.map { createCatalogueSearchItem(it, null) } - var items = initialItems - - val pinnedSourceIds = sourcePreferences.pinnedSources().get() - - fetchSourcesSubscription?.unsubscribe() - fetchSourcesSubscription = Observable.from(sources) - .flatMap( - { source -> - Observable.defer { source.fetchSearchManga(1, query, source.getFilterList()) } - .subscribeOn(Schedulers.io()) - .onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions - .map { it.mangas } - .map { list -> list.map { runBlocking { networkToLocalManga(it, source.id) } } } // Convert to local manga - .doOnNext { fetchImage(it, source) } // Load manga covers - .map { list -> createCatalogueSearchItem(source, list.map { GlobalSearchCardItem(it) }) } - }, - 5, - ) - .observeOn(AndroidSchedulers.mainThread()) - // Update matching source with the obtained results - .map { result -> - items - .map { item -> if (item.source == result.source) result else item } - .sortedWith( - compareBy( - // Bubble up sources that actually have results - { it.results.isNullOrEmpty() }, - // Same as initial sort, i.e. pinned first then alphabetically - { it.source.id.toString() !in pinnedSourceIds }, - { "${it.source.name.lowercase()} (${it.source.lang})" }, - ), - ) - } - // Update current state - .doOnNext { items = it } - // Deliver initial state - .startWith(initialItems) - .subscribeLatestCache( - { view, manga -> - view.setItems(manga) - }, - { _, error -> - logcat(LogPriority.ERROR, error) - }, - ) - } - - /** - * Initialize a list of manga. - * - * @param manga the list of manga to initialize. - */ - private fun fetchImage(manga: List, source: Source) { - fetchImageSubject.onNext(Pair(manga, source)) - } - - /** - * Subscribes to the initializer of manga details and updates the view if needed. - */ - private fun initializeFetchImageSubscription() { - fetchImageSubscription?.unsubscribe() - fetchImageSubscription = fetchImageSubject.observeOn(Schedulers.io()) - .flatMap { (first, source) -> - Observable.from(first) - .filter { it.thumbnailUrl == null && !it.initialized } - .map { Pair(it, source) } - .concatMap { runAsObservable { getMangaDetails(it.first.toDbManga(), it.second) } } - .map { Pair(source as CatalogueSource, it) } - } - .onBackpressureBuffer() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { (source, manga) -> - @Suppress("DEPRECATION") - view?.onMangaInitialized(source, manga.toDomainManga()!!) - }, - { error -> - logcat(LogPriority.ERROR, error) - }, - ) - } - - /** - * Initializes the given manga. - * - * @param manga the manga to initialize. - * @return The initialized manga. - */ - private suspend fun getMangaDetails(manga: Manga, source: Source): Manga { - val networkManga = source.getMangaDetails(manga.copy()) - manga.copyFrom(networkManga) - manga.initialized = true - updateManga.await(manga.toDomainManga()!!.toMangaUpdate()) - return manga - } - - /** - * Returns a manga from the database for the given manga from network. It creates a new entry - * if the manga is not yet in the database. - * - * @param sManga the manga from the source. - * @return a manga from the database. - */ - protected open suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): DomainManga { - return networkToLocalManga.await(sManga.toDomainManga(sourceId)) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt new file mode 100644 index 000000000..48eed0a69 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt @@ -0,0 +1,53 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.browse.GlobalSearchScreen +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.ui.manga.MangaController + +class GlobalSearchScreen( + val searchQuery: String = "", + val extensionFilter: String = "", +) : Screen { + + @Composable + override fun Content() { + val router = LocalRouter.currentOrThrow + + val screenModel = rememberScreenModel { + GlobalSearchScreenModel( + initialQuery = searchQuery, + initialExtensionFilter = extensionFilter, + ) + } + val state by screenModel.state.collectAsState() + + GlobalSearchScreen( + state = state, + navigateUp = router::popCurrentController, + onChangeSearchQuery = screenModel::updateSearchQuery, + onSearch = screenModel::search, + getManga = { source, manga -> + screenModel.getManga( + source = source, + initialManga = manga, + ) + }, + onClickSource = { + if (!screenModel.incognitoMode.get()) { + screenModel.lastUsedSourceId.set(it.id) + } + router.pushController(BrowseSourceController(it, state.searchQuery)) + }, + onClickItem = { router.pushController(MangaController(it.id, true)) }, + onLongClickItem = { router.pushController(MangaController(it.id, true)) }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreenModel.kt new file mode 100644 index 000000000..020a3331e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreenModel.kt @@ -0,0 +1,83 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import androidx.compose.runtime.Immutable +import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.SourceManager +import kotlinx.coroutines.flow.update +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class GlobalSearchScreenModel( + initialQuery: String = "", + initialExtensionFilter: String = "", + preferences: BasePreferences = Injekt.get(), + private val sourcePreferences: SourcePreferences = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), +) : SearchScreenModel(GlobalSearchState(searchQuery = initialQuery)) { + + val incognitoMode = preferences.incognitoMode() + val lastUsedSourceId = sourcePreferences.lastUsedSource() + + init { + extensionFilter = initialExtensionFilter + if (initialQuery.isNotBlank() || initialExtensionFilter.isNotBlank()) { + search(initialQuery) + } + } + + override fun getEnabledSources(): List { + val enabledLanguages = sourcePreferences.enabledLanguages().get() + val disabledSources = sourcePreferences.disabledSources().get() + val pinnedSources = sourcePreferences.pinnedSources().get() + + return sourceManager.getCatalogueSources() + .filter { it.lang in enabledLanguages } + .filterNot { "${it.id}" in disabledSources } + .sortedWith(compareBy({ "${it.id}" !in pinnedSources }, { "${it.name.lowercase()} (${it.lang})" })) + } + + override fun updateSearchQuery(query: String?) { + mutableState.update { + it.copy(searchQuery = query) + } + } + + override fun updateItems(items: Map) { + mutableState.update { + it.copy(items = items) + } + } + + override fun getItems(): Map { + return mutableState.value.items + } +} + +sealed class GlobalSearchItemResult { + object Loading : GlobalSearchItemResult() + + data class Error( + val throwable: Throwable, + ) : GlobalSearchItemResult() + + data class Success( + val result: List, + ) : GlobalSearchItemResult() { + val isEmpty: Boolean + get() = result.isEmpty() + } +} + +@Immutable +data class GlobalSearchState( + val searchQuery: String? = null, + val items: Map = emptyMap(), +) { + + val progress: Int = items.count { it.value !is GlobalSearchItemResult.Loading } + + val total: Int = items.size +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt new file mode 100644 index 000000000..1653f243c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt @@ -0,0 +1,167 @@ +package eu.kanade.tachiyomi.ui.browse.source.globalsearch + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope +import eu.kanade.domain.manga.interactor.GetManga +import eu.kanade.domain.manga.interactor.NetworkToLocalManga +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.toDomainManga +import eu.kanade.domain.manga.model.toMangaUpdate +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.util.lang.awaitSingle +import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.lang.withNonCancellableContext +import eu.kanade.tachiyomi.util.system.logcat +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.Executors + +abstract class SearchScreenModel( + initialState: T, + private val sourcePreferences: SourcePreferences = Injekt.get(), + private val extensionManager: ExtensionManager = Injekt.get(), + private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), + private val getManga: GetManga = Injekt.get(), + private val updateManga: UpdateManga = Injekt.get(), +) : StateScreenModel(initialState) { + + private val coroutineDispatcher = Executors.newFixedThreadPool(5).asCoroutineDispatcher() + + protected var query: String? = null + protected lateinit var extensionFilter: String + + private val sources by lazy { getSelectedSources() } + + @Composable + fun getManga(source: CatalogueSource, initialManga: Manga): State { + return produceState(initialValue = initialManga) { + getManga.subscribe(initialManga.url, initialManga.source) + .collectLatest { manga -> + if (manga == null) return@collectLatest + withIOContext { + initializeManga(source, manga) + } + value = manga + } + } + } + + /** + * Initialize a manga. + * + * @param source to interact with + * @param manga to initialize. + */ + private suspend fun initializeManga(source: CatalogueSource, manga: Manga) { + if (manga.thumbnailUrl != null || manga.initialized) return + withNonCancellableContext { + try { + val networkManga = source.getMangaDetails(manga.toSManga()) + val updatedManga = manga.copyFrom(networkManga) + .copy(initialized = true) + + updateManga.await(updatedManga.toMangaUpdate()) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + } + } + } + + abstract fun getEnabledSources(): List + + fun getSelectedSources(): List { + val filter = extensionFilter + + val enabledSources = getEnabledSources() + + if (filter.isEmpty()) { + val shouldSearchPinnedOnly = sourcePreferences.searchPinnedSourcesOnly().get() + val pinnedSources = sourcePreferences.pinnedSources().get() + + return enabledSources.filter { + if (shouldSearchPinnedOnly) { + "${it.id}" in pinnedSources + } else { + true + } + } + } + + return extensionManager.installedExtensionsFlow.value + .filter { it.pkgName == filter } + .flatMap { it.sources } + .filter { it in enabledSources } + .filterIsInstance() + } + + abstract fun updateSearchQuery(query: String?) + + abstract fun updateItems(items: Map) + + abstract fun getItems(): Map + + fun getAndUpdateItems(function: (Map) -> Map) { + updateItems(function(getItems())) + } + + fun search(query: String) { + if (this.query == query) return + + this.query = query + + val initialItems = getSelectedSources().associateWith { GlobalSearchItemResult.Loading } + updateItems(initialItems) + + val pinnedSources = sourcePreferences.pinnedSources().get() + + val comparator = { mutableMap: MutableMap -> + compareBy( + { mutableMap[it] is GlobalSearchItemResult.Success }, + { "${it.id}" in pinnedSources }, + { "${it.name.lowercase()} (${it.lang})" }, + ) + } + + coroutineScope.launch { + sources.forEach { source -> + val page = try { + withContext(coroutineDispatcher) { + source.fetchSearchManga(1, query, source.getFilterList()).awaitSingle() + } + } catch (e: Exception) { + getAndUpdateItems { items -> + val mutableMap = items.toMutableMap() + mutableMap[source] = GlobalSearchItemResult.Error(throwable = e) + mutableMap.toSortedMap(comparator(mutableMap)) + mutableMap.toMap() + } + return@forEach + } + + val titles = page.mangas.map { + withIOContext { + networkToLocalManga.await(it.toDomainManga(source.id)) + } + } + + getAndUpdateItems { items -> + val mutableMap = items.toMutableMap() + mutableMap[source] = GlobalSearchItemResult.Success(titles) + mutableMap.toSortedMap(comparator(mutableMap)) + mutableMap.toMap() + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt index 9f8f76bc9..1b0797e8b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt @@ -178,7 +178,7 @@ object LibraryScreen : Screen { }, onRefresh = onClickRefresh, onGlobalSearchClicked = { - router.pushController(GlobalSearchController(screenModel.state.value.searchQuery)) + router.pushController(GlobalSearchController(screenModel.state.value.searchQuery ?: "")) }, getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) }, getDisplayModeForPage = { state.categories[it].display }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index df4ec4570..98d081bca 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -461,7 +461,7 @@ class MainActivity : BaseActivity() { if (router.backstackSize > 1) { router.popToRoot() } - router.pushController(GlobalSearchController(query, filter)) + router.pushController(GlobalSearchController(query, filter ?: "")) } } else -> { 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 05ae69352..7c74624aa 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 @@ -50,7 +50,7 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.isLocalOrStub import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController +import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.category.CategoryScreen @@ -113,7 +113,7 @@ class MangaScreen( onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource }, onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, onEditCategoryClicked = screenModel::promptChangeCategories.takeIf { successState.manga.favorite }, - onMigrateClicked = { migrateManga(router, screenModel.manga!!) }.takeIf { successState.manga.favorite }, + onMigrateClicked = { navigator.push(MigrateSearchScreen(successState.manga.id)) }.takeIf { successState.manga.favorite }, onMultiBookmarkClicked = screenModel::bookmarkChapters, onMultiMarkAsReadClicked = screenModel::markChaptersRead, onMarkPreviousAsReadClicked = screenModel::markPreviousChapterRead, @@ -321,14 +321,6 @@ class MangaScreen( } } - /** - * Initiates source migration for the specific manga. - */ - private fun migrateManga(router: Router, manga: Manga) { - val controller = SearchController(manga) - router.pushController(controller) - } - /** * Copy Manga URL to Clipboard */ diff --git a/app/src/main/res/layout/global_search_controller.xml b/app/src/main/res/layout/global_search_controller.xml deleted file mode 100644 index 7505a9825..000000000 --- a/app/src/main/res/layout/global_search_controller.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/global_search_controller_card.xml b/app/src/main/res/layout/global_search_controller_card.xml deleted file mode 100644 index 0d35b90db..000000000 --- a/app/src/main/res/layout/global_search_controller_card.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/global_search_controller_card_item.xml b/app/src/main/res/layout/global_search_controller_card_item.xml deleted file mode 100644 index 9acf49483..000000000 --- a/app/src/main/res/layout/global_search_controller_card_item.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/global_search.xml b/app/src/main/res/menu/global_search.xml deleted file mode 100644 index f3fdb1067..000000000 --- a/app/src/main/res/menu/global_search.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - -