Use Compose on BrowseSourceScreens (#7901)

This commit is contained in:
Andreas 2022-08-31 20:41:35 +02:00 committed by GitHub
parent bb54a81ef0
commit d4b764fa31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1760 additions and 2024 deletions

View File

@ -27,6 +27,10 @@ class MangaRepositoryImpl(
return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) }
}
override fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Manga?> {
return handler.subscribeToOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) }
}
override suspend fun getFavorites(): List<Manga> {
return handler.awaitList { mangasQueries.getFavorites(mangaMapper) }
}

View File

@ -26,4 +26,8 @@ class GetManga(
suspend fun await(url: String, sourceId: Long): Manga? {
return mangaRepository.getMangaByUrlAndSourceId(url, sourceId)
}
fun subscribe(url: String, sourceId: Long): Flow<Manga?> {
return mangaRepository.getMangaByUrlAndSourceIdAsFlow(url, sourceId)
}
}

View File

@ -13,6 +13,8 @@ interface MangaRepository {
suspend fun getMangaByUrlAndSourceId(url: String, sourceId: Long): Manga?
fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Manga?>
suspend fun getFavorites(): List<Manga>
suspend fun getLibraryManga(): List<LibraryManga>

View File

@ -0,0 +1,67 @@
package eu.kanade.presentation.browse
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.paging.compose.collectAsLazyPagingItems
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.browse.components.BrowseLatestToolbar
import eu.kanade.presentation.components.Scaffold
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
@Composable
fun BrowseLatestScreen(
presenter: BrowseSourcePresenter,
navigateUp: () -> Unit,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
val columns by presenter.getColumnsPreferenceForCurrentOrientation()
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val onHelpClick = {
uriHandler.openUri(LocalSource.HELP_URL)
}
val onWebViewClick = f@{
val source = presenter.source as? HttpSource ?: return@f
val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
context.startActivity(intent)
}
Scaffold(
topBar = {
BrowseLatestToolbar(
navigateUp = navigateUp,
source = presenter.source!!,
displayMode = presenter.displayMode,
onDisplayModeChange = { presenter.displayMode = it },
onHelpClick = onHelpClick,
onWebViewClick = onWebViewClick,
)
},
) { paddingValues ->
BrowseSourceContent(
source = presenter.source,
mangaList = presenter.getMangaList().collectAsLazyPagingItems(),
getMangaState = { presenter.getManga(it) },
columns = columns,
displayMode = presenter.displayMode,
snackbarHostState = remember { SnackbarHostState() },
contentPadding = paddingValues,
onWebViewClick = onWebViewClick,
onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
onLocalSourceHelpClick = onHelpClick,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
}

View File

@ -0,0 +1,210 @@
package eu.kanade.presentation.browse
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.browse.components.BrowseSourceComfortableGrid
import eu.kanade.presentation.browse.components.BrowseSourceCompactGrid
import eu.kanade.presentation.browse.components.BrowseSourceList
import eu.kanade.presentation.browse.components.BrowseSourceToolbar
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.Scaffold
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.NoResultsException
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.widget.EmptyView
@Composable
fun BrowseSourceScreen(
presenter: BrowseSourcePresenter,
navigateUp: () -> Unit,
onDisplayModeChange: (LibraryDisplayMode) -> Unit,
onFabClick: () -> Unit,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
val columns by presenter.getColumnsPreferenceForCurrentOrientation()
val mangaList = presenter.getMangaList().collectAsLazyPagingItems()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val onHelpClick = {
uriHandler.openUri(LocalSource.HELP_URL)
}
val onWebViewClick = f@{
val source = presenter.source as? HttpSource ?: return@f
val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
context.startActivity(intent)
}
Scaffold(
topBar = {
BrowseSourceToolbar(
state = presenter,
source = presenter.source!!,
displayMode = presenter.displayMode,
onDisplayModeChange = onDisplayModeChange,
navigateUp = navigateUp,
onWebViewClick = onWebViewClick,
onHelpClick = onHelpClick,
onSearch = { presenter.search() },
)
},
floatingActionButton = {
if (presenter.filters.isNotEmpty()) {
ExtendedFloatingActionButton(
modifier = Modifier.navigationBarsPadding(),
text = { Text(text = stringResource(id = R.string.action_filter)) },
icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
onClick = onFabClick,
)
}
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
) { paddingValues ->
BrowseSourceContent(
source = presenter.source,
mangaList = mangaList,
getMangaState = { presenter.getManga(it) },
columns = columns,
displayMode = presenter.displayMode,
snackbarHostState = snackbarHostState,
contentPadding = paddingValues,
onWebViewClick = onWebViewClick,
onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
onLocalSourceHelpClick = onHelpClick,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
}
@Composable
fun BrowseSourceContent(
source: CatalogueSource?,
mangaList: LazyPagingItems<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
columns: GridCells,
displayMode: LibraryDisplayMode,
snackbarHostState: SnackbarHostState,
contentPadding: PaddingValues,
onWebViewClick: () -> Unit,
onHelpClick: () -> Unit,
onLocalSourceHelpClick: () -> Unit,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
val context = LocalContext.current
val errorState = mangaList.loadState.refresh.takeIf { it is LoadState.Error }
?: mangaList.loadState.append.takeIf { it is LoadState.Error }
val getErrorMessage: (LoadState.Error) -> String = { state ->
when {
state.error is NoResultsException -> context.getString(R.string.no_results_found)
state.error.message == null -> ""
state.error.message!!.startsWith("HTTP error") -> "${state.error.message}: ${context.getString(R.string.http_error_hint)}"
else -> state.error.message!!
}
}
LaunchedEffect(errorState) {
if (mangaList.itemCount > 0 && errorState != null && errorState is LoadState.Error) {
val result = snackbarHostState.showSnackbar(
message = getErrorMessage(errorState),
actionLabel = context.getString(R.string.action_webview_refresh),
duration = SnackbarDuration.Indefinite,
)
when (result) {
SnackbarResult.Dismissed -> snackbarHostState.currentSnackbarData?.dismiss()
SnackbarResult.ActionPerformed -> mangaList.refresh()
}
}
}
if (mangaList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) {
EmptyScreen(
message = getErrorMessage(errorState),
actions = if (source is LocalSource) {
listOf(
EmptyView.Action(R.string.local_source_help_guide, R.drawable.ic_help_24dp) { onLocalSourceHelpClick() },
)
} else {
listOf(
EmptyView.Action(R.string.action_retry, R.drawable.ic_refresh_24dp) { mangaList.refresh() },
EmptyView.Action(R.string.action_open_in_web_view, R.drawable.ic_public_24dp) { onWebViewClick() },
EmptyView.Action(R.string.label_help, R.drawable.ic_help_24dp) { onHelpClick() },
)
},
)
return
}
when (displayMode) {
LibraryDisplayMode.ComfortableGrid -> {
BrowseSourceComfortableGrid(
mangaList = mangaList,
getMangaState = getMangaState,
columns = columns,
contentPadding = contentPadding,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
LibraryDisplayMode.List -> {
BrowseSourceList(
mangaList = mangaList,
getMangaState = getMangaState,
contentPadding = contentPadding,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
else -> {
BrowseSourceCompactGrid(
mangaList = mangaList,
getMangaState = getMangaState,
columns = columns,
contentPadding = contentPadding,
onMangaClick = onMangaClick,
onMangaLongClick = onMangaLongClick,
)
}
}
}

View File

@ -0,0 +1,37 @@
package eu.kanade.presentation.browse
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.toItems
@Stable
interface BrowseSourceState {
val source: CatalogueSource?
var searchQuery: String?
val currentQuery: String
val filters: FilterList
val filterItems: List<IFlexible<*>>
val appliedFilters: FilterList
var dialog: BrowseSourcePresenter.Dialog?
}
fun BrowseSourceState(initialQuery: String?): BrowseSourceState {
return BrowseSourceStateImpl(initialQuery)
}
class BrowseSourceStateImpl(initialQuery: String?) : BrowseSourceState {
override var source: CatalogueSource? by mutableStateOf(null)
override var searchQuery: String? by mutableStateOf(initialQuery)
override var currentQuery: String by mutableStateOf(initialQuery ?: "")
override var filters: FilterList by mutableStateOf(FilterList())
override val filterItems: List<IFlexible<*>> by derivedStateOf { filters.toItems() }
override var appliedFilters by mutableStateOf(FilterList())
override var dialog: BrowseSourcePresenter.Dialog? by mutableStateOf(null)
}

View File

@ -0,0 +1,22 @@
package eu.kanade.presentation.browse
import androidx.compose.runtime.Composable
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
@Composable
fun SourceSearchScreen(
presenter: BrowseSourcePresenter,
navigateUp: () -> Unit,
onFabClick: () -> Unit,
onClickManga: (Manga) -> Unit,
) {
BrowseSourceScreen(
presenter = presenter,
navigateUp = navigateUp,
onDisplayModeChange = { presenter.displayMode = (it) },
onFabClick = onFabClick,
onMangaClick = onClickManga,
onMangaLongClick = onClickManga,
)
}

View File

@ -0,0 +1,105 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Help
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.ViewModule
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
@Composable
fun BrowseLatestToolbar(
navigateUp: () -> Unit,
source: CatalogueSource,
displayMode: LibraryDisplayMode,
onDisplayModeChange: (LibraryDisplayMode) -> Unit,
onHelpClick: () -> Unit,
onWebViewClick: () -> Unit,
) {
AppBar(
navigateUp = navigateUp,
title = source.name,
actions = {
var selectingDisplayMode by remember { mutableStateOf(false) }
AppBarActions(
actions = listOf(
AppBar.Action(
title = "display_mode",
icon = Icons.Filled.ViewModule,
onClick = { selectingDisplayMode = true },
),
if (source is LocalSource) {
AppBar.Action(
title = "help",
icon = Icons.Outlined.Help,
onClick = onHelpClick,
)
} else {
AppBar.Action(
title = "webview",
icon = Icons.Outlined.Public,
onClick = onWebViewClick,
)
},
),
)
DropdownMenu(
expanded = selectingDisplayMode,
onDismissRequest = { selectingDisplayMode = false },
) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_comfortable_grid)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.ComfortableGrid) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.ComfortableGrid) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_grid)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.CompactGrid) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.CompactGrid) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_list)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.List) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.List) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
}
},
)
}

View File

@ -0,0 +1,106 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
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.presentation.components.Badge
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.library.components.MangaGridComfortableText
import eu.kanade.presentation.library.components.MangaGridCover
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
@Composable
fun BrowseSourceComfortableGrid(
mangaList: LazyPagingItems<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
columns: GridCells,
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
LazyVerticalGrid(
columns = columns,
contentPadding = PaddingValues(8.dp) + contentPadding,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
if (mangaList.loadState.prepend is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
items(mangaList.itemCount) { index ->
val initialManga = mangaList[index] ?: return@items
val manga by getMangaState(initialManga)
BrowseSourceComfortableGridItem(
manga = manga,
onClick = { onMangaClick(manga) },
onLongClick = { onMangaLongClick(manga) },
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
if (mangaList.loadState.refresh is LoadState.Loading || mangaList.loadState.append is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
}
}
@Composable
fun BrowseSourceComfortableGridItem(
manga: Manga,
onClick: () -> Unit = {},
onLongClick: () -> Unit = onClick,
) {
val overlayColor = MaterialTheme.colorScheme.background.copy(alpha = 0.66f)
Column(
modifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
) {
MangaGridCover(
cover = {
MangaCover.Book(
modifier = Modifier
.fillMaxWidth()
.drawWithContent {
drawContent()
if (manga.favorite) {
drawRect(overlayColor)
}
},
data = manga.thumbnailUrl,
)
},
badgesStart = {
if (manga.favorite) {
Badge(text = stringResource(id = R.string.in_library))
}
},
)
MangaGridComfortableText(
text = manga.title,
)
}
}

View File

@ -0,0 +1,129 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.presentation.components.Badge
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.library.components.MangaGridCompactText
import eu.kanade.presentation.library.components.MangaGridCover
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
@Composable
fun BrowseSourceCompactGrid(
mangaList: LazyPagingItems<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
columns: GridCells,
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
LazyVerticalGrid(
columns = columns,
contentPadding = PaddingValues(8.dp) + contentPadding,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
if (mangaList.loadState.prepend is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
items(mangaList.itemCount) { index ->
val initialManga = mangaList[index] ?: return@items
val manga by getMangaState(initialManga)
BrowseSourceCompactGridItem(
manga = manga,
onClick = { onMangaClick(manga) },
onLongClick = { onMangaLongClick(manga) },
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
if (mangaList.loadState.refresh is LoadState.Loading || mangaList.loadState.append is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
}
}
@Composable
fun BrowseSourceCompactGridItem(
manga: Manga,
onClick: () -> Unit = {},
onLongClick: () -> Unit = onClick,
) {
val overlayColor = MaterialTheme.colorScheme.background.copy(alpha = 0.66f)
MangaGridCover(
modifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
cover = {
MangaCover.Book(
modifier = Modifier
.fillMaxHeight()
.drawWithContent {
drawContent()
if (manga.favorite) {
drawRect(overlayColor)
}
},
data = eu.kanade.domain.manga.model.MangaCover(
manga.id,
manga.source,
manga.favorite,
manga.thumbnailUrl,
manga.coverLastModified,
),
)
},
badgesStart = {
if (manga.favorite) {
Badge(text = stringResource(id = R.string.in_library))
}
},
content = {
Box(
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xAA000000),
),
)
.fillMaxHeight(0.33f)
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
MangaGridCompactText(manga.title)
},
)
}

View File

@ -0,0 +1,41 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material.TextButton
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import eu.kanade.tachiyomi.R
@Composable
fun RemoveMangaDialog(
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
confirmButton = {
TextButton(
onClick = {
onDismissRequest()
onConfirm()
},
) {
Text(text = stringResource(id = R.string.action_remove))
}
},
title = {
Text(text = stringResource(id = R.string.are_you_sure))
},
text = {
Text(text = stringResource(R.string.remove_manga))
},
)
}

View File

@ -0,0 +1,94 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.res.stringResource
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.items
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.Badge
import eu.kanade.presentation.components.LazyColumn
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.library.components.MangaListItem
import eu.kanade.presentation.library.components.MangaListItemContent
import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.verticalPadding
import eu.kanade.tachiyomi.R
@Composable
fun BrowseSourceList(
mangaList: LazyPagingItems<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
LazyColumn(
contentPadding = contentPadding,
) {
item {
if (mangaList.loadState.prepend is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
items(mangaList) { initialManga ->
initialManga ?: return@items
val manga by getMangaState(initialManga)
BrowseSourceListItem(
manga = manga,
onClick = { onMangaClick(manga) },
onLongClick = { onMangaLongClick(manga) },
)
}
item {
if (mangaList.loadState.refresh is LoadState.Loading || mangaList.loadState.append is LoadState.Loading) {
BrowseSourceLoadingItem()
}
}
}
}
@Composable
fun BrowseSourceListItem(
manga: Manga,
onClick: () -> Unit = {},
onLongClick: () -> Unit = onClick,
) {
val overlayColor = MaterialTheme.colorScheme.background.copy(alpha = 0.66f)
MangaListItem(
coverContent = {
MangaCover.Square(
modifier = Modifier
.padding(vertical = verticalPadding)
.fillMaxHeight()
.drawWithContent {
drawContent()
if (manga.favorite) {
drawRect(overlayColor)
}
},
data = manga.thumbnailUrl,
)
},
onClick = onClick,
onLongClick = onLongClick,
badges = {
if (manga.favorite) {
Badge(text = stringResource(id = R.string.in_library))
}
},
content = {
MangaListItemContent(text = manga.title)
},
)
}

View File

@ -0,0 +1,25 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.Arrangement
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.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun BrowseSourceLoadingItem() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
)
}
}

View File

@ -0,0 +1,206 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Help
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import eu.kanade.presentation.browse.BrowseSourceState
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
import kotlinx.coroutines.delay
@Composable
fun BrowseSourceToolbar(
state: BrowseSourceState,
source: CatalogueSource,
displayMode: LibraryDisplayMode,
onDisplayModeChange: (LibraryDisplayMode) -> Unit,
navigateUp: () -> Unit,
onWebViewClick: () -> Unit,
onHelpClick: () -> Unit,
onSearch: () -> Unit,
) {
if (state.searchQuery == null) {
BrowseSourceRegularToolbar(
source = source,
displayMode = displayMode,
onDisplayModeChange = onDisplayModeChange,
navigateUp = navigateUp,
onSearchClick = { state.searchQuery = "" },
onWebViewClick = onWebViewClick,
onHelpClick = onHelpClick,
)
} else {
BrowseSourceSearchToolbar(
searchQuery = state.searchQuery!!,
onSearchQueryChanged = { state.searchQuery = it },
navigateUp = {
state.searchQuery = null
onSearch()
},
onResetClick = { state.searchQuery = "" },
onSearchClick = onSearch,
)
}
}
@Composable
fun BrowseSourceRegularToolbar(
source: CatalogueSource,
displayMode: LibraryDisplayMode,
onDisplayModeChange: (LibraryDisplayMode) -> Unit,
navigateUp: () -> Unit,
onSearchClick: () -> Unit,
onWebViewClick: () -> Unit,
onHelpClick: () -> Unit,
) {
AppBar(
navigateUp = navigateUp,
title = source.name,
actions = {
var selectingDisplayMode by remember { mutableStateOf(false) }
AppBarActions(
actions = listOf(
AppBar.Action(
title = "search",
icon = Icons.Outlined.Search,
onClick = onSearchClick,
),
AppBar.Action(
title = "display_mode",
icon = Icons.Filled.ViewModule,
onClick = { selectingDisplayMode = true },
),
if (source is LocalSource) {
AppBar.Action(
title = "help",
icon = Icons.Outlined.Help,
onClick = onHelpClick,
)
} else {
AppBar.Action(
title = "webview",
icon = Icons.Outlined.Public,
onClick = onWebViewClick,
)
},
),
)
DropdownMenu(
expanded = selectingDisplayMode,
onDismissRequest = { selectingDisplayMode = false },
) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_comfortable_grid)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.ComfortableGrid) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.ComfortableGrid) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_grid)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.CompactGrid) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.CompactGrid) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.action_display_list)) },
onClick = { onDisplayModeChange(LibraryDisplayMode.List) },
trailingIcon = {
if (displayMode == LibraryDisplayMode.List) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "",
)
}
},
)
}
},
)
}
@Composable
fun BrowseSourceSearchToolbar(
searchQuery: String,
onSearchQueryChanged: (String) -> Unit,
navigateUp: () -> Unit,
onResetClick: () -> Unit,
onSearchClick: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
AppBar(
navigateUp = navigateUp,
titleContent = {
BasicTextField(
value = searchQuery,
onValueChange = onSearchQueryChanged,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
onSearchClick()
},
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface),
)
},
actions = {
AppBarActions(
actions = listOf(
AppBar.Action(
title = "clear",
icon = Icons.Outlined.Clear,
onClick = onResetClick,
),
),
)
},
)
LaunchedEffect(Unit) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(100)
focusRequester.requestFocus()
}
}

View File

@ -1,4 +1,4 @@
package eu.kanade.presentation.manga.components
package eu.kanade.presentation.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer

View File

@ -78,13 +78,22 @@ fun LibraryComfortableGridItem(
isLocal = item.isLocal,
language = item.sourceLanguage,
)
Text(
modifier = Modifier.padding(4.dp),
MangaGridComfortableText(
text = manga.title,
fontSize = 12.sp,
maxLines = 2,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
@Composable
fun MangaGridComfortableText(
text: String,
) {
Text(
modifier = Modifier.padding(4.dp),
text = text,
fontSize = 12.sp,
maxLines = 2,
style = MaterialTheme.typography.titleSmall,
)
}

View File

@ -3,6 +3,7 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -97,8 +98,16 @@ fun LibraryCompactGridItem(
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
MangaGridCompactText(manga.title)
}
}
@Composable
fun BoxScope.MangaGridCompactText(
text: String,
) {
Text(
text = manga.title,
text = text,
modifier = Modifier
.padding(8.dp)
.align(Alignment.BottomStart),
@ -112,5 +121,4 @@ fun LibraryCompactGridItem(
),
),
)
}
}

View File

@ -2,6 +2,7 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -16,6 +17,41 @@ import eu.kanade.presentation.components.BadgeGroup
import eu.kanade.presentation.components.MangaCover
import eu.kanade.tachiyomi.R
@Composable
fun MangaGridCover(
modifier: Modifier = Modifier,
cover: @Composable BoxScope.() -> Unit = {},
badgesStart: (@Composable RowScope.() -> Unit)? = null,
badgesEnd: (@Composable RowScope.() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio),
) {
cover()
content()
if (badgesStart != null) {
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopStart),
content = badgesStart,
)
}
if (badgesEnd != null) {
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopEnd),
content = badgesEnd,
)
}
}
}
@Composable
fun LibraryGridCover(
modifier: Modifier = Modifier,
@ -26,22 +62,15 @@ fun LibraryGridCover(
language: String,
content: @Composable BoxScope.() -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio),
) {
MangaGridCover(
modifier = modifier,
cover = {
MangaCover.Book(
modifier = Modifier.fillMaxWidth(),
data = mangaCover,
)
content()
if (downloadCount > 0 || unreadCount > 0) {
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopStart),
) {
},
badgesStart = {
if (downloadCount > 0) {
Badge(
text = "$downloadCount",
@ -52,14 +81,8 @@ fun LibraryGridCover(
if (unreadCount > 0) {
Badge(text = "$unreadCount")
}
}
}
if (isLocal || language.isNotEmpty()) {
BadgeGroup(
modifier = Modifier
.padding(4.dp)
.align(Alignment.TopEnd),
) {
},
badgesEnd = {
if (isLocal) {
Badge(
text = stringResource(R.string.local_source_badge),
@ -73,7 +96,7 @@ fun LibraryGridCover(
textColor = MaterialTheme.colorScheme.onTertiary,
)
}
}
}
}
},
content = content,
)
}

View File

@ -2,6 +2,7 @@ package eu.kanade.presentation.library.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
@ -19,6 +20,7 @@ import eu.kanade.domain.manga.model.MangaCover
import eu.kanade.presentation.components.Badge
import eu.kanade.presentation.components.BadgeGroup
import eu.kanade.presentation.components.FastScrollLazyColumn
import eu.kanade.presentation.components.MangaCover.Square
import eu.kanade.presentation.components.TextButton
import eu.kanade.presentation.util.bottomNavPaddingValues
import eu.kanade.presentation.util.horizontalPadding
@ -74,38 +76,19 @@ fun LibraryListItem(
onLongClick: (LibraryManga) -> Unit,
) {
val manga = item.manga
Row(
modifier = Modifier
.selectedBackground(isSelected)
.height(56.dp)
.combinedClickable(
onClick = { onClick(manga) },
onLongClick = { onLongClick(manga) },
)
.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
) {
eu.kanade.presentation.components.MangaCover.Square(
modifier = Modifier
.padding(vertical = verticalPadding)
.fillMaxHeight(),
data = MangaCover(
MangaListItem(
modifier = Modifier.selectedBackground(isSelected),
title = manga.title,
cover = MangaCover(
manga.id!!,
manga.source,
manga.favorite,
manga.thumbnail_url,
manga.cover_last_modified,
),
)
Text(
text = manga.title,
modifier = Modifier
.padding(horizontal = horizontalPadding)
.weight(1f),
maxLines = 2,
style = MaterialTheme.typography.bodyMedium,
)
BadgeGroup {
onClick = { onClick(manga) },
onLongClick = { onLongClick(manga) },
) {
if (item.downloadCount > 0) {
Badge(
text = "${item.downloadCount}",
@ -131,5 +114,71 @@ fun LibraryListItem(
)
}
}
}
@Composable
fun MangaListItem(
modifier: Modifier = Modifier,
title: String,
cover: MangaCover,
onClick: () -> Unit,
onLongClick: () -> Unit = onClick,
badges: @Composable RowScope.() -> Unit,
) {
MangaListItem(
modifier = modifier,
coverContent = {
Square(
modifier = Modifier
.padding(vertical = verticalPadding)
.fillMaxHeight(),
data = cover,
)
},
badges = badges,
onClick = onClick,
onLongClick = onLongClick,
content = {
MangaListItemContent(title)
},
)
}
@Composable
fun MangaListItem(
modifier: Modifier = Modifier,
coverContent: @Composable RowScope.() -> Unit,
badges: @Composable RowScope.() -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier = modifier
.height(56.dp)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
) {
coverContent()
content()
BadgeGroup(content = badges)
}
}
@Composable
fun RowScope.MangaListItemContent(
text: String,
) {
Text(
text = text,
modifier = Modifier
.padding(horizontal = horizontalPadding)
.weight(1f),
maxLines = 2,
style = MaterialTheme.typography.bodyMedium,
)
}

View File

@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import java.io.File
import java.io.IOException
import java.io.InputStream
import eu.kanade.domain.manga.model.Manga as DomainManga
/**
* Class used to create cover cache.
@ -87,6 +88,20 @@ class CoverCache(private val context: Context) {
return deleted
}
fun deleteFromCache(manga: DomainManga, deleteCustomCover: Boolean = false): Int {
var amountDeleted = 0
getCoverFile(manga.thumbnailUrl)?.let {
if (it.exists() && it.delete()) amountDeleted++
}
if (deleteCustomCover && deleteCustomCover(manga.id)) {
amountDeleted++
}
return amountDeleted
}
/**
* Delete custom cover of the manga from the cache
*

View File

@ -1,11 +1,13 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.core.os.bundleOf
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.browse.SourceSearchScreen
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceItem
import eu.kanade.tachiyomi.util.system.getSerializableCompat
class SourceSearchController(
@ -13,30 +15,34 @@ class SourceSearchController(
) : BrowseSourceController(bundle) {
constructor(manga: Manga? = null, source: CatalogueSource, searchQuery: String? = null) : this(
Bundle().apply {
putLong(SOURCE_ID_KEY, source.id)
putSerializable(MANGA_KEY, manga)
if (searchQuery != null) {
putString(SEARCH_QUERY_KEY, searchQuery)
}
},
bundleOf(
SOURCE_ID_KEY to source.id,
MANGA_KEY to manga,
SEARCH_QUERY_KEY to searchQuery,
),
)
private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY)
private var newManga: Manga? = null
override fun onItemClick(view: View, position: Int): Boolean {
val item = adapter?.getItem(position) as? SourceItem ?: return false
newManga = item.manga
@Composable
override fun ComposeContent() {
SourceSearchScreen(
presenter = presenter,
navigateUp = { router.popCurrentController() },
onFabClick = { filterSheet?.show() },
onClickManga = {
newManga = it
val searchController = router.backstack.findLast { it.controller.javaClass == SearchController::class.java }?.controller as SearchController?
val dialog =
SearchController.MigrationDialog(oldManga, newManga, this)
val dialog = SearchController.MigrationDialog(oldManga, newManga, this)
dialog.targetController = searchController
dialog.showDialog(router)
return true
}
},
)
override fun onItemLongClick(position: Int) {
view?.let { super.onItemClick(it, position) }
LaunchedEffect(presenter.filters) {
initFilterSheet()
}
}
}

View File

@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import androidx.paging.PagingSource
import androidx.paging.PagingState
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.withIOContext
abstract class BrowsePagingSource : PagingSource<Long, SManga>() {
abstract suspend fun requestNextPage(currentPage: Int): MangasPage
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, SManga> {
val page = params.key ?: 1
val mangasPage = try {
withIOContext {
requestNextPage(page.toInt())
}
} catch (e: Exception) {
return LoadResult.Error(e)
}
return LoadResult.Page(
data = mangasPage.mangas,
prevKey = null,
nextKey = if (mangasPage.hasNextPage) page + 1 else null,
)
}
override fun getRefreshKey(state: PagingState<Long, SManga>): Long? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey ?: anchorPage?.nextKey
}
}
}

View File

@ -1,328 +1,125 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.content.res.Configuration
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 android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.fredporciuncula.flow.preferences.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.snackbar.Snackbar
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.os.bundleOf
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.SourceControllerBinding
import eu.kanade.presentation.browse.BrowseSourceScreen
import eu.kanade.presentation.browse.components.RemoveMangaDialog
import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.DuplicateMangaDialog
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.AddDuplicateMangaDialog
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Dialog
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.preference.asHotFlow
import eu.kanade.tachiyomi.util.system.connectivityManager
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.inflate
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import eu.kanade.tachiyomi.widget.EmptyView
import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import logcat.LogPriority
import uy.kohesive.injekt.injectLazy
open class BrowseSourceController(bundle: Bundle) :
SearchableNucleusController<SourceControllerBinding, BrowseSourcePresenter>(bundle),
FabController,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.EndlessScrollListener,
ChangeMangaCategoriesDialog.Listener {
FullComposeController<BrowseSourcePresenter>(bundle) {
constructor(sourceId: Long, query: String? = null) : this(
Bundle().apply {
putLong(SOURCE_ID_KEY, sourceId)
query?.let { query ->
putString(SEARCH_QUERY_KEY, query)
}
},
bundleOf(
SOURCE_ID_KEY to sourceId,
SEARCH_QUERY_KEY to query,
),
)
constructor(source: CatalogueSource, query: String? = null) : this(source.id, query)
constructor(source: Source, query: String? = null) : this(source.id, query)
private val preferences: PreferencesHelper by injectLazy()
/**
* Adapter containing the list of manga from the catalogue.
*/
protected var adapter: FlexibleAdapter<IFlexible<*>>? = null
private var actionFab: ExtendedFloatingActionButton? = null
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
/**
* Snackbar containing an error message when a request fails.
*/
private var snack: Snackbar? = null
/**
* Sheet containing filter items.
*/
private var filterSheet: SourceFilterSheet? = null
protected var filterSheet: SourceFilterSheet? = null
/**
* Recycler view with the list of results.
*/
private var recycler: RecyclerView? = null
@Composable
override fun ComposeContent() {
val scope = rememberCoroutineScope()
/**
* Subscription for the number of manga per row.
*/
private var numColumnsJob: Job? = null
BrowseSourceScreen(
presenter = presenter,
navigateUp = { router.popCurrentController() },
onDisplayModeChange = { presenter.displayMode = (it) },
onFabClick = { filterSheet?.show() },
onMangaClick = { router.pushController(MangaController(it.id, true)) },
onMangaLongClick = { manga ->
scope.launchIO {
val duplicateManga = presenter.getDuplicateLibraryManga(manga)
when {
manga.favorite -> presenter.dialog = Dialog.RemoveManga(manga)
duplicateManga != null -> presenter.dialog = Dialog.AddDuplicateManga(manga, duplicateManga)
else -> presenter.addFavorite(manga)
}
}
},
)
/**
* Endless loading item.
*/
private var progressItem: ProgressItem? = null
init {
setHasOptionsMenu(true)
val onDismissRequest = { presenter.dialog = null }
when (val dialog = presenter.dialog) {
is Dialog.AddDuplicateManga -> {
DuplicateMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = { presenter.addFavorite(dialog.manga) },
onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) },
duplicateFrom = presenter.getSourceOrStub(dialog.duplicate),
)
}
is Dialog.RemoveManga -> {
RemoveMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
presenter.changeMangaFavorite(dialog.manga)
},
)
}
is Dialog.ChangeMangaCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
router.pushController(CategoryController())
},
onConfirm = { include, _ ->
presenter.changeMangaFavorite(dialog.manga)
presenter.moveMangaToCategories(dialog.manga, include)
},
)
}
null -> {}
}
override fun getTitle(): String? {
return presenter.source.name
LaunchedEffect(presenter.filters) {
initFilterSheet()
}
}
override fun createPresenter(): BrowseSourcePresenter {
return BrowseSourcePresenter(args.getLong(SOURCE_ID_KEY), args.getString(SEARCH_QUERY_KEY))
}
override fun createBinding(inflater: LayoutInflater) = SourceControllerBinding.inflate(inflater)
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// Initialize adapter, scroll listener and recycler views
adapter = FlexibleAdapter(null, this)
setupRecycler(view)
binding.progress.isVisible = true
presenter.restartPager()
}
open fun initFilterSheet() {
if (presenter.sourceFilters.isEmpty()) {
if (presenter.filters.isEmpty()) {
return
}
filterSheet = SourceFilterSheet(
activity!!,
onFilterClicked = {
showProgressBar()
adapter?.clear()
presenter.setSourceFilter(presenter.sourceFilters)
presenter.setSourceFilter(presenter.filters)
},
onResetClicked = {
presenter.appliedFilters = FilterList()
val newFilters = presenter.source.getFilterList()
presenter.sourceFilters = newFilters
presenter.resetFilter()
filterSheet?.setFilters(presenter.filterItems)
},
)
filterSheet?.setFilters(presenter.filterItems)
filterSheet?.setOnShowListener { actionFab?.hide() }
filterSheet?.setOnDismissListener { actionFab?.show() }
actionFab?.setOnClickListener { filterSheet?.show() }
actionFab?.show()
}
override fun configureFab(fab: ExtendedFloatingActionButton) {
actionFab = fab
fab.setText(R.string.action_filter)
fab.setIconResource(R.drawable.ic_filter_list_24dp)
// Controlled by initFilterSheet()
fab.hide()
initFilterSheet()
}
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
fab.setOnClickListener(null)
actionFabScrollListener?.let { recycler?.removeOnScrollListener(it) }
actionFab = null
}
override fun onDestroyView(view: View) {
numColumnsJob?.cancel()
numColumnsJob = null
adapter = null
snack = null
recycler = null
super.onDestroyView(view)
}
private fun setupRecycler(view: View) {
numColumnsJob?.cancel()
var oldPosition = RecyclerView.NO_POSITION
val oldRecycler = binding.catalogueView.getChildAt(1)
if (oldRecycler is RecyclerView) {
oldPosition = (oldRecycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
oldRecycler.adapter = null
binding.catalogueView.removeView(oldRecycler)
}
val recycler = if (preferences.sourceDisplayMode().get() == LibraryDisplayMode.List) {
RecyclerView(view.context).apply {
id = R.id.recycler
layoutManager = LinearLayoutManager(context)
layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
} else {
(binding.catalogueView.inflate(R.layout.source_recycler_autofit) as AutofitRecyclerView).apply {
numColumnsJob = getColumnsPreferenceForCurrentOrientation().asHotFlow { spanCount = it }
.drop(1)
// Set again the adapter to recalculate the covers height
.onEach { adapter = this@BrowseSourceController.adapter }
.launchIn(viewScope)
(layoutManager as GridLayoutManager).spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (adapter?.getItemViewType(position)) {
R.layout.source_compact_grid_item, R.layout.source_comfortable_grid_item -> 1
else -> spanCount
}
}
}
}
}
if (filterSheet != null) {
// Add bottom padding if filter FAB is visible
recycler.updatePadding(bottom = view.resources.getDimensionPixelOffset(R.dimen.fab_list_padding))
recycler.clipToPadding = false
actionFab?.shrinkOnScroll(recycler)
}
recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
recycler.setHasFixedSize(true)
recycler.adapter = adapter
binding.catalogueView.addView(recycler, 1)
if (oldPosition != RecyclerView.NO_POSITION) {
recycler.layoutManager?.scrollToPosition(oldPosition)
}
this.recycler = recycler
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
createOptionsMenu(menu, inflater, R.menu.source_browse, R.id.action_search)
val searchItem = menu.findItem(R.id.action_search)
searchItem.fixExpand(
onExpand = { invalidateMenuOnExpand() },
onCollapse = {
if (router.backstackSize >= 2 && router.backstack[router.backstackSize - 2].controller is GlobalSearchController) {
router.popController(this)
} else {
nonSubmittedQuery = ""
searchWithQuery("")
}
true
},
)
val displayItem = when (preferences.sourceDisplayMode().get()) {
LibraryDisplayMode.List -> R.id.action_list
LibraryDisplayMode.ComfortableGrid -> R.id.action_comfortable_grid
else -> R.id.action_compact_grid
}
menu.findItem(displayItem).isChecked = true
}
override fun onSearchViewQueryTextSubmit(query: String?) {
searchWithQuery(query ?: "")
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
val isHttpSource = presenter.source is HttpSource
menu.findItem(R.id.action_open_in_web_view).isVisible = isHttpSource
val isLocalSource = presenter.source is LocalSource
menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_search -> expandActionViewFromInteraction = true
R.id.action_compact_grid -> setDisplayMode(LibraryDisplayMode.CompactGrid)
R.id.action_comfortable_grid -> setDisplayMode(LibraryDisplayMode.ComfortableGrid)
R.id.action_list -> setDisplayMode(LibraryDisplayMode.List)
R.id.action_open_in_web_view -> openInWebView()
R.id.action_local_source_help -> openLocalSourceHelpGuide()
}
return super.onOptionsItemSelected(item)
}
private fun openInWebView() {
val source = presenter.source as? HttpSource ?: return
val activity = activity ?: return
val intent = WebViewActivity.newIntent(activity, source.baseUrl, source.id, presenter.source.name)
startActivity(intent)
}
private fun openLocalSourceHelpGuide() {
activity?.openInBrowser(LocalSource.HELP_URL)
}
/**
@ -331,15 +128,8 @@ open class BrowseSourceController(bundle: Bundle) :
* @param newQuery the new query.
*/
fun searchWithQuery(newQuery: String) {
// If text didn't change, do nothing
if (presenter.query == newQuery) {
return
}
showProgressBar()
adapter?.clear()
presenter.restartPager(newQuery, presenter.sourceFilters)
presenter.searchQuery = newQuery
presenter.search()
}
/**
@ -350,7 +140,7 @@ open class BrowseSourceController(bundle: Bundle) :
* @param genreName the name of the genre
*/
fun searchWithGenre(genreName: String) {
val defaultFilters = presenter.source.getFilterList()
val defaultFilters = presenter.source!!.getFilterList()
var genreExists = false
@ -380,320 +170,15 @@ open class BrowseSourceController(bundle: Bundle) :
}
if (genreExists) {
presenter.sourceFilters = defaultFilters
filterSheet?.setFilters(presenter.filterItems)
showProgressBar()
adapter?.clear()
presenter.restartPager("", defaultFilters)
presenter.searchQuery = ""
presenter.setFilter(defaultFilters)
} else {
searchWithQuery(genreName)
}
}
/**
* Called from the presenter when the network request is received.
*
* @param page the current page.
* @param mangas the list of manga of the page.
*/
fun onAddPage(page: Int, mangas: List<SourceItem>) {
val adapter = adapter ?: return
hideProgressBar()
if (page == 1) {
adapter.clear()
resetProgressItem()
}
adapter.onLoadMoreComplete(mangas)
}
/**
* Called from the presenter when the network request fails.
*
* @param error the error received.
*/
fun onAddPageError(error: Throwable) {
logcat(LogPriority.ERROR, error)
val adapter = adapter ?: return
adapter.onLoadMoreComplete(null)
hideProgressBar()
snack?.dismiss()
val message = getErrorMessage(error)
val retryAction = View.OnClickListener {
// If not the first page, show bottom progress bar.
if (adapter.mainItemCount > 0 && progressItem != null) {
adapter.addScrollableFooterWithDelay(progressItem!!, 0, true)
} else {
showProgressBar()
}
presenter.requestNext()
}
if (adapter.isEmpty) {
val actions = if (presenter.source is LocalSource) {
listOf(
EmptyView.Action(R.string.local_source_help_guide, R.drawable.ic_help_24dp) { openLocalSourceHelpGuide() },
)
} else {
listOf(
EmptyView.Action(R.string.action_retry, R.drawable.ic_refresh_24dp, retryAction),
EmptyView.Action(R.string.action_open_in_web_view, R.drawable.ic_public_24dp) { openInWebView() },
EmptyView.Action(R.string.label_help, R.drawable.ic_help_24dp) { activity?.openInBrowser(MoreController.URL_HELP) },
)
}
binding.emptyView.show(message, actions)
} else {
snack = (activity as? MainActivity)?.binding?.rootCoordinator?.snack(message, Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_retry, retryAction)
}
}
}
private fun getErrorMessage(error: Throwable): String {
if (error is NoResultsException) {
return binding.catalogueView.context.getString(R.string.no_results_found)
}
return when {
error.message == null -> ""
error.message!!.startsWith("HTTP error") -> "${error.message}: ${binding.catalogueView.context.getString(R.string.http_error_hint)}"
else -> error.message!!
}
}
/**
* Sets a new progress item and reenables the scroll listener.
*/
private fun resetProgressItem() {
progressItem = ProgressItem()
adapter?.endlessTargetCount = 0
adapter?.setEndlessScrollListener(this, progressItem!!)
}
/**
* Called by the adapter when scrolled near the bottom.
*/
override fun onLoadMore(lastPosition: Int, currentPage: Int) {
if (presenter.hasNextPage()) {
presenter.requestNext()
} else {
adapter?.onLoadMoreComplete(null)
adapter?.endlessTargetCount = 1
}
}
override fun noMoreLoad(newItemsSize: Int) {
}
/**
* Called from the presenter when a manga is initialized.
*
* @param manga the manga initialized
*/
fun onMangaInitialized(manga: Manga) {
getHolder(manga)?.setImage(manga)
}
/**
* Sets the current display mode.
*
* @param mode the mode to change to
*/
private fun setDisplayMode(mode: LibraryDisplayMode) {
val view = view ?: return
val adapter = adapter ?: return
preferences.sourceDisplayMode().set(mode)
activity?.invalidateOptionsMenu()
setupRecycler(view)
// Initialize mangas if not on a metered connection
if (!view.context.connectivityManager.isActiveNetworkMetered) {
val mangas = (0 until adapter.itemCount).mapNotNull {
(adapter.getItem(it) as? SourceItem)?.manga
}
presenter.initializeMangas(mangas)
}
}
/**
* Returns a preference for the number of manga per row based on the current orientation.
*
* @return the preference.
*/
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) {
preferences.portraitColumns()
} else {
preferences.landscapeColumns()
}
}
/**
* 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): SourceHolder<*>? {
val adapter = adapter ?: return null
adapter.allBoundViewHolders.forEach { holder ->
val item = adapter.getItem(holder.bindingAdapterPosition) as? SourceItem
if (item != null && item.manga.id == manga.id) {
return holder as SourceHolder<*>
}
}
return null
}
/**
* Shows the progress bar.
*/
private fun showProgressBar() {
binding.emptyView.hide()
binding.progress.isVisible = true
snack?.dismiss()
snack = null
}
/**
* Hides active progress bars.
*/
private fun hideProgressBar() {
binding.emptyView.hide()
binding.progress.isVisible = false
}
/**
* Called when a manga is clicked.
*
* @param position the position of the element clicked.
* @return true if the item should be selected, false otherwise.
*/
override fun onItemClick(view: View, position: Int): Boolean {
val item = adapter?.getItem(position) as? SourceItem ?: return false
router.pushController(MangaController(item.manga.id, true))
return false
}
/**
* Called when a manga is long clicked.
*
* Adds the manga to the default category if none is set it shows a list of categories for the user to put the manga
* in, the list consists of the default category plus the user's categories. The default category is preselected on
* new manga, and on already favorited manga the manga's categories are preselected.
*
* @param position the position of the element clicked.
*/
override fun onItemLongClick(position: Int) {
val activity = activity ?: return
val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return
viewScope.launchIO {
val duplicateManga = presenter.getDuplicateLibraryManga(manga)
withUIContext {
if (manga.favorite) {
MaterialAlertDialogBuilder(activity)
.setTitle(manga.title)
.setItems(arrayOf(activity.getString(R.string.remove_from_library))) { _, which ->
when (which) {
0 -> {
presenter.changeMangaFavorite(manga.toDbManga())
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_removed_library))
}
}
}
.show()
} else {
if (duplicateManga != null) {
AddDuplicateMangaDialog(this@BrowseSourceController, duplicateManga) {
addToLibrary(
manga,
position,
)
}
.showDialog(router)
} else {
addToLibrary(manga, position)
}
}
}
}
}
private fun addToLibrary(newManga: Manga, position: Int) {
val activity = activity ?: return
viewScope.launchIO {
val categories = presenter.getCategories()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
withUIContext {
when {
// Default category set
defaultCategory != null -> {
presenter.moveMangaToCategory(newManga.toDbManga(), defaultCategory)
presenter.changeMangaFavorite(newManga.toDbManga())
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_added_library))
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
presenter.moveMangaToCategory(newManga.toDbManga(), null)
presenter.changeMangaFavorite(newManga.toDbManga())
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_added_library))
}
// Choose a category
else -> {
val ids = presenter.getMangaCategoryIds(newManga)
val preselected = categories.map {
if (it.id in ids) {
QuadStateTextView.State.CHECKED.ordinal
} else {
QuadStateTextView.State.UNCHECKED.ordinal
}
}.toTypedArray()
ChangeMangaCategoriesDialog(this@BrowseSourceController, listOf(newManga), categories, preselected)
.showDialog(router)
}
}
}
}
}
/**
* Update manga to use selected categories.
*
* @param mangas The list of manga to move to categories.
* @param categories The list of categories where manga will be placed.
*/
override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
val manga = mangas.firstOrNull() ?: return
presenter.changeMangaFavorite(manga.toDbManga())
presenter.updateMangaCategories(manga.toDbManga(), addCategories)
val position = adapter?.currentItems?.indexOfFirst { it -> (it as SourceItem).manga.id == manga.id }
if (position != null) {
adapter?.notifyItemChanged(position)
}
activity?.toast(activity?.getString(R.string.manga_added_library))
}
protected companion object {
const val SOURCE_ID_KEY = "sourceId"
const val SEARCH_QUERY_KEY = "searchQuery"

View File

@ -1,7 +1,25 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.content.res.Configuration
import android.os.Bundle
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingSource
import androidx.paging.cachedIn
import androidx.paging.map
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.core.prefs.CheckboxState
import eu.kanade.core.prefs.mapAsCheckboxState
import eu.kanade.domain.category.interactor.GetCategories
import eu.kanade.domain.category.interactor.SetMangaCategories
import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
@ -14,6 +32,8 @@ import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.manga.model.toMangaUpdate
import eu.kanade.domain.track.interactor.InsertTrack
import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.presentation.browse.BrowseSourceState
import eu.kanade.presentation.browse.BrowseSourceStateImpl
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toDomainManga
@ -22,6 +42,7 @@ import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
@ -42,19 +63,17 @@ import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateItem
import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem
import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -65,6 +84,7 @@ import eu.kanade.domain.manga.model.Manga as DomainManga
open class BrowseSourcePresenter(
private val sourceId: Long,
searchQuery: String? = null,
private val state: BrowseSourceStateImpl = BrowseSourceState(searchQuery) as BrowseSourceStateImpl,
private val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
@ -77,55 +97,76 @@ open class BrowseSourcePresenter(
private val updateManga: UpdateManga = Injekt.get(),
private val insertTrack: InsertTrack = Injekt.get(),
private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(),
) : BasePresenter<BrowseSourceController>() {
) : BasePresenter<BrowseSourceController>(), BrowseSourceState by state {
/**
* Selected source.
*/
lateinit var source: CatalogueSource
var displayMode by preferences.sourceDisplayMode().asState()
/**
* Modifiable list of filters.
*/
var sourceFilters = FilterList()
set(value) {
field = value
filterItems = value.toItems()
@Composable
fun getColumnsPreferenceForCurrentOrientation(): State<GridCells> {
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
return produceState<GridCells>(initialValue = GridCells.Adaptive(128.dp), isLandscape) {
(if (isLandscape) preferences.landscapeColumns() else preferences.portraitColumns())
.asFlow()
.collectLatest { columns ->
value = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns)
}
}
}
var filterItems: List<IFlexible<*>> = emptyList()
@Composable
fun getMangaList(): Flow<PagingData<DomainManga>> {
return remember(currentQuery, appliedFilters) {
Pager(
PagingConfig(pageSize = 25),
) {
createPager(currentQuery, appliedFilters)
}.flow
.map {
it.map {
withIOContext {
networkToLocalManga(it, sourceId).toDomainManga()!!
}
}
}
.cachedIn(presenterScope)
}
}
/**
* List of filters used by the [Pager]. If empty alongside [query], the popular query is used.
*/
var appliedFilters = FilterList()
@Composable
fun getManga(initialManga: DomainManga): State<DomainManga> {
return produceState(initialValue = initialManga, initialManga.url, initialManga.source) {
getManga.subscribe(initialManga.url, initialManga.source)
.collectLatest { manga ->
if (manga == null) return@collectLatest
launchIO {
initializeMangas(manga)
}
value = manga
}
}
}
/**
* Pager containing a list of manga results.
*/
private lateinit var pager: Pager
fun setFilter(filters: FilterList) {
state.filters = filters
}
/**
* Subscription for the pager.
*/
private var pagerJob: Job? = null
fun resetFilter() {
state.appliedFilters = FilterList()
val newFilters = source!!.getFilterList()
state.filters = newFilters
}
/**
* Subscription for one request from the pager.
*/
private var nextPageJob: Job? = null
fun search() {
state.currentQuery = searchQuery ?: ""
}
private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
init {
query = searchQuery ?: ""
}
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
source = sourceManager.get(sourceId) as? CatalogueSource ?: return
sourceFilters = source.getFilterList()
state.source = sourceManager.get(sourceId) as? CatalogueSource ?: return
state.filters = source!!.getFilterList()
if (savedState != null) {
query = savedState.getString(::query.name, "")
@ -137,79 +178,6 @@ open class BrowseSourcePresenter(
super.onSave(state)
}
/**
* Restarts the pager for the active source with the provided query and filters.
*
* @param query the query.
* @param filters the current state of the filters (for search mode).
*/
fun restartPager(query: String = this.query, filters: FilterList = this.appliedFilters) {
this.query = query
this.appliedFilters = filters
// Create a new pager.
pager = createPager(query, filters)
val sourceId = source.id
val sourceDisplayMode = preferences.sourceDisplayMode()
pagerJob?.cancel()
pagerJob = presenterScope.launchIO {
pager.asFlow()
.map { (first, second) ->
first to second.map {
networkToLocalManga(
it,
sourceId,
).toDomainManga()!!
}
}
.onEach { initializeMangas(it.second) }
.map { (first, second) ->
first to second.map {
SourceItem(
it,
sourceDisplayMode,
)
}
}
.catch { error ->
logcat(LogPriority.ERROR, error)
}
.collectLatest { (page, mangas) ->
withUIContext {
view?.onAddPage(page, mangas)
}
}
}
// Request first page.
requestNext()
}
/**
* Requests the next page for the active pager.
*/
fun requestNext() {
if (!hasNextPage()) return
nextPageJob?.cancel()
nextPageJob = presenterScope.launchIO {
try {
pager.requestNextPage()
} catch (e: Throwable) {
withUIContext { view?.onAddPageError(e) }
}
}
}
/**
* Returns true if the last fetched page has a next page.
*/
fun hasNextPage(): Boolean {
return pager.hasNextPage
}
/**
* 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.
@ -217,16 +185,14 @@ open class BrowseSourcePresenter(
* @param sManga the manga from the source.
* @return a manga from the database.
*/
private fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
var localManga = runBlocking { getManga.await(sManga.url, sourceId) }
private suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
var localManga = getManga.await(sManga.url, sourceId)
if (localManga == null) {
val newManga = Manga.create(sManga.url, sManga.title, sourceId)
newManga.copyFrom(sManga)
newManga.id = -1
val result = runBlocking {
val id = insertManga.await(newManga.toDomainManga()!!)
getManga.await(id!!)
}
val result = getManga.await(id!!)
localManga = result
} else if (!localManga.favorite) {
// if the manga isn't a favorite, set its display title from source
@ -237,46 +203,27 @@ open class BrowseSourcePresenter(
}
/**
* Initialize a list of manga.
* Initialize a manga.
*
* @param mangas the list of manga to initialize.
*/
fun initializeMangas(mangas: List<DomainManga>) {
presenterScope.launchIO {
mangas.asFlow()
.filter { it.thumbnailUrl == null && !it.initialized }
.map { getMangaDetails(it.toDbManga()) }
.onEach {
withUIContext {
@Suppress("DEPRECATION")
view?.onMangaInitialized(it.toDomainManga()!!)
}
}
.catch { e -> logcat(LogPriority.ERROR, e) }
.collect()
}
}
/**
* Returns the initialized manga.
*
* @param manga the manga to initialize.
* @return the initialized manga
*/
private suspend fun getMangaDetails(manga: Manga): Manga {
private suspend fun initializeMangas(manga: DomainManga) {
if (manga.thumbnailUrl != null && manga.initialized) return
withContext(NonCancellable) {
val db = manga.toDbManga()
try {
val networkManga = source.getMangaDetails(manga.copy())
manga.copyFrom(networkManga)
manga.initialized = true
val networkManga = source!!.getMangaDetails(db.copy())
db.copyFrom(networkManga)
db.initialized = true
updateManga.await(
manga
db
.toDomainManga()
?.toMangaUpdate()!!,
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
return manga
}
}
/**
@ -284,43 +231,76 @@ open class BrowseSourcePresenter(
*
* @param manga the manga to update.
*/
fun changeMangaFavorite(manga: Manga) {
manga.favorite = !manga.favorite
manga.date_added = when (manga.favorite) {
fun changeMangaFavorite(manga: DomainManga) {
presenterScope.launch {
var new = manga.copy(
favorite = !manga.favorite,
dateAdded = when (manga.favorite) {
true -> Date().time
false -> 0
}
},
)
if (!manga.favorite) {
manga.removeCovers(coverCache)
if (!new.favorite) {
new = new.removeCovers(coverCache)
} else {
ChapterSettingsHelper.applySettingDefaults(manga.toDomainManga()!!)
ChapterSettingsHelper.applySettingDefaults(manga)
autoAddTrack(manga)
}
runBlocking {
updateManga.await(
manga
.toDomainManga()
?.toMangaUpdate()!!,
)
updateManga.await(new.toMangaUpdate())
}
}
private fun autoAddTrack(manga: Manga) {
launchIO {
fun getSourceOrStub(manga: DomainManga): Source {
return sourceManager.getOrStub(manga.source)
}
fun addFavorite(manga: DomainManga) {
presenterScope.launch {
val categories = getCategories()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
when {
// Default category set
defaultCategory != null -> {
moveMangaToCategories(manga, defaultCategory)
changeMangaFavorite(manga)
// activity.toast(activity.getString(R.string.manga_added_library))
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
moveMangaToCategories(manga)
changeMangaFavorite(manga)
// activity.toast(activity.getString(R.string.manga_added_library))
}
// Choose a category
else -> {
val preselectedIds = getCategories.await(manga.id).map { it.id }
state.dialog = Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds })
}
}
}
}
private suspend fun autoAddTrack(manga: DomainManga) {
loggedServices
.filterIsInstance<EnhancedTrackService>()
.filter { it.accept(source) }
.filter { it.accept(source!!) }
.forEach { service ->
try {
service.match(manga)?.let { track ->
track.manga_id = manga.id!!
service.match(manga.toDbManga())?.let { track ->
track.manga_id = manga.id
(service as TrackService).bind(track)
insertTrack.await(track.toDomainTrack()!!)
val chapters = getChapterByMangaId.await(manga.id!!)
val chapters = getChapterByMangaId.await(manga.id)
syncChaptersWithTrackServiceTwoWay.await(chapters, track.toDomainTrack()!!, service)
}
} catch (e: Exception) {
@ -328,7 +308,6 @@ open class BrowseSourcePresenter(
}
}
}
}
/**
* Set the filter states for the current source.
@ -336,14 +315,59 @@ open class BrowseSourcePresenter(
* @param filters a list of active filters.
*/
fun setSourceFilter(filters: FilterList) {
restartPager(filters = filters)
state.appliedFilters = filters
}
open fun createPager(query: String, filters: FilterList): Pager {
return SourcePager(source, query, filters)
open fun createPager(query: String, filters: FilterList): PagingSource<Long, SManga> {
return SourceBrowsePagingSource(source!!, query, filters)
}
private fun FilterList.toItems(): List<IFlexible<*>> {
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
suspend fun getCategories(): List<DomainCategory> {
return getCategories.subscribe()
.firstOrNull()
?.filterNot { it.isSystemCategory }
?: emptyList()
}
suspend fun getDuplicateLibraryManga(manga: DomainManga): DomainManga? {
return getDuplicateLibraryManga.await(manga.title, manga.source)
}
/**
* Move the given manga to categories.
*
* @param categories the selected categories.
* @param manga the manga to move.
*/
fun moveMangaToCategories(manga: DomainManga, vararg categories: DomainCategory) {
moveMangaToCategories(manga, categories.filter { it.id != 0L }.map { it.id })
}
fun moveMangaToCategories(manga: DomainManga, categoryIds: List<Long>) {
presenterScope.launchIO {
setMangaCategories.await(
mangaId = manga.id,
categoryIds = categoryIds.toList(),
)
}
}
sealed class Dialog {
data class RemoveManga(val manga: DomainManga) : Dialog()
data class AddDuplicateManga(val manga: DomainManga, val duplicate: DomainManga) : Dialog()
data class ChangeMangaCategory(
val manga: DomainManga,
val initialSelection: List<CheckboxState.State<DomainCategory>>,
) : Dialog()
}
}
fun FilterList.toItems(): List<IFlexible<*>> {
return mapNotNull { filter ->
when (filter) {
is Filter.Header -> HeaderItem(filter)
@ -377,72 +401,4 @@ open class BrowseSourcePresenter(
}
}
}
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
suspend fun getCategories(): List<DomainCategory> {
return getCategories.subscribe()
.firstOrNull()
?.filterNot { it.isSystemCategory }
?: emptyList()
}
suspend fun getDuplicateLibraryManga(manga: DomainManga): DomainManga? {
return getDuplicateLibraryManga.await(manga.title, manga.source)
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: DomainManga): Array<Long?> {
return runBlocking { getCategories.await(manga.id) }
.map { it.id }
.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param categories the selected categories.
* @param manga the manga to move.
*/
private fun moveMangaToCategories(manga: Manga, categories: List<DomainCategory>) {
presenterScope.launchIO {
setMangaCategories.await(
mangaId = manga.id!!,
categoryIds = categories.filter { it.id != 0L }.map { it.id },
)
}
}
/**
* Move the given manga to the category.
*
* @param category the selected category.
* @param manga the manga to move.
*/
fun moveMangaToCategory(manga: Manga, category: DomainCategory?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
/**
* Update manga to use selected categories.
*
* @param manga needed to change
* @param selectedCategories selected categories
*/
fun updateMangaCategories(manga: Manga, selectedCategories: List<DomainCategory>) {
if (!manga.favorite) {
changeMangaFavorite(manga)
}
moveMangaToCategories(manga, selectedCategories)
}
}

View File

@ -1,31 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.core.util.asFlow
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.coroutines.flow.Flow
/**
* A general pager for source requests (latest updates, popular, search)
*/
abstract class Pager(var currentPage: Int = 1) {
var hasNextPage = true
private set
protected val results: PublishRelay<Pair<Int, List<SManga>>> = PublishRelay.create()
fun asFlow(): Flow<Pair<Int, List<SManga>>> {
return results.asObservable().asFlow()
}
abstract suspend fun requestNextPage()
fun onPageReceived(mangasPage: MangasPage) {
val page = currentPage
currentPage++
hasNextPage = mangasPage.hasNextPage && mangasPage.mangas.isNotEmpty()
results.call(Pair(page, mangasPage.mangas))
}
}

View File

@ -1,54 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
class ProgressItem : AbstractFlexibleItem<ProgressItem.Holder>() {
private var loadMore = true
override fun getLayoutRes(): Int {
return R.layout.source_progress_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): Holder {
return Holder(view, adapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, holder: Holder, position: Int, payloads: List<Any?>) {
holder.progressBar.isVisible = false
holder.progressMessage.isVisible = false
if (!adapter.isEndlessScrollEnabled) {
loadMore = false
}
if (loadMore) {
holder.progressBar.isVisible = true
} else {
holder.progressMessage.isVisible = true
}
}
override fun equals(other: Any?): Boolean {
return this === other
}
override fun hashCode(): Int {
return loadMore.hashCode()
}
class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) {
val progressBar: ProgressBar = view.findViewById(R.id.progress_bar)
val progressMessage: TextView = view.findViewById(R.id.progress_message)
}
}

View File

@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.util.lang.awaitSingle
class SourceBrowsePagingSource(val source: CatalogueSource, val query: String, val filters: FilterList) : BrowsePagingSource() {
override suspend fun requestNextPage(currentPage: Int): MangasPage {
val observable = if (query.isBlank() && filters.isEmpty()) {
source.fetchPopularManga(currentPage)
} else {
source.fetchSearchManga(currentPage, query, filters)
}
return observable.awaitSingle()
.takeIf { it.mangas.isNotEmpty() } ?: throw NoResultsException()
}
}

View File

@ -1,53 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import androidx.core.view.isVisible
import coil.dispose
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
/**
* Class used to hold the displayed data of a manga in the catalogue, like the cover or the title.
* All the elements from the layout file "item_source_grid" are available in this class.
*
* @param binding the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new catalogue holder.
*/
class SourceComfortableGridHolder(
override val binding: SourceComfortableGridItemBinding,
adapter: FlexibleAdapter<*>,
) : SourceHolder<SourceComfortableGridItemBinding>(binding.root, adapter) {
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
// Set manga title
binding.title.text = manga.title
// Set alpha of thumbnail.
binding.thumbnail.alpha = if (manga.favorite) 0.3f else 1.0f
// For rounded corners
binding.badges.leftBadges.clipToOutline = true
binding.badges.rightBadges.clipToOutline = true
// Set favorite badge
binding.badges.favoriteText.isVisible = manga.favorite
setImage(manga)
}
override fun setImage(manga: Manga) {
binding.thumbnail.dispose()
binding.thumbnail.loadAutoPause(manga) {
setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false)
}
}
}

View File

@ -1,53 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import androidx.core.view.isVisible
import coil.dispose
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding
import eu.kanade.tachiyomi.util.view.loadAutoPause
/**
* Class used to hold the displayed data of a manga in the catalogue, like the cover or the title.
* All the elements from the layout file "item_source_grid" are available in this class.
*
* @param binding the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new catalogue holder.
*/
class SourceCompactGridHolder(
override val binding: SourceCompactGridItemBinding,
adapter: FlexibleAdapter<*>,
) : SourceHolder<SourceCompactGridItemBinding>(binding.root, adapter) {
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
// Set manga title
binding.title.text = manga.title
// Set alpha of thumbnail.
binding.thumbnail.alpha = if (manga.favorite) 0.3f else 1.0f
// For rounded corners
binding.badges.leftBadges.clipToOutline = true
binding.badges.rightBadges.clipToOutline = true
// Set favorite badge
binding.badges.favoriteText.isVisible = manga.favorite
setImage(manga)
}
override fun setImage(manga: Manga) {
binding.thumbnail.dispose()
binding.thumbnail.loadAutoPause(manga) {
setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false)
}
}
}

View File

@ -1,35 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import androidx.viewbinding.ViewBinding
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.domain.manga.model.Manga
/**
* Generic class used to hold the displayed data of a manga in the catalogue.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
*/
abstract class SourceHolder<VB : ViewBinding>(view: View, adapter: FlexibleAdapter<*>) :
FlexibleViewHolder(view, adapter) {
abstract val binding: VB
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
abstract fun onSetValues(manga: Manga)
/**
* Updates the image for this holder. Useful to update the image when the manga is initialized
* and the url is now known.
*
* @param manga the manga to bind.
*/
abstract fun setImage(manga: Manga)
}

View File

@ -1,63 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.fredporciuncula.flow.preferences.Preference
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
import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding
import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding
import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
class SourceItem(val manga: Manga, private val displayMode: Preference<LibraryDisplayMode>) :
AbstractFlexibleItem<SourceHolder<*>>() {
override fun getLayoutRes(): Int {
return when (displayMode.get()) {
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> R.layout.source_compact_grid_item
LibraryDisplayMode.ComfortableGrid -> R.layout.source_comfortable_grid_item
LibraryDisplayMode.List -> R.layout.source_list_item
}
}
override fun createViewHolder(
view: View,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
): SourceHolder<*> {
return when (displayMode.get()) {
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
SourceCompactGridHolder(SourceCompactGridItemBinding.bind(view), adapter)
}
LibraryDisplayMode.ComfortableGrid -> {
SourceComfortableGridHolder(SourceComfortableGridItemBinding.bind(view), adapter)
}
LibraryDisplayMode.List -> {
SourceListHolder(view, adapter)
}
}
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: SourceHolder<*>,
position: Int,
payloads: List<Any?>?,
) {
holder.onSetValues(manga)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is SourceItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id.hashCode()
}
}

View File

@ -1,60 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import android.view.View
import androidx.core.view.isVisible
import coil.dispose
import coil.load
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.SourceListItemBinding
import eu.kanade.tachiyomi.util.system.getResourceColor
/**
* Class used to hold the displayed data of a manga in the catalogue, like the cover or the title.
* All the elements from the layout file "item_catalogue_list" are available in this class.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @constructor creates a new catalogue holder.
*/
class SourceListHolder(private val view: View, adapter: FlexibleAdapter<*>) :
SourceHolder<SourceListItemBinding>(view, adapter) {
override val binding = SourceListItemBinding.bind(view)
private val favoriteColor = view.context.getResourceColor(R.attr.colorOnSurface, 0.38f)
private val unfavoriteColor = view.context.getResourceColor(R.attr.colorOnSurface)
/**
* Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
binding.title.text = manga.title
binding.title.setTextColor(if (manga.favorite) favoriteColor else unfavoriteColor)
// Set alpha of thumbnail.
binding.thumbnail.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)
}
override fun setImage(manga: Manga) {
binding.thumbnail.dispose()
if (!manga.thumbnailUrl.isNullOrEmpty()) {
binding.thumbnail.load(manga) {
setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false)
}
}
}
}

View File

@ -1,26 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.util.lang.awaitSingle
class SourcePager(val source: CatalogueSource, val query: String, val filters: FilterList) : Pager() {
override suspend fun requestNextPage() {
val page = currentPage
val observable = if (query.isBlank() && filters.isEmpty()) {
source.fetchPopularManga(page)
} else {
source.fetchSearchManga(page, query, filters)
}
val mangasPage = observable.awaitSingle()
if (mangasPage.mangas.isNotEmpty()) {
onPageReceived(mangasPage)
} else {
throw NoResultsException()
}
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.ui.browse.source.latest
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowsePagingSource
import eu.kanade.tachiyomi.util.lang.awaitSingle
class LatestUpdatesBrowsePagingSource(val source: CatalogueSource) : BrowsePagingSource() {
override suspend fun requestNextPage(currentPage: Int): MangasPage {
return source.fetchLatestUpdates(currentPage).awaitSingle()
}
}

View File

@ -1,12 +1,20 @@
package eu.kanade.tachiyomi.ui.browse.source.latest
import android.os.Bundle
import android.view.Menu
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.os.bundleOf
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R
import eu.kanade.presentation.browse.BrowseLatestScreen
import eu.kanade.presentation.browse.components.RemoveMangaDialog
import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.DuplicateMangaDialog
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchIO
/**
* Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController].
@ -21,9 +29,63 @@ class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) {
return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY))
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_search).isVisible = false
@Composable
override fun ComposeContent() {
val scope = rememberCoroutineScope()
BrowseLatestScreen(
presenter = presenter,
navigateUp = { router.popCurrentController() },
onMangaClick = { router.pushController(MangaController(it.id, true)) },
onMangaLongClick = { manga ->
scope.launchIO {
val duplicateManga = presenter.getDuplicateLibraryManga(manga)
when {
manga.favorite -> presenter.dialog = BrowseSourcePresenter.Dialog.RemoveManga(manga)
duplicateManga != null -> presenter.dialog = BrowseSourcePresenter.Dialog.AddDuplicateManga(manga, duplicateManga)
else -> presenter.addFavorite(manga)
}
}
},
)
val onDismissRequest = { presenter.dialog = null }
when (val dialog = presenter.dialog) {
is BrowseSourcePresenter.Dialog.AddDuplicateManga -> {
DuplicateMangaDialog(
onDismissRequest = onDismissRequest,
onOpenManga = {
router.pushController(MangaController(dialog.duplicate.id, true))
},
onConfirm = {
presenter.addFavorite(dialog.manga)
},
duplicateFrom = presenter.getSourceOrStub(dialog.manga),
)
}
is BrowseSourcePresenter.Dialog.RemoveManga -> {
RemoveMangaDialog(
onDismissRequest = onDismissRequest,
onConfirm = {
presenter.changeMangaFavorite(dialog.manga)
},
)
}
is BrowseSourcePresenter.Dialog.ChangeMangaCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = {
router.pushController(CategoryController())
},
onConfirm = { include, _ ->
presenter.changeMangaFavorite(dialog.manga)
presenter.moveMangaToCategories(dialog.manga, include)
},
)
}
null -> {}
}
}
override fun initFilterSheet() {

View File

@ -1,13 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source.latest
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
import eu.kanade.tachiyomi.util.lang.awaitSingle
class LatestUpdatesPager(val source: CatalogueSource) : Pager() {
override suspend fun requestNextPage() {
val mangasPage = source.fetchLatestUpdates(currentPage).awaitSingle()
onPageReceived(mangasPage)
}
}

View File

@ -1,12 +1,13 @@
package eu.kanade.tachiyomi.ui.browse.source.latest
import androidx.paging.PagingSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
class LatestUpdatesPresenter(sourceId: Long) : BrowseSourcePresenter(sourceId) {
override fun createPager(query: String, filters: FilterList): Pager {
return LatestUpdatesPager(source)
override fun createPager(query: String, filters: FilterList): PagingSource<Long, SManga> {
return LatestUpdatesBrowsePagingSource(source!!)
}
}

View File

@ -1,81 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.category.visualName
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
import eu.kanade.tachiyomi.widget.materialdialogs.setQuadStateMultiChoiceItems
class ChangeMangaCategoriesDialog<T>(bundle: Bundle? = null) :
DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener {
private var mangas = emptyList<Manga>()
private var categories = emptyList<Category>()
private var preselected = emptyArray<Int>()
private var selected = emptyArray<Int>().toIntArray()
constructor(
target: T,
mangas: List<Manga>,
categories: List<Category>,
preselected: Array<Int>,
) : this() {
this.mangas = mangas
this.categories = categories
this.preselected = preselected
this.selected = preselected.toIntArray()
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.action_move_category)
.setNegativeButton(android.R.string.cancel, null)
.apply {
if (categories.isNotEmpty()) {
setQuadStateMultiChoiceItems(
items = categories.map { it.visualName(context) },
isActionList = false,
initialSelected = preselected.toIntArray(),
) { selections ->
selected = selections
}
setPositiveButton(android.R.string.ok) { _, _ ->
val add = selected
.mapIndexed { index, value -> if (value == QuadStateTextView.State.CHECKED.ordinal) categories[index] else null }
.filterNotNull()
val remove = selected
.mapIndexed { index, value -> if (value == QuadStateTextView.State.UNCHECKED.ordinal) categories[index] else null }
.filterNotNull()
(targetController as? Listener)?.updateCategoriesForMangas(mangas, add, remove)
}
setNeutralButton(R.string.action_edit) { _, _ -> openCategoryController() }
} else {
setMessage(R.string.information_empty_category_dialog)
setPositiveButton(R.string.action_edit_categories) { _, _ -> openCategoryController() }
}
}
.create()
}
private fun openCategoryController() {
if (targetController is LibraryController) {
val libController = targetController as LibraryController
libController.clearSelection()
}
router.popCurrentController()
router.pushController(CategoryController())
}
interface Listener {
fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category> = emptyList<Category>())
}
}

View File

@ -1,48 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import uy.kohesive.injekt.injectLazy
class AddDuplicateMangaDialog(bundle: Bundle? = null) : DialogController(bundle) {
private val sourceManager: SourceManager by injectLazy()
private lateinit var libraryManga: Manga
private lateinit var onAddToLibrary: () -> Unit
constructor(
target: Controller,
libraryManga: Manga,
onAddToLibrary: () -> Unit,
) : this() {
targetController = target
this.libraryManga = libraryManga
this.onAddToLibrary = onAddToLibrary
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val source = sourceManager.getOrStub(libraryManga.source)
return MaterialAlertDialogBuilder(activity!!)
.setMessage(activity?.getString(R.string.confirm_manga_add_duplicate, source.name))
.setPositiveButton(activity?.getString(R.string.action_add)) { _, _ ->
onAddToLibrary()
}
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ ->
dismissDialog()
router.pushController(MangaController(libraryManga.id))
}
.setCancelable(true)
.create()
}
}

View File

@ -22,12 +22,12 @@ import eu.kanade.data.chapter.NoChaptersException
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.presentation.components.ChangeCategoryDialog
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.DuplicateMangaDialog
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.presentation.manga.MangaScreen
import eu.kanade.presentation.manga.components.DeleteChaptersDialog
import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog
import eu.kanade.presentation.manga.components.DuplicateMangaDialog
import eu.kanade.presentation.util.calculateWindowWidthSizeClass
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService

View File

@ -51,6 +51,12 @@ fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Int {
return coverCache.deleteFromCache(this, true)
}
fun DomainManga.removeCovers(coverCache: CoverCache = Injekt.get()): DomainManga {
if (isLocal()) return this
coverCache.deleteFromCache(this, true)
return copy(coverLastModified = Date().time)
}
fun DomainManga.shouldDownloadNewChapters(dbCategories: List<Long>, preferences: PreferencesHelper): Boolean {
if (!favorite) return false

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true" android:color="?attr/colorOnPrimary" />
<item android:state_activated="true" android:color="?attr/colorOnPrimary" />
<item android:color="?android:attr/textColorPrimary" />
</selector>

View File

@ -1,50 +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="match_parent"
android:layout_height="wrap_content"
android:layout_margin="2dp"
android:background="@drawable/library_item_selector"
android:foreground="@drawable/library_item_selector_overlay"
android:padding="4dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/thumbnail"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="w,3:2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Cover"
tools:ignore="ContentDescription"
tools:src="@mipmap/ic_launcher" />
<include
android:id="@+id/badges"
layout="@layout/source_grid_item_badges"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="@+id/thumbnail"
app:layout_constraintStart_toStartOf="@+id/thumbnail"
app:layout_constraintTop_toTopOf="@+id/thumbnail" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:padding="4dp"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="@color/source_comfortable_item_title"
android:textSize="12sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/thumbnail"
tools:text="Sample name" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,58 +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="match_parent"
android:layout_height="wrap_content"
android:layout_margin="2dp"
android:background="@drawable/library_item_selector"
android:foreground="@drawable/library_item_selector_overlay"
android:padding="4dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/thumbnail"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
android:foreground="@drawable/card_gradient_shape"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="w,2:3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Cover"
tools:ignore="ContentDescription"
tools:src="@mipmap/ic_launcher" />
<include
android:id="@+id/badges"
layout="@layout/source_grid_item_badges"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="@+id/thumbnail"
app:layout_constraintStart_toStartOf="@+id/thumbnail"
app:layout_constraintTop_toTopOf="@+id/thumbnail" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:ellipsize="end"
android:maxLines="2"
android:padding="8dp"
android:shadowColor="@color/md_black_1000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="4"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="@color/md_white_1000"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="@+id/thumbnail"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Sample name" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,38 +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="match_parent">
<LinearLayout
android:id="@+id/catalogue_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.browse.source.browse.BrowseSourceController">
<FrameLayout
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
</LinearLayout>
<eu.kanade.tachiyomi.widget.EmptyView
android:id="@+id/empty_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:visibility="gone" />
</FrameLayout>

View File

@ -1,116 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp">
<LinearLayout
android:id="@+id/left_badges"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/rounded_rectangle">
<TextView
android:id="@+id/local_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-condensed"
android:text="@string/local_source_badge"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/download_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-medium"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:text="120"
tools:visibility="visible" />
<TextView
android:id="@+id/unread_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-medium"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSecondary"
android:visibility="gone"
tools:text="120"
tools:visibility="visible" />
<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>
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/right_badges"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/rounded_rectangle">
<TextView
android:id="@+id/language_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-condensed"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:text="EN"
tools:visibility="visible" />
</LinearLayout>
</LinearLayout>

View File

@ -1,138 +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="match_parent"
android:layout_height="56dp"
android:layout_gravity="center_vertical"
android:background="@drawable/list_item_selector_background"
android:paddingHorizontal="8dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/thumbnail"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_gravity="center_vertical"
android:padding="8dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Cover"
tools:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/badges"
app:layout_constraintHorizontal_bias="0.007"
app:layout_constraintStart_toEndOf="@+id/thumbnail"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.523"
tools:text="Manga title" />
<LinearLayout
android:id="@+id/badges"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/rounded_rectangle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/local_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-condensed"
android:text="@string/local_source_badge"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/download_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-medium"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:text="122"
tools:visibility="visible" />
<TextView
android:id="@+id/unread_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-medium"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSecondary"
android:visibility="gone"
tools:text="130"
tools:visibility="visible" />
<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" />
<TextView
android:id="@+id/language_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorTertiary"
android:maxLines="1"
android:paddingStart="3dp"
android:paddingTop="1dp"
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:fontFamily="sans-serif-condensed"
tools:text="EN"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnTertiary"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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:padding="8dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
app:indicatorSize="24dp"
app:trackThickness="3dp" />
<TextView
android:id="@+id/progress_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_more_results"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<eu.kanade.tachiyomi.widget.AutofitRecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/source_grid"
style="@style/Widget.Tachiyomi.GridView.Source"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:columnWidth="140dp"
android:padding="5dp"
tools:listitem="@layout/source_compact_grid_item" />

View File

@ -1,46 +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" />
<item
android:icon="@drawable/ic_view_module_24dp"
android:title="@string/action_display_mode"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom">
<menu>
<group android:checkableBehavior="single">
<item
android:id="@+id/action_compact_grid"
android:title="@string/action_display_grid" />
<item
android:id="@+id/action_comfortable_grid"
android:title="@string/action_display_comfortable_grid" />
<item
android:id="@+id/action_list"
android:title="@string/action_display_list" />
</group>
</menu>
</item>
<item
android:id="@+id/action_open_in_web_view"
android:icon="@drawable/ic_public_24dp"
android:title="@string/action_open_in_web_view"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_local_source_help"
android:icon="@drawable/ic_help_24dp"
android:title="@string/local_source_help_guide"
app:iconTint="?attr/colorOnSurface"
app:showAsAction="ifRoom" />
</menu>

View File

@ -870,4 +870,5 @@
<!-- App widget -->
<string name="appwidget_updates_description">See your recently updated manga</string>
<string name="appwidget_unavailable_locked">Widget not available when app lock is enabled</string>
<string name="remove_manga">You are about to remove this manga from your library</string>
</resources>