Use Compose on Global/Migrate Search screen (#8631)

* Use Compose on Global/Migrate Search screen

- Refactor to use Voyager and Compose
- Use sealed class for state
- Somethings are broken/missing due to screens using different navigation libraries

* Review changes
This commit is contained in:
Andreas 2022-11-27 20:56:21 +01:00 committed by GitHub
parent ac1bed38f9
commit f99b62a069
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1229 additions and 1506 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,226 +1,25 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.GlobalSearchControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.manga.MangaController
import uy.kohesive.injekt.injectLazy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
/**
* This controller shows and manages the different search result in global search.
* This controller should only handle UI actions, IO actions should be done by [GlobalSearchPresenter]
* [GlobalSearchCardAdapter.OnMangaClickListener] called when manga is clicked in global search
*/
open class GlobalSearchController(
protected val initialQuery: String? = null,
private val extensionFilter: String? = null,
) : SearchableNucleusController<GlobalSearchControllerBinding, GlobalSearchPresenter>(),
GlobalSearchCardAdapter.OnMangaClickListener,
GlobalSearchAdapter.OnTitleClickListener {
class GlobalSearchController(
val searchQuery: String = "",
val extensionFilter: String = "",
) : BasicFullComposeController() {
private val preferences: BasePreferences by injectLazy()
private val sourcePreferences: SourcePreferences by injectLazy()
/**
* Adapter containing search results grouped by lang.
*/
protected var adapter: GlobalSearchAdapter? = null
/**
* Ref to the OptionsMenu.SearchItem created in onCreateOptionsMenu
*/
private var optionsMenuSearchItem: MenuItem? = null
init {
setHasOptionsMenu(true)
}
override fun createBinding(inflater: LayoutInflater) = GlobalSearchControllerBinding.inflate(inflater)
override fun getTitle(): String? {
return presenter.query
}
override fun createPresenter(): GlobalSearchPresenter {
return GlobalSearchPresenter(initialQuery, extensionFilter)
}
/**
* Called when manga in global search is clicked, opens manga.
*
* @param manga clicked item containing manga information.
*/
override fun onMangaClick(manga: Manga) {
router.pushController(MangaController(manga.id, true))
}
/**
* Called when manga in global search is long clicked.
*
* @param manga clicked item containing manga information.
*/
override fun onMangaLongClick(manga: Manga) {
// Delegate to single click by default.
onMangaClick(manga)
}
/**
* Adds items to the options menu.
*
* @param menu menu containing options.
* @param inflater used to load the menu xml.
*/
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
createOptionsMenu(
menu,
inflater,
R.menu.global_search,
R.id.action_search,
)
optionsMenuSearchItem = menu.findItem(R.id.action_search)
// Focus search on launch from browse screen
if (initialQuery.isNullOrEmpty()) {
optionsMenuSearchItem?.expandActionView()
@Composable
override fun ComposeContent() {
CompositionLocalProvider(LocalRouter provides router) {
Navigator(
screen = GlobalSearchScreen(
searchQuery = searchQuery,
extensionFilter = extensionFilter,
),
)
}
}
override fun onSearchMenuItemActionCollapse(item: MenuItem?) {
super.onSearchMenuItemActionCollapse(item)
// Close this screen if query is empty
// i.e. launch from browse screen and clicking the back button icon without making any search
if (presenter.query.isEmpty()) {
router.popCurrentController()
}
}
override fun onSearchMenuItemActionExpand(item: MenuItem?) {
super.onSearchMenuItemActionExpand(item)
val searchView = optionsMenuSearchItem?.actionView as SearchView
searchView.onActionViewExpanded() // Required to show the query in the view
if (nonSubmittedQuery.isBlank()) {
searchView.setQuery(presenter.query, false)
}
}
override fun onSearchViewQueryTextSubmit(query: String?) {
presenter.search(query ?: "")
optionsMenuSearchItem?.collapseActionView()
setTitle() // Update toolbar title
}
/**
* Called when the view is created
*
* @param view view of controller
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = GlobalSearchAdapter(this)
// Create recycler and set adapter.
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onSaveViewState(view: View, outState: Bundle) {
super.onSaveViewState(view, outState)
adapter?.onSaveInstanceState(outState)
}
override fun onRestoreViewState(view: View, savedViewState: Bundle) {
super.onRestoreViewState(view, savedViewState)
adapter?.onRestoreInstanceState(savedViewState)
}
/**
* Returns the view holder for the given manga.
*
* @param source used to find holder containing source
* @return the holder of the manga or null if it's not bound.
*/
private fun getHolder(source: CatalogueSource): GlobalSearchHolder? {
val adapter = adapter ?: return null
adapter.allBoundViewHolders.forEach { holder ->
val item = adapter.getItem(holder.bindingAdapterPosition)
if (item != null && source.id == item.source.id) {
return holder as GlobalSearchHolder
}
}
return null
}
/**
* Add search result to adapter.
*
* @param searchResult result of search.
*/
fun setItems(searchResult: List<GlobalSearchItem>) {
if (searchResult.isEmpty() && sourcePreferences.searchPinnedSourcesOnly().get()) {
binding.emptyView.show(R.string.no_pinned_sources)
} else {
binding.emptyView.hide()
}
adapter?.updateDataSet(searchResult)
val progress = searchResult.mapNotNull { it.results }.size.toDouble() / searchResult.size
if (progress < 1) {
binding.progressBar.isVisible = true
binding.progressBar.progress = (progress * 100).toInt()
} else {
binding.progressBar.isVisible = false
}
}
/**
* Called from the presenter when a manga is initialized.
*
* @param manga the initialized manga.
*/
fun onMangaInitialized(source: CatalogueSource, manga: Manga) {
getHolder(source)?.setImage(manga)
}
/**
* Opens a catalogue with the given search.
*/
override fun onTitleClick(source: CatalogueSource) {
if (!preferences.incognitoMode().get()) {
sourcePreferences.lastUsedSource().set(source.id)
}
router.pushController(BrowseSourceController(source, presenter.query))
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
*/

View file

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

View file

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

View file

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

View file

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