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 package eu.kanade.domain
import eu.kanade.data.history.HistoryRepositoryImpl import eu.kanade.data.history.HistoryRepositoryImpl
import eu.kanade.data.manga.MangaRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl import eu.kanade.data.source.SourceRepositoryImpl
import eu.kanade.domain.history.interactor.DeleteHistoryTable import eu.kanade.domain.history.interactor.DeleteHistoryTable
import eu.kanade.domain.history.interactor.GetHistory 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.RemoveHistoryById
import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
import eu.kanade.domain.history.repository.HistoryRepository 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.DisableSource
import eu.kanade.domain.source.interactor.GetEnabledSources import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
@ -23,6 +26,8 @@ import uy.kohesive.injekt.api.get
class DomainModule : InjektModule { class DomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() { override fun InjektRegistrar.registerInjectables() {
addSingletonFactory<MangaRepository> { MangaRepositoryImpl(get()) }
addFactory { GetFavoritesBySourceId(get()) }
addFactory { GetNextChapterForManga(get()) } addFactory { GetNextChapterForManga(get()) }
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(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 package eu.kanade.tachiyomi.ui.base.controller
import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -13,7 +14,7 @@ import nucleus.presenter.Presenter
/** /**
* Compose controller with a Nucleus 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 = override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
ComposeControllerBinding.inflate(inflater) ComposeControllerBinding.inflate(inflater)
@ -54,7 +55,7 @@ abstract class BasicComposeController : BaseController<ComposeControllerBinding>
@Composable abstract fun ComposeContent(nestedScrollInterop: NestedScrollConnection) @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 = override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
ComposeControllerBinding.inflate(inflater) 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 package eu.kanade.tachiyomi.ui.browse.migration.manga
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import androidx.compose.runtime.Composable
import android.view.View import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.recyclerview.widget.LinearLayoutManager import eu.kanade.presentation.source.MigrateMangaScreen
import dev.chrisbanes.insetter.applyInsetter import eu.kanade.tachiyomi.ui.base.controller.ComposeController
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.databinding.MigrationMangaControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
class MigrationMangaController : class MigrationMangaController : ComposeController<MigrationMangaPresenter> {
NucleusController<MigrationMangaControllerBinding, MigrationMangaPresenter>,
FlexibleAdapter.OnItemClickListener,
MigrationMangaAdapter.OnCoverClickListener {
private var adapter: MigrationMangaAdapter? = null
constructor(sourceId: Long, sourceName: String?) : super( constructor(sourceId: Long, sourceName: String?) : super(
bundleOf( bundleOf(
@ -36,50 +28,22 @@ class MigrationMangaController :
private val sourceId: Long = args.getLong(SOURCE_ID_EXTRA) private val sourceId: Long = args.getLong(SOURCE_ID_EXTRA)
private val sourceName: String? = args.getString(SOURCE_NAME_EXTRA) private val sourceName: String? = args.getString(SOURCE_NAME_EXTRA)
override fun getTitle(): String? { override fun getTitle(): String? = sourceName
return sourceName
}
override fun createPresenter(): MigrationMangaPresenter { override fun createPresenter(): MigrationMangaPresenter = MigrationMangaPresenter(sourceId)
return MigrationMangaPresenter(sourceId)
}
override fun createBinding(inflater: LayoutInflater) = MigrationMangaControllerBinding.inflate(inflater) @Composable
override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
override fun onViewCreated(view: View) { MigrateMangaScreen(
super.onViewCreated(view) nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
binding.recycler.applyInsetter { onClickItem = {
type(navigationBars = true) { router.pushController(SearchController(it.id))
padding() },
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 { 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 package eu.kanade.tachiyomi.ui.browse.migration.manga
import android.os.Bundle import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.domain.manga.interactor.GetFavoritesBySourceId
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class MigrationMangaPresenter( class MigrationMangaPresenter(
private val sourceId: Long, private val sourceId: Long,
private val db: DatabaseHelper = Injekt.get(), private val getFavoritesBySourceId: GetFavoritesBySourceId = Injekt.get()
) : BasePresenter<MigrationMangaController>() { ) : BasePresenter<MigrationMangaController>() {
private val _state: MutableStateFlow<MigrateMangaState> = MutableStateFlow(MigrateMangaState.Loading)
val state: StateFlow<MigrateMangaState> = _state.asStateFlow()
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
presenterScope.launchIO {
db.getFavoriteMangas() getFavoritesBySourceId
.asRxObservable() .subscribe(sourceId)
.observeOn(AndroidSchedulers.mainThread()) .catch { exception ->
.map { libraryToMigrationItem(it) } _state.emit(MigrateMangaState.Error(exception))
.subscribeLatestCache(MigrationMangaController::setManga) }
} .collectLatest { list ->
_state.emit(MigrateMangaState.Success(list))
private fun libraryToMigrationItem(library: List<Manga>): List<MigrationMangaItem> { }
return library.filter { it.source == sourceId } }
.sortedBy { it.title }
.map { MigrationMangaItem(it) }
} }
} }
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.bluelinelabs.conductor.RouterTransaction
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R 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.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.CatalogueSource 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.GlobalSearchController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class SearchController( class SearchController(
private var manga: Manga? = null, private var manga: Manga? = null,
) : GlobalSearchController(manga?.title) { ) : GlobalSearchController(manga?.title) {
constructor(mangaId: Long) : this(
Injekt.get<DatabaseHelper>()
.getManga(mangaId)
.executeAsBlocking()
)
private var newManga: Manga? = null private var newManga: Manga? = null
override fun createPresenter(): GlobalSearchPresenter { 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="migration_selection_prompt">Select a source to migrate from</string>
<string name="migrate">Migrate</string> <string name="migrate">Migrate</string>
<string name="copy">Copy</string> <string name="copy">Copy</string>
<string name="migrate_empty_screen">Well, this is awkward</string>
<!-- Downloads activity and service --> <!-- Downloads activity and service -->
<string name="download_queue_error">Couldn\'t download chapters. You can try again in the downloads section</string> <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(*) count(*)
FROM mangas FROM mangas
WHERE favorite = 1 WHERE favorite = 1
GROUP BY source; GROUP BY source;
getFavoriteBySourceId:
SELECT *
FROM mangas
WHERE favorite = 1
AND source = :sourceId;