Manga migration feature
parent
e4e14214d9
commit
16027e3295
@ -0,0 +1,69 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||||
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val MAX_PARALLELISM = 4
|
||||||
|
private const val MATCH_THRESHOLD = 0.2f
|
||||||
|
|
||||||
|
class AlternativesUseCase @Inject constructor(
|
||||||
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||||
|
val sources = sourcesRepository.getEnabledSources()
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
return emptyFlow()
|
||||||
|
}
|
||||||
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||||
|
return channelFlow {
|
||||||
|
for (source in sources) {
|
||||||
|
val repository = mangaRepositoryFactory.create(source)
|
||||||
|
if (!repository.isSearchSupported) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
val list = runCatchingCancellable {
|
||||||
|
semaphore.withPermit {
|
||||||
|
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
for (item in list) {
|
||||||
|
if (item != manga && item.matches(manga)) {
|
||||||
|
send(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.map {
|
||||||
|
runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||||
|
}.getOrDefault(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Manga.matches(ref: Manga): Boolean {
|
||||||
|
return matchesTitles(title, ref.title) ||
|
||||||
|
matchesTitles(title, ref.altTitle) ||
|
||||||
|
matchesTitles(altTitle, ref.title) ||
|
||||||
|
matchesTitles(altTitle, ref.altTitle)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesTitles(a: String?, b: String?): Boolean {
|
||||||
|
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.domain
|
||||||
|
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
|
||||||
|
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||||
|
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MigrateUseCase @Inject constructor(
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
|
private val database: MangaDatabase,
|
||||||
|
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||||
|
private val useCase: DetailsLoadUseCase
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
||||||
|
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
|
runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
|
}.getOrDefault(oldManga)
|
||||||
|
} else {
|
||||||
|
oldManga
|
||||||
|
}
|
||||||
|
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||||
|
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||||
|
} else {
|
||||||
|
newManga
|
||||||
|
}
|
||||||
|
mangaDataRepository.storeManga(newDetails)
|
||||||
|
database.withTransaction {
|
||||||
|
// replace favorites
|
||||||
|
val favoritesDao = database.getFavouritesDao()
|
||||||
|
val oldFavourite = favoritesDao.find(oldDetails.id)
|
||||||
|
if (oldFavourite != null) {
|
||||||
|
favoritesDao.delete(oldManga.id)
|
||||||
|
for (f in oldFavourite.categories) {
|
||||||
|
val e = FavouriteEntity(
|
||||||
|
mangaId = newManga.id,
|
||||||
|
categoryId = f.categoryId.toLong(),
|
||||||
|
sortKey = f.sortKey,
|
||||||
|
createdAt = f.createdAt,
|
||||||
|
deletedAt = 0,
|
||||||
|
)
|
||||||
|
favoritesDao.upsert(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// replace history
|
||||||
|
val historyDao = database.getHistoryDao()
|
||||||
|
val oldHistory = historyDao.find(oldDetails.id)
|
||||||
|
if (oldHistory != null) {
|
||||||
|
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||||
|
historyDao.delete(oldDetails.id)
|
||||||
|
historyDao.upsert(newHistory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progressUpdateUseCase(newManga)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeNewHistory(
|
||||||
|
oldManga: Manga,
|
||||||
|
newManga: Manga,
|
||||||
|
history: HistoryEntity,
|
||||||
|
): HistoryEntity {
|
||||||
|
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||||
|
val branch = newManga.getPreferredBranch(null)
|
||||||
|
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||||
|
return HistoryEntity(
|
||||||
|
mangaId = newManga.id,
|
||||||
|
createdAt = history.createdAt,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
chapterId = chapters[(chapters.lastIndex * history.percent).toInt()].id,
|
||||||
|
page = history.page,
|
||||||
|
scroll = history.scroll,
|
||||||
|
percent = history.percent,
|
||||||
|
deletedAt = 0,
|
||||||
|
chaptersCount = chapters.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||||
|
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||||
|
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||||
|
if (index < 0) {
|
||||||
|
index = (oldChapters.size * history.percent).toInt()
|
||||||
|
}
|
||||||
|
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||||
|
val newBranch = if (newChapters.containsKey(branch)) {
|
||||||
|
branch
|
||||||
|
} else {
|
||||||
|
newManga.getPreferredBranch(null)
|
||||||
|
}
|
||||||
|
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
||||||
|
val oldChapter = oldChapters[index]
|
||||||
|
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||||
|
}.id
|
||||||
|
|
||||||
|
return HistoryEntity(
|
||||||
|
mangaId = newManga.id,
|
||||||
|
createdAt = history.createdAt,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
chapterId = newChapterId,
|
||||||
|
page = history.page,
|
||||||
|
scroll = history.scroll,
|
||||||
|
percent = PROGRESS_NONE,
|
||||||
|
deletedAt = 0,
|
||||||
|
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
||||||
|
return if (number <= 0f) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
firstOrNull { it.volume == volume && it.number == number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.text.style.ForegroundColorSpan
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.text.buildSpannedString
|
||||||
|
import androidx.core.text.inSpans
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.transform.CircleCropTransformation
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import kotlin.math.sign
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
fun alternativeAD(
|
||||||
|
coil: ImageLoader,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
listener: OnListItemClickListener<MangaAlternativeModel>,
|
||||||
|
) = adapterDelegateViewBinding<MangaAlternativeModel, ListModel, ItemMangaAlternativeBinding>(
|
||||||
|
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
|
||||||
|
) {
|
||||||
|
|
||||||
|
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
|
||||||
|
val colorRed = ContextCompat.getColor(context, R.color.common_red)
|
||||||
|
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||||
|
itemView.setOnClickListener(clickListener)
|
||||||
|
binding.buttonMigrate.setOnClickListener(clickListener)
|
||||||
|
binding.chipSource.setOnClickListener(clickListener)
|
||||||
|
|
||||||
|
bind { payloads ->
|
||||||
|
binding.textViewTitle.text = item.manga.title
|
||||||
|
binding.textViewSubtitle.text = buildSpannedString {
|
||||||
|
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
|
||||||
|
when (item.chaptersDiff.sign) {
|
||||||
|
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
|
||||||
|
append(" ▼ ")
|
||||||
|
append(item.chaptersDiff.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
|
||||||
|
append(" ▲ +")
|
||||||
|
append(item.chaptersDiff.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||||
|
binding.chipSource.also { chip ->
|
||||||
|
chip.text = item.manga.source.title
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(item.manga.source.faviconUri())
|
||||||
|
.lifecycle(lifecycleOwner)
|
||||||
|
.crossfade(false)
|
||||||
|
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||||
|
.target(ChipIconTarget(chip))
|
||||||
|
.placeholder(R.drawable.ic_web)
|
||||||
|
.fallback(R.drawable.ic_web)
|
||||||
|
.error(R.drawable.ic_web)
|
||||||
|
.source(item.manga.source)
|
||||||
|
.transformations(CircleCropTransformation())
|
||||||
|
.allowRgb565(true)
|
||||||
|
.enqueueWith(coil)
|
||||||
|
}
|
||||||
|
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||||
|
size(CoverSizeResolver(binding.imageViewCover))
|
||||||
|
placeholder(R.drawable.ic_placeholder)
|
||||||
|
fallback(R.drawable.ic_placeholder)
|
||||||
|
error(R.drawable.ic_error_placeholder)
|
||||||
|
transformations(TrimTransformation())
|
||||||
|
allowRgb565(true)
|
||||||
|
tag(item.manga)
|
||||||
|
source(item.manga.source)
|
||||||
|
enqueueWith(coil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import coil.ImageLoader
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||||
|
OnListItemClickListener<MangaAlternativeModel> {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
private val viewModel by viewModels<AlternativesViewModel>()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
|
||||||
|
supportActionBar?.run {
|
||||||
|
setDisplayHomeAsUpEnabled(true)
|
||||||
|
subtitle = viewModel.manga.title
|
||||||
|
}
|
||||||
|
val listAdapter = BaseListAdapter<ListModel>()
|
||||||
|
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
||||||
|
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
||||||
|
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
|
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
|
with(viewBinding.recyclerView) {
|
||||||
|
setHasFixedSize(true)
|
||||||
|
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||||
|
adapter = listAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||||
|
viewModel.content.observe(this, listAdapter)
|
||||||
|
viewModel.onMigrated.observeEvent(this) {
|
||||||
|
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||||
|
startActivity(DetailsActivity.newIntent(this, it))
|
||||||
|
finishAfterTransition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
viewBinding.root.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
)
|
||||||
|
viewBinding.recyclerView.updatePadding(
|
||||||
|
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||||
|
when (view.id) {
|
||||||
|
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
||||||
|
R.id.button_migrate -> confirmMigration(item.manga)
|
||||||
|
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmMigration(target: Manga) {
|
||||||
|
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
||||||
|
.setIcon(R.drawable.ic_replace)
|
||||||
|
.setTitle(R.string.manga_migration)
|
||||||
|
.setMessage(
|
||||||
|
getString(
|
||||||
|
R.string.migrate_confirmation,
|
||||||
|
viewModel.manga.title,
|
||||||
|
viewModel.manga.source.title,
|
||||||
|
target.title,
|
||||||
|
target.source.title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.migrate) { _, _ ->
|
||||||
|
viewModel.migrate(target)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
|
||||||
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEmpty
|
||||||
|
import kotlinx.coroutines.flow.runningFold
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
||||||
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AlternativesViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
|
private val migrateUseCase: MigrateUseCase,
|
||||||
|
private val extraProvider: ListExtraProvider,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||||
|
|
||||||
|
val onMigrated = MutableEventFlow<Manga>()
|
||||||
|
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
||||||
|
private var migrationJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
val ref = mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||||
|
val refCount = ref.chaptersCount()
|
||||||
|
alternativesUseCase(manga)
|
||||||
|
.map {
|
||||||
|
MangaAlternativeModel(
|
||||||
|
manga = it,
|
||||||
|
progress = extraProvider.getProgress(it.id),
|
||||||
|
referenceChapters = refCount,
|
||||||
|
)
|
||||||
|
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||||
|
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
|
||||||
|
}.onEmpty {
|
||||||
|
emit(
|
||||||
|
listOf(
|
||||||
|
EmptyState(
|
||||||
|
icon = R.drawable.ic_empty_common,
|
||||||
|
textPrimary = R.string.nothing_found,
|
||||||
|
textSecondary = R.string.text_search_holder_secondary,
|
||||||
|
actionStringRes = 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.collect {
|
||||||
|
content.value = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun migrate(target: Manga) {
|
||||||
|
if (migrationJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
migrationJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
migrateUseCase(manga, target)
|
||||||
|
onMigrated.call(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
||||||
|
return list.map {
|
||||||
|
MangaAlternativeModel(
|
||||||
|
manga = it,
|
||||||
|
progress = extraProvider.getProgress(it.id),
|
||||||
|
referenceChapters = refCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
data class MangaAlternativeModel(
|
||||||
|
val manga: Manga,
|
||||||
|
val progress: Float,
|
||||||
|
private val referenceChapters: Int,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
val chaptersCount = manga.chaptersCount()
|
||||||
|
|
||||||
|
val chaptersDiff: Int
|
||||||
|
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is MangaAlternativeModel && other.manga.id == manga.id
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import coil.target.GenericViewTarget
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
|
||||||
|
class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() {
|
||||||
|
|
||||||
|
override var drawable: Drawable?
|
||||||
|
get() = view.chipIcon
|
||||||
|
set(value) {
|
||||||
|
view.chipIcon = value
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M14,3L12,1H4A2,2 0 0,0 2,3V15A2,2 0 0,0 4,17H11V19L15,16L11,13V15H4V3H14M21,10V21A2,2 0 0,1 19,23H8A2,2 0 0,1 6,21V19H8V21H19V12H14V7H8V13H6V7A2,2 0 0,1 8,5H16L21,10Z" />
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
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="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:id="@+id/appbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fitsSystemWindows="true">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||||
|
android:id="@+id/collapsingToolbarLayout"
|
||||||
|
style="?attr/collapsingToolbarLayoutMediumStyle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/collapsingToolbarLayoutMediumSize"
|
||||||
|
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
|
||||||
|
app:toolbarId="@id/toolbar">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
app:layout_collapseMode="pin"
|
||||||
|
tools:title="@string/alternatives" />
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="@dimen/list_spacing_normal"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
|
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
||||||
|
tools:listitem="@layout/item_manga_alternative" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
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"
|
||||||
|
app:cardCornerRadius="16dp">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/imageView_cover"
|
||||||
|
android:layout_width="98dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:background="?colorSurfaceContainer"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:layout_constraintDimensionRatio="13:18"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
|
||||||
|
tools:src="@tools:sample/backgrounds/scenic" />
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.history.ui.util.ReadingProgressView
|
||||||
|
android:id="@+id/progressView"
|
||||||
|
android:layout_width="@dimen/card_indicator_size"
|
||||||
|
android:layout_height="@dimen/card_indicator_size"
|
||||||
|
android:layout_margin="@dimen/card_indicator_offset"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/imageView_cover"
|
||||||
|
app:layout_constraintEnd_toEndOf="@id/imageView_cover" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="@tools:sample/lorem" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_subtitle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
||||||
|
tools:text="@tools:sample/lorem/random" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chip_source"
|
||||||
|
style="@style/Widget.Kotatsu.Chip.Assist"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="12dp"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/textView_subtitle"
|
||||||
|
tools:text="Mangadex" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/button_migrate"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:text="@string/migrate"
|
||||||
|
app:icon="@drawable/ic_replace"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/chip_source"
|
||||||
|
app:layout_constraintVertical_bias="1" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
Loading…
Reference in New Issue