Add a new screen to help migrating manga from sources

This commit is contained in:
inorichi
2018-01-12 22:02:05 +01:00
parent b0482003bd
commit a75457ad88
25 changed files with 842 additions and 62 deletions
@@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.ui.migration
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
class MangaAdapter(controller: MigrationController) :
FlexibleAdapter<IFlexible<*>>(null, controller) {
private var items: List<IFlexible<*>>? = null
override fun updateDataSet(items: MutableList<IFlexible<*>>?) {
if (this.items !== items) {
this.items = items
super.updateDataSet(items)
}
}
}
@@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.ui.migration
import android.view.View
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import kotlinx.android.synthetic.main.catalogue_list_item.*
class MangaHolder(
private val view: View,
private val adapter: FlexibleAdapter<*>
) : BaseFlexibleViewHolder(view, adapter) {
fun bind(item: MangaItem) {
// Update the title of the manga.
title.text = item.manga.title
// Create thumbnail onclick to simulate long click
thumbnail.setOnClickListener {
// Simulate long click on this view to enter selection mode
onLongClick(itemView)
}
// Update the cover.
GlideApp.with(itemView.context).clear(thumbnail)
GlideApp.with(itemView.context)
.load(item.manga)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.circleCrop()
.dontAnimate()
.into(thumbnail)
}
}
@@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.ui.migration
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
class MangaItem(val manga: Manga) : AbstractFlexibleItem<MangaHolder>() {
override fun getLayoutRes(): Int {
return R.layout.catalogue_list_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): MangaHolder {
return MangaHolder(view, adapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<*>,
holder: MangaHolder,
position: Int,
payloads: List<Any?>?) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (other is MangaItem) {
return manga.id == other.manga.id
}
return false
}
override fun hashCode(): Int {
return manga.id!!.hashCode()
}
}
@@ -0,0 +1,135 @@
package eu.kanade.tachiyomi.ui.migration
import android.app.Dialog
import android.os.Bundle
import android.support.v7.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.afollestad.materialdialogs.MaterialDialog
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import kotlinx.android.synthetic.main.migration_controller.*
class MigrationController : NucleusController<MigrationPresenter>(),
FlexibleAdapter.OnItemClickListener,
SourceAdapter.OnSelectClickListener {
private var adapter: FlexibleAdapter<IFlexible<*>>? = null
private var title: String? = null
set(value) {
field = value
setTitle()
}
override fun createPresenter(): MigrationPresenter {
return MigrationPresenter()
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.migration_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
adapter = FlexibleAdapter(null, this)
migration_recycler.layoutManager = LinearLayoutManager(view.context)
migration_recycler.adapter = adapter
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun getTitle(): String? {
return title
}
override fun handleBack(): Boolean {
return if (presenter.state.selectedSource != null) {
presenter.deselectSource()
true
} else {
super.handleBack()
}
}
fun render(state: ViewState) {
if (state.selectedSource == null) {
title = resources?.getString(R.string.label_migration)
if (adapter !is SourceAdapter) {
adapter = SourceAdapter(this)
migration_recycler.adapter = adapter
}
adapter?.updateDataSet(state.sourcesWithManga)
} else {
title = state.selectedSource.toString()
if (adapter !is MangaAdapter) {
adapter = MangaAdapter(this)
migration_recycler.adapter = adapter
}
adapter?.updateDataSet(state.mangaForSource)
}
}
fun renderIsReplacingManga(state: ViewState) {
if (state.isReplacingManga) {
if (router.getControllerWithTag(LOADING_DIALOG_TAG) == null) {
LoadingController().showDialog(router, LOADING_DIALOG_TAG)
}
} else {
router.popControllerWithTag(LOADING_DIALOG_TAG)
}
}
override fun onItemClick(position: Int): Boolean {
val item = adapter?.getItem(position) ?: return false
if (item is MangaItem) {
val controller = SearchController(item.manga)
controller.targetController = this
router.pushController(controller.withFadeTransaction())
} else if (item is SourceItem) {
presenter.setSelectedSource(item.source)
}
return false
}
override fun onSelectClick(position: Int) {
onItemClick(position)
}
fun migrateManga(prevManga: Manga, manga: Manga) {
presenter.migrateManga(prevManga, manga, replace = true)
}
fun copyManga(prevManga: Manga, manga: Manga) {
presenter.migrateManga(prevManga, manga, replace = false)
}
class LoadingController : DialogController() {
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog.Builder(activity!!)
.progress(true, 0)
.content(R.string.migrating)
.cancelable(false)
.build()
}
}
companion object {
const val LOADING_DIALOG_TAG = "LoadingDialog"
}
}
@@ -0,0 +1,140 @@
package eu.kanade.tachiyomi.ui.migration
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.combineLatest
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MigrationPresenter(
private val sourceManager: SourceManager = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<MigrationController>() {
var state = ViewState()
private set(value) {
field = value
stateRelay.call(value)
}
private val stateRelay = BehaviorRelay.create(state)
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
db.getLibraryMangas()
.asRxObservable()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { state = state.copy(sourcesWithManga = findSourcesWithManga(it)) }
.combineLatest(stateRelay.map { it.selectedSource }
.distinctUntilChanged(),
{ library, source -> library to source })
.filter { (_, source) -> source != null }
.observeOn(Schedulers.io())
.map { (library, source) -> libraryToMigrationItem(library, source!!.id) }
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { state = state.copy(mangaForSource = it) }
.subscribe()
stateRelay
// Render the view when any field other than isReplacingManga changes
.distinctUntilChanged { t1, t2 -> t1.isReplacingManga != t2.isReplacingManga }
.subscribeLatestCache(MigrationController::render)
stateRelay.distinctUntilChanged { state -> state.isReplacingManga }
.subscribeLatestCache(MigrationController::renderIsReplacingManga)
}
fun setSelectedSource(source: Source) {
state = state.copy(selectedSource = source, mangaForSource = emptyList())
}
fun deselectSource() {
state = state.copy(selectedSource = null, mangaForSource = emptyList())
}
private fun findSourcesWithManga(library: List<Manga>): List<SourceItem> {
val header = SelectionHeader()
return library.map { it.source }.toSet()
.mapNotNull { if (it != LocalSource.ID) sourceManager.get(it) else null }
.map { SourceItem(it, header) }
}
private fun libraryToMigrationItem(library: List<Manga>, sourceId: Long): List<MangaItem> {
return library.filter { it.source == sourceId }.map(::MangaItem)
}
fun migrateManga(prevManga: Manga, manga: Manga, replace: Boolean) {
val source = sourceManager.get(manga.source) ?: return
state = state.copy(isReplacingManga = true)
Observable.defer { source.fetchChapterList(manga) }
.doOnNext { migrateMangaInternal(source, it, prevManga, manga, replace) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnUnsubscribe { state = state.copy(isReplacingManga = false) }
.subscribe()
}
private fun migrateMangaInternal(source: Source, sourceChapters: List<SChapter>,
prevManga: Manga, manga: Manga, replace: Boolean) {
db.inTransaction {
// Update chapters read
if (preferences.migrateChapters().getOrDefault()) {
syncChaptersWithSource(db, sourceChapters, manga, source)
val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking()
val maxChapterRead = prevMangaChapters.filter { it.read }
.maxBy { it.chapter_number }?.chapter_number
if (maxChapterRead != null) {
val dbChapters = db.getChapters(manga).executeAsBlocking()
for (chapter in dbChapters) {
if (chapter.isRecognizedNumber && chapter.chapter_number <= maxChapterRead) {
chapter.read = true
}
}
db.insertChapters(dbChapters).executeAsBlocking()
}
}
// Update categories
if (preferences.migrateCategories().getOrDefault()) {
val categories = db.getCategoriesForManga(prevManga).executeAsBlocking()
val mangaCategories = categories.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mangaCategories, listOf(manga))
}
// Update track
if (preferences.migrateTracks().getOrDefault()) {
val tracks = db.getTracks(prevManga).executeAsBlocking()
for (track in tracks) {
track.id = null
track.manga_id = manga.id!!
}
db.insertTracks(tracks).executeAsBlocking()
}
// Update favorite status
if (replace) {
prevManga.favorite = false
db.updateMangaFavorite(prevManga).executeAsBlocking()
}
manga.favorite = true
db.updateMangaFavorite(manga).executeAsBlocking()
}
}
}
@@ -0,0 +1,108 @@
package eu.kanade.tachiyomi.ui.migration
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter
import uy.kohesive.injekt.injectLazy
class SearchController(
private var manga: Manga? = null
) : CatalogueSearchController(manga?.title) {
private var newManga: Manga? = null
override fun createPresenter(): CatalogueSearchPresenter {
return SearchPresenter(initialQuery, manga!!)
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putSerializable(::manga.name, manga)
outState.putSerializable(::newManga.name, newManga)
super.onSaveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
manga = savedInstanceState.getSerializable(::manga.name) as? Manga
newManga = savedInstanceState.getSerializable(::newManga.name) as? Manga
}
fun migrateManga() {
val target = targetController as? MigrationController ?: return
val manga = manga ?: return
val newManga = newManga ?: return
router.popController(this)
target.migrateManga(manga, newManga)
}
fun copyManga() {
val target = targetController as? MigrationController ?: return
val manga = manga ?: return
val newManga = newManga ?: return
router.popController(this)
target.copyManga(manga, newManga)
}
override fun onMangaClick(manga: Manga) {
newManga = manga
val dialog = MigrationDialog()
dialog.targetController = this
dialog.showDialog(router)
}
class MigrationDialog : DialogController() {
private val preferences: PreferencesHelper by injectLazy()
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val optionTitles = arrayOf(
R.string.chapters,
R.string.categories,
R.string.track
)
val optionPrefs = arrayOf(
preferences.migrateChapters(),
preferences.migrateCategories(),
preferences.migrateTracks()
)
val preselected = optionPrefs.mapIndexedNotNull { index, preference ->
if (preference.getOrDefault()) index else null
}
return MaterialDialog.Builder(activity!!)
.content(R.string.migration_dialog_what_to_include)
.items(optionTitles.map { resources?.getString(it) })
.alwaysCallMultiChoiceCallback()
.itemsCallbackMultiChoice(preselected.toTypedArray(), { _, positions, _ ->
// Save current settings for the next time
optionPrefs.forEachIndexed { index, preference ->
preference.set(index in positions)
}
true
})
.positiveText(R.string.migrate)
.negativeText(R.string.copy)
.neutralText(android.R.string.cancel)
.onPositive { _, _ ->
(targetController as? SearchController)?.migrateManga()
}
.onNegative { _, _ ->
(targetController as? SearchController)?.copyManga()
}
.build()
}
}
}
@@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.ui.migration
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter
class SearchPresenter(
initialQuery: String? = "",
private val manga: Manga
) : CatalogueSearchPresenter(initialQuery) {
override fun getEnabledSources(): List<CatalogueSource> {
// Filter out the source of the selected manga
return super.getEnabledSources()
.filterNot { it.id == manga.source }
}
}
@@ -0,0 +1,50 @@
package eu.kanade.tachiyomi.ui.migration
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import kotlinx.android.synthetic.main.catalogue_main_controller_card.*
/**
* Item that contains the selection header.
*/
class SelectionHeader : AbstractHeaderItem<SelectionHeader.Holder>() {
/**
* Returns the layout resource of this item.
*/
override fun getLayoutRes(): Int {
return R.layout.catalogue_main_controller_card
}
/**
* Creates a new view holder for this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): Holder {
return SelectionHeader.Holder(view, adapter)
}
/**
* Binds this item to the given view holder.
*/
override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder,
position: Int, payloads: List<Any?>?) {
// Intentionally empty
}
class Holder(view: View, adapter: FlexibleAdapter<*>) : BaseFlexibleViewHolder(view, adapter) {
init {
title.text = "Please select a source to migrate from"
}
}
override fun equals(other: Any?): Boolean {
return other is SelectionHeader
}
override fun hashCode(): Int {
return 0
}
}
@@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.ui.migration
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.getResourceColor
/**
* Adapter that holds the catalogue cards.
*
* @param controller instance of [MigrationController].
*/
class SourceAdapter(val controller: MigrationController) :
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
val cardBackground = controller.activity!!.getResourceColor(R.attr.background_card)
private var items: List<IFlexible<*>>? = null
init {
setDisplayHeadersAtStartUp(true)
}
/**
* Listener for browse item clicks.
*/
val selectClickListener: OnSelectClickListener? = controller
/**
* Listener which should be called when user clicks select.
*/
interface OnSelectClickListener {
fun onSelectClick(position: Int)
}
override fun updateDataSet(items: MutableList<IFlexible<*>>?) {
if (this.items !== items) {
this.items = items
super.updateDataSet(items)
}
}
}
@@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.migration
import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.ui.base.holder.SlicedHolder
import eu.kanade.tachiyomi.util.getRound
import eu.kanade.tachiyomi.util.gone
import io.github.mthli.slice.Slice
import kotlinx.android.synthetic.main.catalogue_main_controller_card_item.*
class SourceHolder(view: View, override val adapter: SourceAdapter) :
BaseFlexibleViewHolder(view, adapter),
SlicedHolder {
override val slice = Slice(card).apply {
setColor(adapter.cardBackground)
}
override val viewToSlice: View
get() = card
init {
source_latest.gone()
source_browse.setText(R.string.select)
source_browse.setOnClickListener {
adapter.selectClickListener?.onSelectClick(adapterPosition)
}
}
fun bind(item: SourceItem) {
val source = item.source
setCardEdges(item)
// Set source name
title.text = source.name
// Set circle letter image.
itemView.post {
image.setImageDrawable(image.getRound(source.name.take(1).toUpperCase(),false))
}
}
}
@@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.ui.migration
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.Source
/**
* Item that contains source information.
*
* @param source Instance of [Source] containing source information.
* @param header The header for this item.
*/
data class SourceItem(val source: Source, val header: SelectionHeader? = null) :
AbstractSectionableItem<SourceHolder, SelectionHeader>(header) {
/**
* Returns the layout resource of this item.
*/
override fun getLayoutRes(): Int {
return R.layout.catalogue_main_controller_card_item
}
/**
* Creates a new view holder for this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): SourceHolder {
return SourceHolder(view, adapter as SourceAdapter)
}
/**
* Binds this item to the given view holder.
*/
override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: SourceHolder,
position: Int, payloads: List<Any?>?) {
holder.bind(this)
}
}
@@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.ui.migration
import eu.kanade.tachiyomi.source.Source
data class ViewState(
val selectedSource: Source? = null,
val mangaForSource: List<MangaItem> = emptyList(),
val sourcesWithManga: List<SourceItem> = emptyList(),
val isReplacingManga: Boolean = false
)