mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	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
This commit is contained in:
		| @@ -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, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -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)) | ||||
|     } | ||||
| } | ||||
| @@ -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<Manga>, | ||||
|     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<CatalogueSource, GlobalSearchItemResult>, | ||||
|     contentPadding: PaddingValues, | ||||
|     getManga: @Composable (CatalogueSource, Manga) -> State<Manga>, | ||||
|     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, | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<Manga>, | ||||
|     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<CatalogueSource, GlobalSearchItemResult>, | ||||
|     contentPadding: PaddingValues, | ||||
|     getManga: @Composable (CatalogueSource, Manga) -> State<Manga>, | ||||
|     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, | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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<Manga>, | ||||
|     getManga: @Composable (Manga) -> State<Manga>, | ||||
|     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) }, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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)) | ||||
|     } | ||||
| } | ||||
| @@ -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(), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -18,6 +18,8 @@ class Padding { | ||||
|     val medium = 16.dp | ||||
|  | ||||
|     val small = 8.dp | ||||
|  | ||||
|     val tiny = 4.dp | ||||
| } | ||||
|  | ||||
| val MaterialTheme.padding: Padding | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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<Int>() | ||||
|                         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<Int> by lazy { | ||||
|         preferenceStore.getInt("migrate_flags", Int.MAX_VALUE) | ||||
|     } | ||||
|  | ||||
|     private val enhancedServices by lazy { Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() } | ||||
|  | ||||
|     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<SChapter>, | ||||
|         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, | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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>(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<CatalogueSource> { | ||||
|         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<CatalogueSource, GlobalSearchItemResult>) { | ||||
|         mutableState.update { | ||||
|             it.copy(items = items) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getItems(): Map<CatalogueSource, GlobalSearchItemResult> { | ||||
|         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<CatalogueSource, GlobalSearchItemResult> = emptyMap(), | ||||
|     val dialog: MigrateSearchDialog? = null, | ||||
| ) { | ||||
|  | ||||
|     val progress: Int = items.count { it.value !is GlobalSearchItemResult.Loading } | ||||
|  | ||||
|     val total: Int = items.size | ||||
| } | ||||
| @@ -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<GetManga>() | ||||
|                 .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<Int>() | ||||
|                     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)) | ||||
|     } | ||||
| } | ||||
| @@ -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<Pair<Boolean, Manga?>>() | ||||
|     private val coverCache: CoverCache by injectLazy() | ||||
|     private val enhancedServices by lazy { Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() } | ||||
|  | ||||
|     val migrateFlags: Preference<Int> 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<CatalogueSource> { | ||||
|         // 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<GlobalSearchCardItem>?): 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<SChapter>, | ||||
|         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, | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|         } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -334,6 +334,7 @@ open class BrowseSourcePresenter( | ||||
|             val manga: Manga, | ||||
|             val initialSelection: List<CheckboxState.State<Category>>, | ||||
|         ) : Dialog() | ||||
|         data class Migrate(val newManga: Manga) : Dialog() | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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<GlobalSearchItem>(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<Any?>) { | ||||
|         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<Parcelable>() | ||||
|         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<Parcelable>(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" | ||||
| @@ -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<GlobalSearchCardItem>(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) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<GlobalSearchCardHolder>() { | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return R.layout.global_search_controller_card_item | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): GlobalSearchCardHolder { | ||||
|         return GlobalSearchCardHolder(view, adapter as GlobalSearchCardAdapter) | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder( | ||||
|         adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, | ||||
|         holder: GlobalSearchCardHolder, | ||||
|         position: Int, | ||||
|         payloads: List<Any?>?, | ||||
|     ) { | ||||
|         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() | ||||
|     } | ||||
| } | ||||
| @@ -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<GlobalSearchControllerBinding, GlobalSearchPresenter>(), | ||||
|     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<GlobalSearchItem>) { | ||||
|         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)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<GlobalSearchCardItem>? = 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 | ||||
|     } | ||||
| } | ||||
| @@ -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<GlobalSearchCardItem>?, val highlighted: Boolean = false) : | ||||
|     AbstractFlexibleItem<GlobalSearchHolder>() { | ||||
|  | ||||
|     /** | ||||
|      * 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<IFlexible<RecyclerView.ViewHolder>>): GlobalSearchHolder { | ||||
|         return GlobalSearchHolder(view, adapter as GlobalSearchAdapter) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Bind item to view. | ||||
|      */ | ||||
|     override fun bindViewHolder( | ||||
|         adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, | ||||
|         holder: GlobalSearchHolder, | ||||
|         position: Int, | ||||
|         payloads: List<Any?>?, | ||||
|     ) { | ||||
|         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() | ||||
|     } | ||||
| } | ||||
| @@ -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<GlobalSearchController>() { | ||||
|  | ||||
|     /** | ||||
|      * 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<Pair<List<DomainManga>, 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<CatalogueSource> { | ||||
|         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<CatalogueSource> { | ||||
|         val filter = extensionFilter | ||||
|         val enabledSources = getEnabledSources() | ||||
|         var filteredSources: List<CatalogueSource>? = null | ||||
|  | ||||
|         if (!filter.isNullOrEmpty()) { | ||||
|             filteredSources = extensionManager.installedExtensionsFlow.value | ||||
|                 .filter { it.pkgName == filter } | ||||
|                 .flatMap { it.sources } | ||||
|                 .filter { it in enabledSources } | ||||
|                 .filterIsInstance<CatalogueSource>() | ||||
|         } | ||||
|  | ||||
|         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<GlobalSearchCardItem>?): 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<DomainManga>, 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)) | ||||
|     } | ||||
| } | ||||
| @@ -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)) }, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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>(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<CatalogueSource> { | ||||
|         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<CatalogueSource, GlobalSearchItemResult>) { | ||||
|         mutableState.update { | ||||
|             it.copy(items = items) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getItems(): Map<CatalogueSource, GlobalSearchItemResult> { | ||||
|         return mutableState.value.items | ||||
|     } | ||||
| } | ||||
|  | ||||
| sealed class GlobalSearchItemResult { | ||||
|     object Loading : GlobalSearchItemResult() | ||||
|  | ||||
|     data class Error( | ||||
|         val throwable: Throwable, | ||||
|     ) : GlobalSearchItemResult() | ||||
|  | ||||
|     data class Success( | ||||
|         val result: List<Manga>, | ||||
|     ) : GlobalSearchItemResult() { | ||||
|         val isEmpty: Boolean | ||||
|             get() = result.isEmpty() | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Immutable | ||||
| data class GlobalSearchState( | ||||
|     val searchQuery: String? = null, | ||||
|     val items: Map<CatalogueSource, GlobalSearchItemResult> = emptyMap(), | ||||
| ) { | ||||
|  | ||||
|     val progress: Int = items.count { it.value !is GlobalSearchItemResult.Loading } | ||||
|  | ||||
|     val total: Int = items.size | ||||
| } | ||||
| @@ -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<T>( | ||||
|     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<T>(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<Manga> { | ||||
|         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<CatalogueSource> | ||||
|  | ||||
|     fun getSelectedSources(): List<CatalogueSource> { | ||||
|         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<CatalogueSource>() | ||||
|     } | ||||
|  | ||||
|     abstract fun updateSearchQuery(query: String?) | ||||
|  | ||||
|     abstract fun updateItems(items: Map<CatalogueSource, GlobalSearchItemResult>) | ||||
|  | ||||
|     abstract fun getItems(): Map<CatalogueSource, GlobalSearchItemResult> | ||||
|  | ||||
|     fun getAndUpdateItems(function: (Map<CatalogueSource, GlobalSearchItemResult>) -> Map<CatalogueSource, GlobalSearchItemResult>) { | ||||
|         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<CatalogueSource, GlobalSearchItemResult> -> | ||||
|             compareBy<CatalogueSource>( | ||||
|                 { 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() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 }, | ||||
|   | ||||
| @@ -461,7 +461,7 @@ class MainActivity : BaseActivity() { | ||||
|                     if (router.backstackSize > 1) { | ||||
|                         router.popToRoot() | ||||
|                     } | ||||
|                     router.pushController(GlobalSearchController(query, filter)) | ||||
|                     router.pushController(GlobalSearchController(query, filter ?: "")) | ||||
|                 } | ||||
|             } | ||||
|             else -> { | ||||
|   | ||||
| @@ -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 | ||||
|      */ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user