瀏覽代碼

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
Andreas 2 年之前
父節點
當前提交
f99b62a069
共有 38 個文件被更改,包括 1231 次插入1508 次删除
  1. 10 0
      app/src/main/java/eu/kanade/domain/manga/model/MangaCover.kt
  2. 13 0
      app/src/main/java/eu/kanade/presentation/browse/BrowseBadges.kt
  3. 111 0
      app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt
  4. 100 0
      app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt
  5. 2 6
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt
  6. 2 6
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt
  7. 2 6
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt
  8. 40 0
      app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt
  9. 101 0
      app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchResultItems.kt
  10. 36 0
      app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchToolbar.kt
  11. 33 0
      app/src/main/java/eu/kanade/presentation/browse/components/GlobalSerachCard.kt
  12. 2 0
      app/src/main/java/eu/kanade/presentation/util/Constants.kt
  13. 3 8
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaScreen.kt
  14. 326 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt
  15. 93 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt
  16. 0 154
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt
  17. 0 204
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchPresenter.kt
  18. 27 6
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt
  19. 2 1
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt
  20. 1 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt
  21. 0 79
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchAdapter.kt
  22. 0 27
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardAdapter.kt
  23. 0 58
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardHolder.kt
  24. 0 40
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardItem.kt
  25. 20 221
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt
  26. 0 110
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchHolder.kt
  27. 0 71
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchItem.kt
  28. 0 265
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt
  29. 53 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt
  30. 83 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreenModel.kt
  31. 167 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt
  32. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt
  33. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
  34. 2 10
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt
  35. 0 52
      app/src/main/res/layout/global_search_controller.xml
  36. 0 86
      app/src/main/res/layout/global_search_controller_card.xml
  37. 0 84
      app/src/main/res/layout/global_search_controller_card_item.xml
  38. 0 12
      app/src/main/res/menu/global_search.xml

+ 10 - 0
app/src/main/java/eu/kanade/domain/manga/model/MangaCover.kt

@@ -10,3 +10,13 @@ data class MangaCover(
     val url: String?,
     val lastModified: Long,
 )
+
+fun Manga.asMangaCover(): MangaCover {
+    return MangaCover(
+        mangaId = id,
+        sourceId = source,
+        isMangaFavorite = favorite,
+        url = thumbnailUrl,
+        lastModified = coverLastModified,
+    )
+}

+ 13 - 0
app/src/main/java/eu/kanade/presentation/browse/BrowseBadges.kt

@@ -0,0 +1,13 @@
+package eu.kanade.presentation.browse
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import eu.kanade.presentation.components.Badge
+import eu.kanade.tachiyomi.R
+
+@Composable
+fun InLibraryBadge(enabled: Boolean) {
+    if (enabled) {
+        Badge(text = stringResource(R.string.in_library))
+    }
+}

+ 111 - 0
app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt

@@ -0,0 +1,111 @@
+package eu.kanade.presentation.browse
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.browse.components.GlobalSearchCardRow
+import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem
+import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem
+import eu.kanade.presentation.browse.components.GlobalSearchResultItem
+import eu.kanade.presentation.browse.components.GlobalSearchToolbar
+import eu.kanade.presentation.components.LazyColumn
+import eu.kanade.presentation.components.Scaffold
+import eu.kanade.presentation.util.padding
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItemResult
+import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchState
+import eu.kanade.tachiyomi.util.system.LocaleHelper
+
+@Composable
+fun GlobalSearchScreen(
+    state: GlobalSearchState,
+    navigateUp: () -> Unit,
+    onChangeSearchQuery: (String?) -> Unit,
+    onSearch: (String) -> Unit,
+    getManga: @Composable (CatalogueSource, Manga) -> State<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,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 100 - 0
app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt

@@ -0,0 +1,100 @@
+package eu.kanade.presentation.browse
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.browse.components.GlobalSearchCardRow
+import eu.kanade.presentation.browse.components.GlobalSearchEmptyResultItem
+import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem
+import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem
+import eu.kanade.presentation.browse.components.GlobalSearchResultItem
+import eu.kanade.presentation.browse.components.GlobalSearchToolbar
+import eu.kanade.presentation.components.LazyColumn
+import eu.kanade.presentation.components.Scaffold
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchState
+import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItemResult
+import eu.kanade.tachiyomi.util.system.LocaleHelper
+
+@Composable
+fun MigrateSearchScreen(
+    navigateUp: () -> Unit,
+    state: MigrateSearchState,
+    getManga: @Composable (CatalogueSource, Manga) -> State<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,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 2 - 6
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt

@@ -8,17 +8,15 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.getValue
-import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.paging.LoadState
 import androidx.paging.compose.LazyPagingItems
 import eu.kanade.domain.manga.model.Manga
 import eu.kanade.domain.manga.model.MangaCover
-import eu.kanade.presentation.components.Badge
+import eu.kanade.presentation.browse.InLibraryBadge
 import eu.kanade.presentation.components.CommonMangaItemDefaults
 import eu.kanade.presentation.components.MangaComfortableGridItem
 import eu.kanade.presentation.util.plus
-import eu.kanade.tachiyomi.R
 
 @Composable
 fun BrowseSourceComfortableGrid(
@@ -76,9 +74,7 @@ fun BrowseSourceComfortableGridItem(
         ),
         coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
         coverBadgeStart = {
-            if (manga.favorite) {
-                Badge(text = stringResource(R.string.in_library))
-            }
+            InLibraryBadge(enabled = manga.favorite)
         },
         onLongClick = onLongClick,
         onClick = onClick,

+ 2 - 6
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt

@@ -8,17 +8,15 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.getValue
-import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.paging.LoadState
 import androidx.paging.compose.LazyPagingItems
 import eu.kanade.domain.manga.model.Manga
 import eu.kanade.domain.manga.model.MangaCover
-import eu.kanade.presentation.components.Badge
+import eu.kanade.presentation.browse.InLibraryBadge
 import eu.kanade.presentation.components.CommonMangaItemDefaults
 import eu.kanade.presentation.components.MangaCompactGridItem
 import eu.kanade.presentation.util.plus
-import eu.kanade.tachiyomi.R
 
 @Composable
 fun BrowseSourceCompactGrid(
@@ -76,9 +74,7 @@ private fun BrowseSourceCompactGridItem(
         ),
         coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
         coverBadgeStart = {
-            if (manga.favorite) {
-                Badge(text = stringResource(R.string.in_library))
-            }
+            InLibraryBadge(enabled = manga.favorite)
         },
         onLongClick = onLongClick,
         onClick = onClick,

+ 2 - 6
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt

@@ -4,19 +4,17 @@ import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.getValue
-import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.paging.LoadState
 import androidx.paging.compose.LazyPagingItems
 import androidx.paging.compose.items
 import eu.kanade.domain.manga.model.Manga
 import eu.kanade.domain.manga.model.MangaCover
-import eu.kanade.presentation.components.Badge
+import eu.kanade.presentation.browse.InLibraryBadge
 import eu.kanade.presentation.components.CommonMangaItemDefaults
 import eu.kanade.presentation.components.LazyColumn
 import eu.kanade.presentation.components.MangaListItem
 import eu.kanade.presentation.util.plus
-import eu.kanade.tachiyomi.R
 
 @Composable
 fun BrowseSourceList(
@@ -70,9 +68,7 @@ fun BrowseSourceListItem(
         ),
         coverAlpha = if (manga.favorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
         badge = {
-            if (manga.favorite) {
-                Badge(text = stringResource(R.string.in_library))
-            }
+            InLibraryBadge(enabled = manga.favorite)
         },
         onLongClick = onLongClick,
         onClick = onClick,

+ 40 - 0
app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt

@@ -0,0 +1,40 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.domain.manga.model.asMangaCover
+import eu.kanade.presentation.util.padding
+
+@Composable
+fun GlobalSearchCardRow(
+    titles: List<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) },
+            )
+        }
+    }
+}

+ 101 - 0
app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchResultItems.kt

@@ -0,0 +1,101 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ArrowForward
+import androidx.compose.material.icons.outlined.Error
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import eu.kanade.presentation.util.padding
+import eu.kanade.tachiyomi.R
+
+@Composable
+fun GlobalSearchResultItem(
+    title: String,
+    subtitle: String,
+    onClick: () -> Unit,
+    content: @Composable () -> Unit,
+) {
+    Column {
+        Row(
+            modifier = Modifier
+                .padding(
+                    start = MaterialTheme.padding.medium,
+                    end = MaterialTheme.padding.tiny,
+                )
+                .fillMaxWidth()
+                .clickable(onClick = onClick),
+            horizontalArrangement = Arrangement.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Column {
+                Text(
+                    text = title,
+                    style = MaterialTheme.typography.titleMedium,
+                )
+                Text(text = subtitle)
+            }
+            IconButton(onClick = onClick) {
+                Icon(imageVector = Icons.Outlined.ArrowForward, contentDescription = null)
+            }
+        }
+        content()
+    }
+}
+
+@Composable
+fun GlobalSearchEmptyResultItem() {
+    Text(
+        text = stringResource(id = R.string.no_results_found),
+        modifier = Modifier
+            .padding(
+                horizontal = MaterialTheme.padding.medium,
+                vertical = MaterialTheme.padding.small,
+            ),
+    )
+}
+
+@Composable
+fun GlobalSearchLoadingResultItem() {
+    Box(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = MaterialTheme.padding.medium),
+    ) {
+        CircularProgressIndicator(
+            modifier = Modifier
+                .size(16.dp)
+                .align(Alignment.Center),
+            strokeWidth = 2.dp,
+        )
+    }
+}
+
+@Composable
+fun GlobalSearchErrorResultItem(message: String?) {
+    Column(
+        modifier = Modifier
+            .padding(vertical = MaterialTheme.padding.medium)
+            .fillMaxWidth(),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.Center,
+    ) {
+        Icon(imageVector = Icons.Outlined.Error, contentDescription = null)
+        Text(text = message ?: stringResource(id = R.string.unknown_error))
+    }
+}

+ 36 - 0
app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchToolbar.kt

@@ -0,0 +1,36 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import eu.kanade.presentation.components.SearchToolbar
+
+@Composable
+fun GlobalSearchToolbar(
+    searchQuery: String?,
+    progress: Int,
+    total: Int,
+    navigateUp: () -> Unit,
+    onChangeSearchQuery: (String?) -> Unit,
+    onSearch: (String) -> Unit,
+) {
+    Box {
+        SearchToolbar(
+            searchQuery = searchQuery,
+            onChangeSearchQuery = onChangeSearchQuery,
+            onSearch = onSearch,
+            navigateUp = navigateUp,
+        )
+        if (progress in 1 until total) {
+            LinearProgressIndicator(
+                progress = progress / total.toFloat(),
+                modifier = Modifier
+                    .align(Alignment.BottomStart)
+                    .fillMaxWidth(),
+            )
+        }
+    }
+}

+ 33 - 0
app/src/main/java/eu/kanade/presentation/browse/components/GlobalSerachCard.kt

@@ -0,0 +1,33 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import eu.kanade.domain.manga.model.MangaCover
+import eu.kanade.presentation.browse.InLibraryBadge
+import eu.kanade.presentation.components.CommonMangaItemDefaults
+import eu.kanade.presentation.components.MangaComfortableGridItem
+
+@Composable
+fun GlobalSearchCard(
+    title: String,
+    cover: MangaCover,
+    isFavorite: Boolean,
+    onClick: () -> Unit,
+    onLongClick: () -> Unit,
+) {
+    Box(modifier = Modifier.width(128.dp)) {
+        MangaComfortableGridItem(
+            title = title,
+            coverData = cover,
+            coverBadgeStart = {
+                InLibraryBadge(enabled = isFavorite)
+            },
+            coverAlpha = if (isFavorite) CommonMangaItemDefaults.BrowseFavoriteCoverAlpha else 1f,
+            onClick = onClick,
+            onLongClick = onLongClick,
+        )
+    }
+}

+ 2 - 0
app/src/main/java/eu/kanade/presentation/util/Constants.kt

@@ -18,6 +18,8 @@ class Padding {
     val medium = 16.dp
 
     val small = 8.dp
+
+    val tiny = 4.dp
 }
 
 val MaterialTheme.padding: Padding

+ 3 - 8
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaScreen.kt

@@ -13,8 +13,7 @@ import eu.kanade.presentation.browse.MigrateMangaScreen
 import eu.kanade.presentation.components.LoadingScreen
 import eu.kanade.presentation.util.LocalRouter
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.base.controller.pushController
-import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
+import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
 import eu.kanade.tachiyomi.ui.manga.MangaScreen
 import eu.kanade.tachiyomi.util.system.toast
 import kotlinx.coroutines.flow.collectLatest
@@ -41,12 +40,8 @@ data class MigrationMangaScreen(
             navigateUp = navigator::pop,
             title = state.source!!.name,
             state = state,
-            onClickItem = {
-                router.pushController(SearchController(it.id))
-            },
-            onClickCover = {
-                navigator.push(MangaScreen(it.id))
-            },
+            onClickItem = { navigator.push(MigrateSearchScreen(it.id)) },
+            onClickCover = { navigator.push(MangaScreen(it.id)) },
         )
 
         LaunchedEffect(Unit) {

+ 326 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt

@@ -0,0 +1,326 @@
+package eu.kanade.tachiyomi.ui.browse.migration.search
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.util.fastForEachIndexed
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import eu.kanade.domain.category.interactor.GetCategories
+import eu.kanade.domain.category.interactor.SetMangaCategories
+import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
+import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
+import eu.kanade.domain.chapter.interactor.UpdateChapter
+import eu.kanade.domain.chapter.model.toChapterUpdate
+import eu.kanade.domain.manga.interactor.UpdateManga
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.domain.manga.model.MangaUpdate
+import eu.kanade.domain.manga.model.hasCustomCover
+import eu.kanade.domain.track.interactor.GetTracks
+import eu.kanade.domain.track.interactor.InsertTrack
+import eu.kanade.presentation.browse.MigrateSearchScreen
+import eu.kanade.presentation.util.LocalRouter
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.core.preference.Preference
+import eu.kanade.tachiyomi.core.preference.PreferenceStore
+import eu.kanade.tachiyomi.data.cache.CoverCache
+import eu.kanade.tachiyomi.data.track.EnhancedTrackService
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.tachiyomi.source.SourceManager
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.ui.base.controller.pushController
+import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
+import eu.kanade.tachiyomi.ui.manga.MangaController
+import eu.kanade.tachiyomi.ui.manga.MangaScreen
+import eu.kanade.tachiyomi.util.lang.launchIO
+import eu.kanade.tachiyomi.util.lang.launchUI
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.util.Date
+
+class MigrateSearchScreen(private val mangaId: Long) : Screen {
+
+    @Composable
+    override fun Content() {
+        val navigator = LocalNavigator.currentOrThrow
+        val router = LocalRouter.currentOrThrow
+        val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) }
+        val state by screenModel.state.collectAsState()
+
+        MigrateSearchScreen(
+            navigateUp = navigator::pop,
+            state = state,
+            getManga = { source, manga ->
+                screenModel.getManga(source = source, initialManga = manga)
+            },
+            onChangeSearchQuery = screenModel::updateSearchQuery,
+            onSearch = screenModel::search,
+            onClickSource = {
+                if (!screenModel.incognitoMode.get()) {
+                    screenModel.lastUsedSourceId.set(it.id)
+                }
+                router.pushController(SourceSearchController(state.manga, it, state.searchQuery))
+            },
+            onClickItem = { screenModel.setDialog(MigrateSearchDialog.Migrate(it)) },
+            onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
+        )
+
+        when (val dialog = state.dialog) {
+            null -> {}
+            is MigrateSearchDialog.Migrate -> {
+                MigrateDialog(
+                    oldManga = state.manga!!,
+                    newManga = dialog.manga,
+                    screenModel = rememberScreenModel { MigrateDialogScreenModel() },
+                    onDismissRequest = { screenModel.setDialog(null) },
+                    onClickTitle = {
+                        navigator.push(MangaScreen(dialog.manga.id, true))
+                    },
+                    onPopScreen = {
+                        if (navigator.lastItem is MangaScreen) {
+                            val lastItem = navigator.lastItem
+                            navigator.popUntil { navigator.items.contains(lastItem) }
+                            navigator.push(MangaScreen(dialog.manga.id))
+                        } else {
+                            navigator.pop()
+                            router.pushController(MangaController(dialog.manga.id))
+                        }
+                    },
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun MigrateDialog(
+    oldManga: Manga,
+    newManga: Manga,
+    screenModel: MigrateDialogScreenModel,
+    onDismissRequest: () -> Unit,
+    onClickTitle: () -> Unit,
+    onPopScreen: () -> Unit,
+) {
+    val context = LocalContext.current
+    val scope = rememberCoroutineScope()
+    val activeFlags = remember { MigrationFlags.getEnabledFlagsPositions(screenModel.migrateFlags.get()) }
+    val items = remember {
+        MigrationFlags.titles(oldManga)
+            .map { context.getString(it) }
+            .toList()
+    }
+    val selected = remember {
+        mutableStateListOf(*List(items.size) { i -> activeFlags.contains(i) }.toTypedArray())
+    }
+    AlertDialog(
+        onDismissRequest = onDismissRequest,
+        title = {
+            Text(text = stringResource(id = R.string.migration_dialog_what_to_include))
+        },
+        text = {
+            Column {
+                items.forEachIndexed { index, title ->
+                    Row(
+                        verticalAlignment = Alignment.CenterVertically,
+                    ) {
+                        Checkbox(checked = selected[index], onCheckedChange = { selected[index] = !selected[index] })
+                        Text(text = title)
+                    }
+                }
+            }
+        },
+        confirmButton = {
+            Row {
+                TextButton(
+                    modifier = Modifier.weight(1f),
+                    onClick = {
+                        onClickTitle()
+                        onDismissRequest()
+                    },
+                ) {
+                    Text(text = stringResource(id = R.string.action_show_manga))
+                }
+                TextButton(onClick = {
+                    scope.launchIO {
+                        screenModel.migrateManga(oldManga, newManga, false)
+                        launchUI {
+                            onPopScreen()
+                        }
+                    }
+                },) {
+                    Text(text = stringResource(id = R.string.copy))
+                }
+                TextButton(onClick = {
+                    scope.launchIO {
+                        val selectedIndices = mutableListOf<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,
+            ),
+        )
+    }
+}

+ 93 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreenModel.kt

@@ -0,0 +1,93 @@
+package eu.kanade.tachiyomi.ui.browse.migration.search
+
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.coroutineScope
+import eu.kanade.domain.base.BasePreferences
+import eu.kanade.domain.manga.interactor.GetManga
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.domain.source.service.SourcePreferences
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.SourceManager
+import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItemResult
+import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class MigrateSearchScreenModel(
+    val mangaId: Long,
+    initialExtensionFilter: String = "",
+    preferences: BasePreferences = Injekt.get(),
+    private val sourcePreferences: SourcePreferences = Injekt.get(),
+    private val sourceManager: SourceManager = Injekt.get(),
+    private val getManga: GetManga = Injekt.get(),
+) : SearchScreenModel<MigrateSearchState>(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
+}

+ 0 - 154
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchController.kt

@@ -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))
-    }
-}

+ 0 - 204
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SearchPresenter.kt

@@ -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,
-            ),
-        )
-    }
-}

+ 27 - 6
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt

@@ -3,12 +3,19 @@ package eu.kanade.tachiyomi.ui.browse.migration.search
 import android.os.Bundle
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
 import androidx.core.os.bundleOf
 import eu.kanade.domain.manga.model.Manga
 import eu.kanade.presentation.browse.SourceSearchScreen
+import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.source.CatalogueSource
 import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.ui.base.controller.pushController
+import eu.kanade.tachiyomi.ui.base.controller.setRoot
+import eu.kanade.tachiyomi.ui.browse.BrowseController
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
+import eu.kanade.tachiyomi.ui.manga.MangaController
 import eu.kanade.tachiyomi.ui.webview.WebViewActivity
 import eu.kanade.tachiyomi.util.system.getSerializableCompat
 
@@ -25,7 +32,6 @@ class SourceSearchController(
     )
 
     private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY)
-    private var newManga: Manga? = null
 
     @Composable
     override fun ComposeContent() {
@@ -34,11 +40,7 @@ class SourceSearchController(
             navigateUp = { router.popCurrentController() },
             onFabClick = { filterSheet?.show() },
             onMangaClick = {
-                newManga = it
-                val searchController = router.backstack.findLast { it.controller.javaClass == SearchController::class.java }?.controller as SearchController?
-                val dialog = SearchController.MigrationDialog(oldManga, newManga, this)
-                dialog.targetController = searchController
-                dialog.showDialog(router)
+                presenter.dialog = BrowseSourcePresenter.Dialog.Migrate(it)
             },
             onWebViewClick = f@{
                 val source = presenter.source as? HttpSource ?: return@f
@@ -49,6 +51,25 @@ class SourceSearchController(
             },
         )
 
+        when (val dialog = presenter.dialog) {
+            is BrowseSourcePresenter.Dialog.Migrate -> {
+                MigrateDialog(
+                    oldManga = oldManga!!,
+                    newManga = dialog.newManga,
+                    // TODO: Move screen model down into Dialog when this screen is using Voyager
+                    screenModel = remember { MigrateDialogScreenModel() },
+                    onDismissRequest = { presenter.dialog = null },
+                    onClickTitle = { router.pushController(MangaController(dialog.newManga.id)) },
+                    onPopScreen = {
+                        // TODO: Push to manga screen and remove this and the previous screen when it moves to Voyager
+                        router.setRoot(BrowseController(toExtensions = false), R.id.nav_browse)
+                        router.pushController(MangaController(dialog.newManga.id))
+                    },
+                )
+            }
+            else -> {}
+        }
+
         LaunchedEffect(presenter.filters) {
             initFilterSheet()
         }

+ 2 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt

@@ -81,6 +81,8 @@ open class BrowseSourceController(bundle: Bundle) :
 
         val onDismissRequest = { presenter.dialog = null }
         when (val dialog = presenter.dialog) {
+            null -> {}
+            is Dialog.Migrate -> {}
             is Dialog.AddDuplicateManga -> {
                 DuplicateMangaDialog(
                     onDismissRequest = onDismissRequest,
@@ -111,7 +113,6 @@ open class BrowseSourceController(bundle: Bundle) :
                     },
                 )
             }
-            null -> {}
         }
 
         BackHandler(onBack = ::navigateUp)

+ 1 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt

@@ -334,6 +334,7 @@ open class BrowseSourcePresenter(
             val manga: Manga,
             val initialSelection: List<CheckboxState.State<Category>>,
         ) : Dialog()
+        data class Migrate(val newManga: Manga) : Dialog()
     }
 }
 

+ 0 - 79
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchAdapter.kt

@@ -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"

+ 0 - 27
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardAdapter.kt

@@ -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)
-    }
-}

+ 0 - 58
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardHolder.kt

@@ -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)
-        }
-    }
-}

+ 0 - 40
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchCardItem.kt

@@ -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()
-    }
-}

+ 20 - 221
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt

@@ -1,226 +1,25 @@
 package eu.kanade.tachiyomi.ui.browse.source.globalsearch
 
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.Menu
-import android.view.MenuInflater
-import android.view.MenuItem
-import android.view.View
-import androidx.appcompat.widget.SearchView
-import androidx.core.view.isVisible
-import androidx.recyclerview.widget.LinearLayoutManager
-import dev.chrisbanes.insetter.applyInsetter
-import eu.kanade.domain.base.BasePreferences
-import eu.kanade.domain.manga.model.Manga
-import eu.kanade.domain.source.service.SourcePreferences
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.databinding.GlobalSearchControllerBinding
-import eu.kanade.tachiyomi.source.CatalogueSource
-import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
-import eu.kanade.tachiyomi.ui.base.controller.pushController
-import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
-import eu.kanade.tachiyomi.ui.manga.MangaController
-import uy.kohesive.injekt.injectLazy
-
-/**
- * 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 {
-
-    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()
-        }
-    }
-
-    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)
+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
+
+class GlobalSearchController(
+    val searchQuery: String = "",
+    val extensionFilter: String = "",
+) : BasicFullComposeController() {
+
+    @Composable
+    override fun ComposeContent() {
+        CompositionLocalProvider(LocalRouter provides router) {
+            Navigator(
+                screen = GlobalSearchScreen(
+                    searchQuery = searchQuery,
+                    extensionFilter = extensionFilter,
+                ),
+            )
         }
-        router.pushController(BrowseSourceController(source, presenter.query))
     }
 }

+ 0 - 110
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchHolder.kt

@@ -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
-    }
-}

+ 0 - 71
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchItem.kt

@@ -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()
-    }
-}

+ 0 - 265
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt

@@ -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))
-    }
-}

+ 53 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt

@@ -0,0 +1,53 @@
+package eu.kanade.tachiyomi.ui.browse.source.globalsearch
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.currentOrThrow
+import eu.kanade.presentation.browse.GlobalSearchScreen
+import eu.kanade.presentation.util.LocalRouter
+import eu.kanade.tachiyomi.ui.base.controller.pushController
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
+import eu.kanade.tachiyomi.ui.manga.MangaController
+
+class GlobalSearchScreen(
+    val searchQuery: String = "",
+    val extensionFilter: String = "",
+) : Screen {
+
+    @Composable
+    override fun Content() {
+        val router = LocalRouter.currentOrThrow
+
+        val screenModel = rememberScreenModel {
+            GlobalSearchScreenModel(
+                initialQuery = searchQuery,
+                initialExtensionFilter = extensionFilter,
+            )
+        }
+        val state by screenModel.state.collectAsState()
+
+        GlobalSearchScreen(
+            state = state,
+            navigateUp = router::popCurrentController,
+            onChangeSearchQuery = screenModel::updateSearchQuery,
+            onSearch = screenModel::search,
+            getManga = { source, manga ->
+                screenModel.getManga(
+                    source = source,
+                    initialManga = manga,
+                )
+            },
+            onClickSource = {
+                if (!screenModel.incognitoMode.get()) {
+                    screenModel.lastUsedSourceId.set(it.id)
+                }
+                router.pushController(BrowseSourceController(it, state.searchQuery))
+            },
+            onClickItem = { router.pushController(MangaController(it.id, true)) },
+            onLongClickItem = { router.pushController(MangaController(it.id, true)) },
+        )
+    }
+}

+ 83 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreenModel.kt

@@ -0,0 +1,83 @@
+package eu.kanade.tachiyomi.ui.browse.source.globalsearch
+
+import androidx.compose.runtime.Immutable
+import eu.kanade.domain.base.BasePreferences
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.domain.source.service.SourcePreferences
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.SourceManager
+import kotlinx.coroutines.flow.update
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class GlobalSearchScreenModel(
+    initialQuery: String = "",
+    initialExtensionFilter: String = "",
+    preferences: BasePreferences = Injekt.get(),
+    private val sourcePreferences: SourcePreferences = Injekt.get(),
+    private val sourceManager: SourceManager = Injekt.get(),
+) : SearchScreenModel<GlobalSearchState>(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
+}

+ 167 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt

@@ -0,0 +1,167 @@
+package eu.kanade.tachiyomi.ui.browse.source.globalsearch
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.produceState
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
+import eu.kanade.domain.manga.interactor.GetManga
+import eu.kanade.domain.manga.interactor.NetworkToLocalManga
+import eu.kanade.domain.manga.interactor.UpdateManga
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.domain.manga.model.toDomainManga
+import eu.kanade.domain.manga.model.toMangaUpdate
+import eu.kanade.domain.source.service.SourcePreferences
+import eu.kanade.tachiyomi.extension.ExtensionManager
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.util.lang.awaitSingle
+import eu.kanade.tachiyomi.util.lang.withIOContext
+import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
+import eu.kanade.tachiyomi.util.system.logcat
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import logcat.LogPriority
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.util.concurrent.Executors
+
+abstract class SearchScreenModel<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()
+                }
+            }
+        }
+    }
+}

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt

@@ -178,7 +178,7 @@ object LibraryScreen : Screen {
                 },
                 onRefresh = onClickRefresh,
                 onGlobalSearchClicked = {
-                    router.pushController(GlobalSearchController(screenModel.state.value.searchQuery))
+                    router.pushController(GlobalSearchController(screenModel.state.value.searchQuery ?: ""))
                 },
                 getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) },
                 getDisplayModeForPage = { state.categories[it].display },

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

@@ -461,7 +461,7 @@ class MainActivity : BaseActivity() {
                     if (router.backstackSize > 1) {
                         router.popToRoot()
                     }
-                    router.pushController(GlobalSearchController(query, filter))
+                    router.pushController(GlobalSearchController(query, filter ?: ""))
                 }
             }
             else -> {

+ 2 - 10
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt

@@ -50,7 +50,7 @@ import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.source.isLocalOrStub
 import eu.kanade.tachiyomi.source.online.HttpSource
 import eu.kanade.tachiyomi.ui.base.controller.pushController
-import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
+import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
 import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
 import eu.kanade.tachiyomi.ui.category.CategoryScreen
@@ -113,7 +113,7 @@ class MangaScreen(
             onShareClicked = { shareManga(context, screenModel.manga, screenModel.source) }.takeIf { isHttpSource },
             onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() },
             onEditCategoryClicked = screenModel::promptChangeCategories.takeIf { successState.manga.favorite },
-            onMigrateClicked = { migrateManga(router, screenModel.manga!!) }.takeIf { successState.manga.favorite },
+            onMigrateClicked = { navigator.push(MigrateSearchScreen(successState.manga.id)) }.takeIf { successState.manga.favorite },
             onMultiBookmarkClicked = screenModel::bookmarkChapters,
             onMultiMarkAsReadClicked = screenModel::markChaptersRead,
             onMarkPreviousAsReadClicked = screenModel::markPreviousChapterRead,
@@ -321,14 +321,6 @@ class MangaScreen(
         }
     }
 
-    /**
-     * Initiates source migration for the specific manga.
-     */
-    private fun migrateManga(router: Router, manga: Manga) {
-        val controller = SearchController(manga)
-        router.pushController(controller)
-    }
-
     /**
      * Copy Manga URL to Clipboard
      */

+ 0 - 52
app/src/main/res/layout/global_search_controller.xml

@@ -1,52 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content">
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:clipToPadding="false"
-        android:paddingTop="4dp"
-        android:paddingBottom="4dp"
-        tools:listitem="@layout/global_search_controller_card" />
-
-    <com.google.android.material.progressindicator.LinearProgressIndicator
-        android:id="@+id/progress_bar"
-        android:layout_width="match_parent"
-        android:layout_height="2dp"
-        android:max="100"
-        android:visibility="gone"
-        tools:progress="50"
-        tools:visibility="visible" />
-
-    <FrameLayout
-        android:id="@+id/progress"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:visibility="gone">
-
-        <FrameLayout
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:alpha="0.75"
-            android:background="?attr/colorSurface" />
-
-        <com.google.android.material.progressindicator.CircularProgressIndicator
-            android:layout_width="wrap_content"
-            android:layout_height="match_parent"
-            android:layout_gravity="center"
-            android:indeterminate="true" />
-
-    </FrameLayout>
-
-    <eu.kanade.tachiyomi.widget.EmptyView
-        android:id="@+id/empty_view"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:visibility="gone" />
-
-</FrameLayout>

+ 0 - 86
app/src/main/res/layout/global_search_controller_card.xml

@@ -1,86 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:orientation="vertical">
-
-    <androidx.constraintlayout.widget.ConstraintLayout
-        android:id="@+id/title_wrapper"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:background="?attr/selectableItemBackground">
-
-        <TextView
-            android:id="@+id/title"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_marginStart="16dp"
-            android:textAppearance="?attr/textAppearanceBodyMedium"
-            app:layout_constraintBottom_toTopOf="@+id/subtitle"
-            app:layout_constraintEnd_toStartOf="@+id/title_more_icon"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toTopOf="parent"
-            app:layout_constraintVertical_chainStyle="packed"
-            tools:text="Title" />
-
-        <TextView
-            android:id="@+id/subtitle"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content"
-            android:layout_marginStart="16dp"
-            android:maxLines="1"
-            android:textAppearance="?attr/textAppearanceBodyMedium"
-            android:textSize="12sp"
-            android:visibility="gone"
-            android:textColor="?android:attr/textColorSecondary"
-            app:layout_constraintBottom_toBottomOf="parent"
-            app:layout_constraintEnd_toStartOf="@+id/title_more_icon"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@+id/title"
-            tools:text="English"
-            tools:visibility="visible" />
-
-        <ImageView
-            android:id="@+id/title_more_icon"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:contentDescription="@string/all"
-            android:padding="16dp"
-            app:layout_constraintBottom_toBottomOf="parent"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintTop_toTopOf="parent"
-            app:srcCompat="@drawable/ic_arrow_forward_24dp"
-            app:tint="?android:attr/textColorPrimary" />
-
-    </androidx.constraintlayout.widget.ConstraintLayout>
-
-    <TextView
-        android:id="@+id/no_results_found"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:paddingStart="16dp"
-        android:paddingEnd="16dp"
-        android:paddingBottom="16dp"
-        android:text="@string/no_results_found"
-        android:visibility="gone" />
-
-    <com.google.android.material.progressindicator.CircularProgressIndicator
-        android:id="@+id/progress"
-        style="@style/Widget.Tachiyomi.CircularProgressIndicator.Small"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:indeterminate="true" />
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:paddingStart="12dp"
-        android:paddingEnd="12dp"
-        android:clipToPadding="false"
-        tools:listitem="@layout/global_search_controller_card_item" />
-
-</LinearLayout>

+ 0 - 84
app/src/main/res/layout/global_search_controller_card_item.xml

@@ -1,84 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="wrap_content"
-    android:layout_height="wrap_content"
-    android:layout_marginVertical="4dp"
-    android:background="@drawable/library_item_selector"
-    android:padding="4dp">
-
-    <FrameLayout
-        android:id="@+id/card"
-        android:layout_width="112dp"
-        android:layout_height="144dp"
-        android:background="@drawable/rounded_rectangle"
-        app:layout_constraintDimensionRatio="h,5:7"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <com.google.android.material.progressindicator.CircularProgressIndicator
-            android:id="@+id/progress"
-            style="@style/Widget.Tachiyomi.CircularProgressIndicator.Small"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center"
-            android:indeterminate="true"
-            android:visibility="gone" />
-
-        <ImageView
-            android:id="@+id/cover"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:background="?attr/colorSurface"
-            android:scaleType="centerCrop"
-            tools:ignore="ContentDescription"
-            tools:src="@mipmap/ic_launcher" />
-
-        <LinearLayout
-            android:id="@+id/badges"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_marginStart="4dp"
-            android:layout_marginTop="4dp"
-            android:background="@drawable/rounded_rectangle">
-
-            <TextView
-                android:id="@+id/favorite_text"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:background="?attr/colorSecondary"
-                android:maxLines="1"
-                android:paddingStart="3dp"
-                android:paddingTop="1dp"
-                android:paddingEnd="3dp"
-                android:paddingBottom="1dp"
-                android:fontFamily="sans-serif-condensed"
-                android:text="@string/in_library"
-                android:textAppearance="?attr/textAppearanceBodySmall"
-                android:textColor="?attr/colorOnSecondary"
-                android:visibility="gone"
-                tools:visibility="visible" />
-
-        </LinearLayout>
-
-    </FrameLayout>
-
-    <TextView
-        android:id="@+id/title"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:ellipsize="end"
-        android:maxLines="2"
-        android:padding="4dp"
-        android:textAppearance="?attr/textAppearanceTitleSmall"
-        android:textSize="12sp"
-        app:layout_constraintEnd_toEndOf="@+id/card"
-        app:layout_constraintStart_toStartOf="@+id/card"
-        app:layout_constraintTop_toBottomOf="@+id/card"
-        tools:text="Sample name" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
-
-

+ 0 - 12
app/src/main/res/menu/global_search.xml

@@ -1,12 +0,0 @@
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-
-    <item
-        android:id="@+id/action_search"
-        android:icon="@drawable/ic_search_24dp"
-        android:title="@string/action_search"
-        app:actionViewClass="eu.kanade.tachiyomi.widget.TachiyomiSearchView"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="collapseActionView|ifRoom" />
-
-</menu>