Migrate Migrate Manga screen to Compose (#7045)

* Migrate Migrate Manga screen to Compose

* Changes from review comments
This commit is contained in:
Andreas 2022-04-30 15:37:10 +02:00 committed by GitHub
parent 6ef6eab994
commit bf6d59cd21
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 258 additions and 177 deletions

View file

@ -0,0 +1,15 @@
package eu.kanade.data.manga
import eu.kanade.data.DatabaseHandler
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.repository.MangaRepository
import kotlinx.coroutines.flow.Flow
class MangaRepositoryImpl(
private val databaseHandler: DatabaseHandler
) : MangaRepository {
override fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>> {
return databaseHandler.subscribeToList { mangasQueries.getFavoriteBySourceId(sourceId, mangaMapper) }
}
}

View file

@ -1,6 +1,7 @@
package eu.kanade.domain
import eu.kanade.data.history.HistoryRepositoryImpl
import eu.kanade.data.manga.MangaRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl
import eu.kanade.domain.history.interactor.DeleteHistoryTable
import eu.kanade.domain.history.interactor.GetHistory
@ -8,6 +9,8 @@ import eu.kanade.domain.history.interactor.GetNextChapterForManga
import eu.kanade.domain.history.interactor.RemoveHistoryById
import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
import eu.kanade.domain.history.repository.HistoryRepository
import eu.kanade.domain.manga.interactor.GetFavoritesBySourceId
import eu.kanade.domain.manga.repository.MangaRepository
import eu.kanade.domain.source.interactor.DisableSource
import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
@ -23,6 +26,8 @@ import uy.kohesive.injekt.api.get
class DomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addSingletonFactory<MangaRepository> { MangaRepositoryImpl(get()) }
addFactory { GetFavoritesBySourceId(get()) }
addFactory { GetNextChapterForManga(get()) }
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }

View file

@ -0,0 +1,14 @@
package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.repository.MangaRepository
import kotlinx.coroutines.flow.Flow
class GetFavoritesBySourceId(
private val mangaRepository: MangaRepository
) {
fun subscribe(sourceId: Long): Flow<List<Manga>> {
return mangaRepository.getFavoritesBySourceId(sourceId)
}
}

View file

@ -0,0 +1,9 @@
package eu.kanade.domain.manga.repository
import eu.kanade.domain.manga.model.Manga
import kotlinx.coroutines.flow.Flow
interface MangaRepository {
fun getFavoritesBySourceId(sourceId: Long): Flow<List<Manga>>
}

View file

@ -0,0 +1,65 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.util.horizontalPadding
@Composable
fun BaseMangaListItem(
modifier: Modifier = Modifier,
manga: Manga,
onClickItem: () -> Unit = {},
onClickCover: () -> Unit = onClickItem,
cover: @Composable RowScope.() -> Unit = { defaultCover(manga, onClickCover) },
actions: @Composable RowScope.() -> Unit = {},
content: @Composable RowScope.() -> Unit = { defaultContent(manga) },
) {
Row(
modifier = modifier
.clickable(onClick = onClickItem)
.height(56.dp)
.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically
) {
cover()
content()
actions()
}
}
private val defaultCover: @Composable RowScope.(Manga, () -> Unit) -> Unit = { manga, onClick ->
MangaCover.Square(
modifier = Modifier
.padding(vertical = 8.dp)
.clickable(onClick = onClick)
.fillMaxHeight(),
data = manga.thumbnailUrl
)
}
private val defaultContent: @Composable RowScope.(Manga) -> Unit = {
Box(modifier = Modifier.weight(1f)) {
Text(
text = it.title,
modifier = Modifier
.padding(start = horizontalPadding),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium
)
}
}

View file

@ -0,0 +1,84 @@
package eu.kanade.presentation.source
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import eu.kanade.domain.manga.model.Manga
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.manga.components.BaseMangaListItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaState
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrationMangaPresenter
@Composable
fun MigrateMangaScreen(
nestedScrollInterop: NestedScrollConnection,
presenter: MigrationMangaPresenter,
onClickItem: (Manga) -> Unit,
onClickCover: (Manga) -> Unit
) {
val state by presenter.state.collectAsState()
when (state) {
MigrateMangaState.Loading -> LoadingScreen()
is MigrateMangaState.Error -> Text(text = (state as MigrateMangaState.Error).error.message!!)
is MigrateMangaState.Success -> {
MigrateMangaContent(
nestedScrollInterop = nestedScrollInterop,
list = (state as MigrateMangaState.Success).list,
onClickItem = onClickItem,
onClickCover = onClickCover,
)
}
}
}
@Composable
fun MigrateMangaContent(
nestedScrollInterop: NestedScrollConnection,
list: List<Manga>,
onClickItem: (Manga) -> Unit,
onClickCover: (Manga) -> Unit
) {
if (list.isEmpty()) {
EmptyScreen(textResource = R.string.migrate_empty_screen)
return
}
LazyColumn(
modifier = Modifier.nestedScroll(nestedScrollInterop),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
items(list) { manga ->
MigrateMangaItem(
manga = manga,
onClickItem = onClickItem,
onClickCover = onClickCover
)
}
}
}
@Composable
fun MigrateMangaItem(
modifier: Modifier = Modifier,
manga: Manga,
onClickItem: (Manga) -> Unit,
onClickCover: (Manga) -> Unit
) {
BaseMangaListItem(
modifier = modifier,
manga = manga,
onClickItem = { onClickItem(manga) },
onClickCover = { onClickCover(manga) }
)
}

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.base.controller
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.compose.runtime.Composable
@ -13,7 +14,7 @@ import nucleus.presenter.Presenter
/**
* Compose controller with a Nucleus presenter.
*/
abstract class ComposeController<P : Presenter<*>> : NucleusController<ComposeControllerBinding, P>() {
abstract class ComposeController<P : Presenter<*>>(bundle: Bundle? = null) : NucleusController<ComposeControllerBinding, P>(bundle) {
override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
ComposeControllerBinding.inflate(inflater)
@ -54,7 +55,7 @@ abstract class BasicComposeController : BaseController<ComposeControllerBinding>
@Composable abstract fun ComposeContent(nestedScrollInterop: NestedScrollConnection)
}
abstract class SearchableComposeController<P : BasePresenter<*>> : SearchableNucleusController<ComposeControllerBinding, P>() {
abstract class SearchableComposeController<P : BasePresenter<*>>(bundle: Bundle? = null) : SearchableNucleusController<ComposeControllerBinding, P>(bundle) {
override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
ComposeControllerBinding.inflate(inflater)

View file

@ -1,14 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.migration.manga
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
class MigrationMangaAdapter(controller: MigrationMangaController) :
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
val coverClickListener: OnCoverClickListener = controller
interface OnCoverClickListener {
fun onCoverClick(position: Int)
}
}

View file

@ -1,24 +1,16 @@
package eu.kanade.tachiyomi.ui.browse.migration.manga
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.databinding.MigrationMangaControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.presentation.source.MigrateMangaScreen
import eu.kanade.tachiyomi.ui.base.controller.ComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.manga.MangaController
class MigrationMangaController :
NucleusController<MigrationMangaControllerBinding, MigrationMangaPresenter>,
FlexibleAdapter.OnItemClickListener,
MigrationMangaAdapter.OnCoverClickListener {
private var adapter: MigrationMangaAdapter? = null
class MigrationMangaController : ComposeController<MigrationMangaPresenter> {
constructor(sourceId: Long, sourceName: String?) : super(
bundleOf(
@ -36,50 +28,22 @@ class MigrationMangaController :
private val sourceId: Long = args.getLong(SOURCE_ID_EXTRA)
private val sourceName: String? = args.getString(SOURCE_NAME_EXTRA)
override fun getTitle(): String? {
return sourceName
}
override fun getTitle(): String? = sourceName
override fun createPresenter(): MigrationMangaPresenter {
return MigrationMangaPresenter(sourceId)
}
override fun createPresenter(): MigrationMangaPresenter = MigrationMangaPresenter(sourceId)
override fun createBinding(inflater: LayoutInflater) = MigrationMangaControllerBinding.inflate(inflater)
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
@Composable
override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
MigrateMangaScreen(
nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
onClickItem = {
router.pushController(SearchController(it.id))
},
onClickCover = {
router.pushController(MangaController(it.id))
}
}
adapter = MigrationMangaAdapter(this)
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter
adapter?.fastScroller = binding.fastScroller
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
fun setManga(manga: List<MigrationMangaItem>) {
adapter?.updateDataSet(manga)
}
override fun onItemClick(view: View, position: Int): Boolean {
val item = adapter?.getItem(position) as? MigrationMangaItem ?: return false
val controller = SearchController(item.manga)
router.pushController(controller)
return false
}
override fun onCoverClick(position: Int) {
val mangaItem = adapter?.getItem(position) as? MigrationMangaItem ?: return
router.pushController(MangaController(mangaItem.manga))
)
}
companion object {

View file

@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.migration.manga
import android.view.View
import coil.dispose
import coil.load
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.databinding.SourceListItemBinding
class MigrationMangaHolder(
view: View,
private val adapter: MigrationMangaAdapter,
) : FlexibleViewHolder(view, adapter) {
private val binding = SourceListItemBinding.bind(view)
init {
binding.thumbnail.setOnClickListener {
adapter.coverClickListener.onCoverClick(bindingAdapterPosition)
}
}
fun bind(item: MigrationMangaItem) {
binding.title.text = item.manga.title
// Update the cover
binding.thumbnail.dispose()
binding.thumbnail.load(item.manga)
}
}

View file

@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.migration.manga
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
class MigrationMangaItem(val manga: Manga) : AbstractFlexibleItem<MigrationMangaHolder>() {
override fun getLayoutRes(): Int {
return R.layout.source_list_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): MigrationMangaHolder {
return MigrationMangaHolder(view, adapter as MigrationMangaAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: MigrationMangaHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (other is MigrationMangaItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id!!.hashCode()
}
}

View file

@ -1,31 +1,43 @@
package eu.kanade.tachiyomi.ui.browse.migration.manga
import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.domain.manga.interactor.GetFavoritesBySourceId
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import rx.android.schedulers.AndroidSchedulers
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MigrationMangaPresenter(
private val sourceId: Long,
private val db: DatabaseHelper = Injekt.get(),
private val getFavoritesBySourceId: GetFavoritesBySourceId = Injekt.get()
) : BasePresenter<MigrationMangaController>() {
private val _state: MutableStateFlow<MigrateMangaState> = MutableStateFlow(MigrateMangaState.Loading)
val state: StateFlow<MigrateMangaState> = _state.asStateFlow()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
db.getFavoriteMangas()
.asRxObservable()
.observeOn(AndroidSchedulers.mainThread())
.map { libraryToMigrationItem(it) }
.subscribeLatestCache(MigrationMangaController::setManga)
}
private fun libraryToMigrationItem(library: List<Manga>): List<MigrationMangaItem> {
return library.filter { it.source == sourceId }
.sortedBy { it.title }
.map { MigrationMangaItem(it) }
presenterScope.launchIO {
getFavoritesBySourceId
.subscribe(sourceId)
.catch { exception ->
_state.emit(MigrateMangaState.Error(exception))
}
.collectLatest { list ->
_state.emit(MigrateMangaState.Success(list))
}
}
}
}
sealed class MigrateMangaState {
object Loading : MigrateMangaState()
data class Error(val error: Throwable) : MigrateMangaState()
data class Success(val list: List<Manga>) : MigrateMangaState()
}

View file

@ -7,6 +7,7 @@ import com.bluelinelabs.conductor.Controller
import com.bluelinelabs.conductor.RouterTransaction
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.CatalogueSource
@ -16,12 +17,20 @@ import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter
import eu.kanade.tachiyomi.ui.manga.MangaController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class SearchController(
private var manga: Manga? = null,
) : GlobalSearchController(manga?.title) {
constructor(mangaId: Long) : this(
Injekt.get<DatabaseHelper>()
.getManga(mangaId)
.executeAsBlocking()
)
private var newManga: Manga? = null
override fun createPresenter(): GlobalSearchPresenter {

View file

@ -1,21 +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">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<eu.kanade.tachiyomi.widget.MaterialFastScroll
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
</FrameLayout>

View file

@ -717,6 +717,7 @@
<string name="migration_selection_prompt">Select a source to migrate from</string>
<string name="migrate">Migrate</string>
<string name="copy">Copy</string>
<string name="migrate_empty_screen">Well, this is awkward</string>
<!-- Downloads activity and service -->
<string name="download_queue_error">Couldn\'t download chapters. You can try again in the downloads section</string>

View file

@ -36,4 +36,10 @@ source,
count(*)
FROM mangas
WHERE favorite = 1
GROUP BY source;
GROUP BY source;
getFavoriteBySourceId:
SELECT *
FROM mangas
WHERE favorite = 1
AND source = :sourceId;