Migrate details to AdapterDelegates and mvvm

This commit is contained in:
Koitharu
2020-11-24 06:58:16 +02:00
parent 1b1540b35b
commit fa02cfd7e8
25 changed files with 427 additions and 338 deletions

View File

@@ -32,6 +32,12 @@ class MangaDataRepository(private val db: MangaDatabase) {
return db.mangaDao.find(mangaId)?.toManga()
}
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga
intent.mangaId != MangaIntent.ID_NONE -> db.mangaDao.find(intent.mangaId)?.toManga()
else -> null // TODO resolve uri
}
suspend fun storeManga(manga: Manga) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.base.domain
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import org.koitharu.kotatsu.core.model.Manga
data class MangaIntent(
val manga: Manga?,
val mangaId: Long,
val uri: Uri?
) {
companion object {
fun from(intent: Intent?) = MangaIntent(
manga = intent?.getParcelableExtra(KEY_MANGA),
mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
uri = intent?.data
)
fun from(args: Bundle?) = MangaIntent(
manga = args?.getParcelable(KEY_MANGA),
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
uri = null
)
const val ID_NONE = 0L
const val KEY_MANGA = "manga"
const val KEY_ID = "id"
}
}

View File

@@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.utils.SingleLiveEvent
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
abstract class BaseViewModel : ViewModel() {
@@ -13,19 +15,21 @@ abstract class BaseViewModel : ViewModel() {
val isLoading = MutableLiveData(false)
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(createErrorHandler(), start, block)
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
protected fun launchLoadingJob(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(createErrorHandler(), start) {
isLoading.value = true
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
isLoading.postValue(true)
try {
block()
} finally {
isLoading.value = false
isLoading.postValue(false)
}
}
@@ -34,7 +38,7 @@ abstract class BaseViewModel : ViewModel() {
throwable.printStackTrace()
}
if (throwable !is CancellationException) {
onError.call(throwable)
onError.postCall(throwable)
}
}
}

View File

@@ -5,6 +5,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.koin.core.component.KoinComponent
import org.koitharu.kotatsu.utils.ext.replaceWith
@Deprecated("", replaceWith = ReplaceWith("AsyncListDifferDelegationAdapter"))
abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecyclerItemClickListener<T>? = null) :
RecyclerView.Adapter<BaseViewHolder<T, E>>(),
KoinComponent {

View File

@@ -2,10 +2,13 @@ package org.koitharu.kotatsu.details
import org.koin.android.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.details.ui.DetailsViewModel
val detailsModule
get() = module {
viewModel { DetailsViewModel(get(), get(), get(), get(), get()) }
viewModel { (intent: MangaIntent) ->
DetailsViewModel(intent, get(), get(), get(), get(), get())
}
}

View File

@@ -1,93 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.base.ui.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.base.ui.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.history.domain.ChapterExtra
class ChaptersAdapter(onItemClickListener: OnRecyclerItemClickListener<MangaChapter>) :
BaseRecyclerAdapter<MangaChapter, ChapterExtra>(onItemClickListener) {
private val checkedIds = HashSet<Long>()
val checkedItemsCount: Int
get() = checkedIds.size
val checkedItemsIds: Set<Long>
get() = checkedIds
var currentChapterId: Long? = null
set(value) {
field = value
updateCurrentPosition()
}
var newChaptersCount: Int = 0
set(value) {
val updated = maxOf(field, value)
field = value
notifyItemRangeChanged(itemCount - updated, updated)
}
var currentChapterPosition = RecyclerView.NO_POSITION
private set
fun clearChecked() {
checkedIds.clear()
notifyDataSetChanged()
}
fun checkAll() {
for (item in dataSet) {
checkedIds.add(item.id)
}
notifyDataSetChanged()
}
fun setItemIsChecked(itemId: Long, isChecked: Boolean) {
if ((isChecked && checkedIds.add(itemId)) || (!isChecked && checkedIds.remove(itemId))) {
val pos = findItemPositionById(itemId)
if (pos != RecyclerView.NO_POSITION) {
notifyItemChanged(pos)
}
}
}
fun toggleItemChecked(itemId: Long) {
setItemIsChecked(itemId, itemId !in checkedIds)
}
override fun onCreateViewHolder(parent: ViewGroup) = ChapterHolder(parent)
override fun onGetItemId(item: MangaChapter) = item.id
override fun getExtra(item: MangaChapter, position: Int): ChapterExtra = when {
item.id in checkedIds -> ChapterExtra.CHECKED
currentChapterPosition == RecyclerView.NO_POSITION
|| currentChapterPosition < position -> if (position >= itemCount - newChaptersCount) {
ChapterExtra.NEW
} else {
ChapterExtra.UNREAD
}
currentChapterPosition == position -> ChapterExtra.CURRENT
currentChapterPosition > position -> ChapterExtra.READ
else -> ChapterExtra.UNREAD
}
override fun onDataSetChanged() {
super.onDataSetChanged()
updateCurrentPosition()
}
private fun updateCurrentPosition() {
val pos = currentChapterId?.let {
dataSet.indexOfFirst { x -> x.id == it }
} ?: RecyclerView.NO_POSITION
if (pos != currentChapterPosition) {
currentChapterPosition = pos
notifyDataSetChanged()
}
}
}

View File

@@ -9,75 +9,66 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.fragment_chapters.*
import org.koin.android.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.DownloadService
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.ext.resolveDp
class ChaptersFragment : BaseFragment(R.layout.fragment_chapters),
OnRecyclerItemClickListener<MangaChapter>, ActionMode.Callback {
OnListItemClickListener<MangaChapter>, ActionMode.Callback {
private val viewModel by sharedViewModel<DetailsViewModel>()
private var manga: Manga? = null
private lateinit var adapter: ChaptersAdapter
private var chaptersAdapter: ChaptersAdapter? = null
private var actionMode: ActionMode? = null
private var selectionDecoration: ChaptersSelectionDecoration? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = ChaptersAdapter(this)
recyclerView_chapters.addItemDecoration(
DividerItemDecoration(
view.context,
RecyclerView.VERTICAL
)
)
recyclerView_chapters.setHasFixedSize(true)
recyclerView_chapters.adapter = adapter
chaptersAdapter = ChaptersAdapter(this)
selectionDecoration = ChaptersSelectionDecoration(view.context)
with(recyclerView_chapters) {
addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL))
addItemDecoration(selectionDecoration!!)
setHasFixedSize(true)
adapter = chaptersAdapter
}
viewModel.mangaData.observe(viewLifecycleOwner, this::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.history.observe(viewLifecycleOwner, this::onHistoryChanged)
viewModel.newChapters.observe(viewLifecycleOwner, this::onNewChaptersChanged)
viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)
}
private fun onMangaUpdated(manga: Manga) {
this.manga = manga
adapter.replaceData(manga.chapters.orEmpty())
scrollToCurrent()
override fun onDestroyView() {
chaptersAdapter = null
selectionDecoration = null
super.onDestroyView()
}
private fun onChaptersChanged(list: List<ChapterListItem>) {
chaptersAdapter?.items = list
}
private fun onLoadingStateChanged(isLoading: Boolean) {
progressBar.isVisible = isLoading
}
private fun onHistoryChanged(history: MangaHistory?) {
adapter.currentChapterId = history?.chapterId
scrollToCurrent()
}
private fun onNewChaptersChanged(newChapters: Int) {
adapter.newChaptersCount = newChapters
}
override fun onItemClick(item: MangaChapter, position: Int, view: View) {
if (adapter.checkedItemsCount != 0) {
adapter.toggleItemChecked(item.id)
if (adapter.checkedItemsCount == 0) {
override fun onItemClick(item: MangaChapter, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.id)
if (selectionDecoration?.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
recyclerView_chapters.invalidateItemDecorations()
}
return
}
@@ -91,44 +82,38 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters),
startActivity(
ReaderActivity.newIntent(
context ?: return,
manga ?: return,
viewModel.manga.value ?: return,
item.id
), options.toBundle()
)
}
override fun onItemLongClick(item: MangaChapter, position: Int, view: View): Boolean {
override fun onItemLongClick(item: MangaChapter, view: View): Boolean {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
return actionMode?.also {
adapter.setItemIsChecked(item.id, true)
selectionDecoration?.setItemIsChecked(item.id, true)
recyclerView_chapters.invalidateItemDecorations()
it.invalidate()
} != null
}
private fun scrollToCurrent() {
val pos = (recyclerView_chapters.adapter as? ChaptersAdapter)?.currentChapterPosition
?: RecyclerView.NO_POSITION
if (pos != RecyclerView.NO_POSITION) {
(recyclerView_chapters.layoutManager as? LinearLayoutManager)
?.scrollToPositionWithOffset(pos, resources.resolveDp(40))
}
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
DownloadService.start(
context ?: return false,
manga ?: return false,
adapter.checkedItemsIds
viewModel.manga.value ?: return false,
selectionDecoration?.checkedItemsIds
)
mode.finish()
true
}
R.id.action_select_all -> {
adapter.checkAll()
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionDecoration?.checkAll(ids)
recyclerView_chapters.invalidateItemDecorations()
mode.invalidate()
true
}
@@ -137,6 +122,7 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters),
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val manga = viewModel.manga.value
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
menu.findItem(R.id.action_save).isVisible = manga?.source != MangaSource.LOCAL
mode.title = manga?.title
@@ -144,18 +130,19 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters),
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter.checkedItemsCount
val count = selectionDecoration?.checkedItemsCount ?: return false
mode.subtitle = resources.getQuantityString(
R.plurals.chapters_from_x,
count,
count,
adapter.itemCount
chaptersAdapter?.itemCount ?: 0
)
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
adapter.clearChecked()
selectionDecoration?.clearSelection()
recyclerView_chapters.invalidateItemDecorations()
actionMode = null
}
}

View File

@@ -7,20 +7,22 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.ActionMode
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toFile
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.android.synthetic.main.activity_details.*
import kotlinx.coroutines.launch
import org.koin.android.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.model.Manga
@@ -34,9 +36,9 @@ import org.koitharu.kotatsu.utils.ext.getThemeColor
class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrategy {
private val viewModel by viewModel<DetailsViewModel>()
private var manga: Manga? = null
private val viewModel by viewModel<DetailsViewModel> {
parametersOf(MangaIntent.from(intent))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -44,22 +46,14 @@ class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrate
supportActionBar?.setDisplayHomeAsUpEnabled(true)
pager.adapter = MangaDetailsAdapter(this)
TabLayoutMediator(tabs, pager, this).attach()
if (savedInstanceState == null) {
intent?.getParcelableExtra<Manga>(EXTRA_MANGA)?.let {
viewModel.loadDetails(it, true)
} ?: intent?.getLongExtra(EXTRA_MANGA_ID, 0)?.takeUnless { it == 0L }?.let {
viewModel.findMangaById(it)
} ?: finishAfterTransition()
}
viewModel.mangaData.observe(this, ::onMangaUpdated)
viewModel.manga.observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError)
viewModel.newChapters.observe(this, ::onNewChaptersChanged)
}
private fun onMangaUpdated(manga: Manga) {
this.manga = manga
title = manga.title
invalidateOptionsMenu()
}
@@ -73,7 +67,7 @@ class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrate
}
private fun onError(e: Throwable) {
if (manga == null) {
if (viewModel.manga.value == null) {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition()
} else {
@@ -98,8 +92,9 @@ class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrate
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val manga = viewModel.manga.value
menu.findItem(R.id.action_save).isVisible =
manga?.source != null && manga?.source != MangaSource.LOCAL
manga?.source != null && manga.source != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible =
manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible =
@@ -111,7 +106,7 @@ class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrate
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_share -> {
manga?.let {
viewModel.manga.value?.let {
if (it.source == MangaSource.LOCAL) {
ShareHelper.shareCbz(this, Uri.parse(it.url).toFile())
} else {
@@ -121,8 +116,8 @@ class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrate
true
}
R.id.action_delete -> {
manga?.let { m ->
MaterialAlertDialogBuilder(this)
viewModel.manga.value?.let { m ->
AlertDialog.Builder(this)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title))
.setPositiveButton(R.string.delete) { _, _ ->
@@ -134,10 +129,10 @@ class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrate
true
}
R.id.action_save -> {
manga?.let {
viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) {
MaterialAlertDialogBuilder(this)
AlertDialog.Builder(this)
.setTitle(R.string.save_manga)
.setMessage(
getString(
@@ -160,19 +155,19 @@ class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrate
true
}
R.id.action_browser -> {
manga?.let {
viewModel.manga.value?.let {
startActivity(BrowserActivity.newIntent(this, it.url))
}
true
}
R.id.action_related -> {
manga?.let {
viewModel.manga.value?.let {
startActivity(GlobalSearchActivity.newIntent(this, it.title))
}
true
}
R.id.action_shortcut -> {
manga?.let {
viewModel.manga.value?.let {
lifecycleScope.launch {
if (!MangaShortcut(it).requestPinShortcut(this@DetailsActivity)) {
Snackbar.make(
@@ -192,7 +187,6 @@ class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrate
tab.text = when (position) {
0 -> getString(R.string.details)
1 -> getString(R.string.chapters)
2 -> getString(R.string.related)
else -> null
}
}
@@ -211,17 +205,14 @@ class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrate
companion object {
private const val EXTRA_MANGA = "manga"
const val EXTRA_MANGA_ID = "manga_id"
const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA"
fun newIntent(context: Context, manga: Manga) =
Intent(context, DetailsActivity::class.java)
.putExtra(EXTRA_MANGA, manga)
.putExtra(MangaIntent.KEY_MANGA, manga)
fun newIntent(context: Context, mangaId: Long) =
Intent(context, DetailsActivity::class.java)
.putExtra(EXTRA_MANGA_ID, mangaId)
.putExtra(MangaIntent.KEY_ID, mangaId)
}
}

View File

@@ -29,23 +29,19 @@ class DetailsFragment : BaseFragment(R.layout.fragment_details), View.OnClickLis
private val viewModel by sharedViewModel<DetailsViewModel>()
private var manga: Manga? = null
private var history: MangaHistory? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.mangaData.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
viewModel.history.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
}
private fun onMangaUpdated(manga: Manga) {
this.manga = manga
imageView_cover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl)
.fallback(R.drawable.ic_placeholder)
.crossfade(true)
.lifecycle(this)
.lifecycle(viewLifecycleOwner)
.enqueueWith(coil)
textView_title.text = manga.title
textView_subtitle.textAndVisible = manga.altTitle
@@ -94,12 +90,19 @@ class DetailsFragment : BaseFragment(R.layout.fragment_details), View.OnClickLis
imageView_favourite.setOnClickListener(this)
button_read.setOnClickListener(this)
button_read.setOnLongClickListener(this)
updateReadButton()
button_read.isEnabled = !manga.chapters.isNullOrEmpty()
}
private fun onHistoryChanged(history: MangaHistory?) {
this.history = history
updateReadButton()
with(button_read) {
if (history == null) {
setText(R.string.read)
setIconResource(R.drawable.ic_read)
} else {
setText(R.string._continue)
setIconResource(R.drawable.ic_play)
}
}
}
private fun onFavouriteChanged(categories: List<FavouriteCategory>) {
@@ -117,6 +120,7 @@ class DetailsFragment : BaseFragment(R.layout.fragment_details), View.OnClickLis
}
override fun onClick(v: View) {
val manga = viewModel.manga.value
when {
v.id == R.id.imageView_favourite -> {
FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return)
@@ -126,7 +130,7 @@ class DetailsFragment : BaseFragment(R.layout.fragment_details), View.OnClickLis
ReaderActivity.newIntent(
context ?: return,
manga ?: return,
history
viewModel.readingHistory.value
)
)
}
@@ -145,7 +149,7 @@ class DetailsFragment : BaseFragment(R.layout.fragment_details), View.OnClickLis
override fun onLongClick(v: View): Boolean {
when (v.id) {
R.id.button_read -> {
if (history == null) {
if (viewModel.readingHistory.value == null) {
return false
}
v.showPopupMenu(R.menu.popup_read) {
@@ -154,7 +158,7 @@ class DetailsFragment : BaseFragment(R.layout.fragment_details), View.OnClickLis
startActivity(
ReaderActivity.newIntent(
context ?: return@showPopupMenu false,
manga ?: return@showPopupMenu false
viewModel.manga.value ?: return@showPopupMenu false
)
)
true
@@ -167,19 +171,4 @@ class DetailsFragment : BaseFragment(R.layout.fragment_details), View.OnClickLis
else -> return false
}
}
private fun updateReadButton() {
if (manga?.chapters.isNullOrEmpty()) {
button_read.isEnabled = false
} else {
button_read.isEnabled = true
if (history == null) {
button_read.setText(R.string.read)
button_read.setIconResource(R.drawable.ic_read)
} else {
button_read.setText(R.string._continue)
button_read.setIconResource(R.drawable.ic_play)
}
}
}
}

View File

@@ -1,18 +1,18 @@
package org.koitharu.kotatsu.details.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.domain.OnFavouritesChangeListener
import org.koitharu.kotatsu.history.domain.ChapterExtra
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.OnHistoryChangeListener
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
@@ -20,88 +20,82 @@ import org.koitharu.kotatsu.utils.ext.safe
import java.io.IOException
class DetailsViewModel(
intent: MangaIntent,
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository,
private val mangaDataRepository: MangaDataRepository
) : BaseViewModel(), OnHistoryChangeListener, OnFavouritesChangeListener {
) : BaseViewModel() {
private val mangaData = MutableStateFlow<Manga?>(intent.manga)
private val history = mangaData.mapNotNull { it?.id }
.distinctUntilChanged()
.flatMapLatest { mangaId ->
historyRepository.observeOne(mangaId)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val favourite = mangaData.mapNotNull { it?.id }
.distinctUntilChanged()
.flatMapLatest { mangaId ->
favouritesRepository.observeCategories(mangaId)
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val newChapters = mangaData.mapNotNull { it?.id }
.distinctUntilChanged()
.mapLatest { mangaId ->
trackingRepository.getNewChaptersCount(mangaId)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
val manga = mangaData.filterNotNull()
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val favouriteCategories = favourite
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val newChaptersCount = newChapters
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val readingHistory = history
.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val mangaData = MutableLiveData<Manga>()
val newChapters = MutableLiveData<Int>(0)
val onMangaRemoved = SingleLiveEvent<Manga>()
val history = MutableLiveData<MangaHistory?>()
val favouriteCategories = MutableLiveData<List<FavouriteCategory>>()
val chapters = combine(
mangaData.map { it?.chapters.orEmpty() },
history.map { it?.chapterId },
newChapters
) { chapters, currentId, newCount ->
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
chapters.mapIndexed { index, chapter ->
chapter.toListItem(
when {
index >= firstNewIndex -> ChapterExtra.NEW
index == currentIndex -> ChapterExtra.CURRENT
index < currentIndex -> ChapterExtra.READ
else -> ChapterExtra.UNREAD
}
)
}
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init {
HistoryRepository.subscribe(this)
FavouritesRepository.subscribe(this)
}
fun findMangaById(id: Long) {
launchLoadingJob {
val manga = mangaDataRepository.findMangaById(id)
?: throw MangaNotFoundException("Cannot find manga by id")
launchLoadingJob(Dispatchers.Default) {
var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga")
mangaData.value = manga
manga = manga.source.repository.getDetails(manga)
mangaData.value = manga
loadDetails(manga, true)
}
}
fun loadDetails(manga: Manga, force: Boolean = false) {
if (!force && mangaData.value == manga) {
return
}
loadHistory(manga)
mangaData.value = manga
loadFavourite(manga)
launchLoadingJob {
val data = withContext(Dispatchers.Default) {
manga.source.repository.getDetails(manga)
}
mangaData.value = data
newChapters.value = trackingRepository.getNewChaptersCount(manga.id)
}
}
fun deleteLocal(manga: Manga) {
launchLoadingJob {
withContext(Dispatchers.Default) {
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
safe {
historyRepository.deleteOrSwap(manga, original)
}
launchLoadingJob(Dispatchers.Default) {
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
safe {
historyRepository.deleteOrSwap(manga, original)
}
onMangaRemoved.call(manga)
onMangaRemoved.postCall(manga)
}
}
private fun loadHistory(manga: Manga) {
launchJob {
history.value = historyRepository.getOne(manga)
}
}
private fun loadFavourite(manga: Manga) {
launchJob {
favouriteCategories.value = favouritesRepository.getCategories(manga.id)
}
}
override fun onHistoryChanged() {
loadHistory(mangaData.value ?: return)
}
override fun onFavouritesChanged(mangaId: Long) {
val manga = mangaData.value ?: return
if (mangaId == manga.id) {
loadFavourite(manga)
}
}
override fun onCleared() {
HistoryRepository.unsubscribe(this)
FavouritesRepository.unsubscribe(this)
super.onCleared()
}
}

View File

@@ -1,23 +1,29 @@
package org.koitharu.kotatsu.details.ui
package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Color
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateLayoutContainer
import kotlinx.android.synthetic.main.item_chapter.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.history.domain.ChapterExtra
import org.koitharu.kotatsu.utils.ext.getThemeColor
class ChapterHolder(parent: ViewGroup) :
BaseViewHolder<MangaChapter, ChapterExtra>(parent, R.layout.item_chapter) {
fun chapterListItemAD(
clickListener: OnListItemClickListener<MangaChapter>
) = adapterDelegateLayoutContainer<ChapterListItem, ChapterListItem>(R.layout.item_chapter) {
override fun onBind(data: MangaChapter, extra: ChapterExtra) {
textView_title.text = data.name
textView_number.text = data.number.toString()
imageView_check.isVisible = extra == ChapterExtra.CHECKED
when (extra) {
itemView.setOnClickListener {
clickListener.onItemClick(item.chapter, it)
}
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.chapter, it)
}
bind { payload ->
textView_title.text = item.chapter.name
textView_number.text = item.chapter.number.toString()
when (item.extra) {
ChapterExtra.UNREAD -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_default)
textView_number.setTextColor(context.getThemeColor(android.R.attr.textColorSecondaryInverse))
@@ -34,10 +40,6 @@ class ChapterHolder(parent: ViewGroup) :
textView_number.setBackgroundResource(R.drawable.bg_badge_accent)
textView_number.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
}
ChapterExtra.CHECKED -> {
textView_number.background = null
textView_number.setTextColor(Color.TRANSPARENT)
}
}
}
}

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.details.ui.adapter
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import kotlin.jvm.internal.Intrinsics
class ChaptersAdapter(
onItemClickListener: OnListItemClickListener<MangaChapter>
) : AsyncListDifferDelegationAdapter<ChapterListItem>(DiffCallback()) {
init {
setHasStableIds(true)
delegatesManager.addDelegate(chapterListItemAD(onItemClickListener))
}
override fun getItemId(position: Int): Long {
return items[position].chapter.id
}
private class DiffCallback : DiffUtil.ItemCallback<ChapterListItem>() {
override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {
return oldItem.chapter.id == newItem.chapter.id
}
override fun areContentsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? {
if (oldItem.extra != newItem.extra) {
return newItem.extra
}
return null
}
}
}

View File

@@ -0,0 +1,108 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import androidx.collection.ArraySet
import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.resolveDp
class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() {
private val icon = ContextCompat.getDrawable(context, R.drawable.ic_check)
private val padding = context.resources.resolveDp(16)
private val bounds = Rect()
private val selection = ArraySet<Long>()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
paint.color = context.getThemeColor(android.R.attr.colorControlActivated)
paint.style = Paint.Style.FILL
}
val checkedItemsCount: Int
get() = selection.size
val checkedItemsIds: Set<Long>
get() = selection
fun toggleItemChecked(id: Long) {
if (!selection.remove(id)) {
selection.add(id)
}
}
fun setItemIsChecked(id: Long, isChecked: Boolean) {
if (isChecked) {
selection.add(id)
} else {
selection.remove(id)
}
}
fun checkAll(ids: Collection<Long>) {
selection.addAll(ids)
}
fun clearSelection() {
selection.clear()
}
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
icon ?: return
canvas.save()
if (parent.clipToPadding) {
canvas.clipRect(
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
parent.height - parent.paddingBottom
)
}
for (child in parent.children) {
val itemId = parent.getChildItemId(child)
if (itemId in selection) {
parent.getDecoratedBoundsWithMargins(child, bounds)
bounds.offset(child.translationX.toInt(), child.translationY.toInt())
canvas.drawRect(bounds, paint)
}
}
canvas.restore()
}
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
icon ?: return
canvas.save()
val left: Int
val right: Int
if (parent.clipToPadding) {
left = parent.paddingLeft
right = parent.width - parent.paddingRight
canvas.clipRect(
left, parent.paddingTop, right,
parent.height - parent.paddingBottom
)
} else {
left = 0
right = parent.width
}
for (child in parent.children) {
val itemId = parent.getChildItemId(child)
if (itemId in selection) {
parent.getDecoratedBoundsWithMargins(child, bounds)
bounds.offset(child.translationX.toInt(), child.translationY.toInt())
val hh = (bounds.height() - icon.intrinsicHeight) / 2
val top: Int = bounds.top + hh
val bottom: Int = bounds.bottom - hh
icon.setBounds(right - icon.intrinsicWidth - padding, top, right - padding, bottom)
icon.draw(canvas)
}
}
canvas.restore()
}
}

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.history.domain.ChapterExtra
data class ChapterListItem(
val chapter: MangaChapter,
val extra: ChapterExtra
)

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.history.domain.ChapterExtra
fun MangaChapter.toListItem(extra: ChapterExtra) = ChapterListItem(
chapter = this,
extra = extra
)

View File

@@ -38,6 +38,10 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga?
@Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")
abstract fun observe(id: Long): Flow<FavouriteManga?>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(favourite: FavouriteEntity)

View File

@@ -4,6 +4,7 @@ import androidx.collection.ArraySet
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@@ -57,6 +58,12 @@ class FavouritesRepository(private val db: MangaDatabase) {
return entities?.map { it.toFavouriteCategory() }.orEmpty()
}
fun observeCategories(mangaId: Long): Flow<List<FavouriteCategory>> {
return db.favouritesDao.observe(mangaId).map { entity ->
entity?.categories?.map { it.toFavouriteCategory() }.orEmpty()
}
}
suspend fun addCategory(title: String): FavouriteCategory {
val entity = FavouriteCategoryEntity(
title = title,

View File

@@ -25,6 +25,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity?
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract fun observe(id: Long): Flow<HistoryEntity?>
@Query("DELETE FROM history")
abstract suspend fun clear()

View File

@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.history.domain
enum class ChapterExtra {
READ, CURRENT, UNREAD, NEW, CHECKED
READ, CURRENT, UNREAD, NEW
}

View File

@@ -4,6 +4,7 @@ import androidx.collection.ArraySet
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -32,6 +33,12 @@ class HistoryRepository(private val db: MangaDatabase) : KoinComponent {
}
}
fun observeOne(id: Long): Flow<MangaHistory?> {
return db.historyDao.observe(id).map {
it?.toMangaHistory()
}
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {

View File

@@ -5,18 +5,17 @@ import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.dialog_chapters.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.details.ui.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.utils.ext.withArgs
class ChaptersDialog : AlertDialogFragment(R.layout.dialog_chapters),
OnRecyclerItemClickListener<MangaChapter> {
OnListItemClickListener<MangaChapter> {
override fun onBuildDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.chapters)
@@ -32,22 +31,12 @@ class ChaptersDialog : AlertDialogFragment(R.layout.dialog_chapters),
)
)
recyclerView_chapters.adapter = ChaptersAdapter(this).apply {
arguments?.getParcelableArrayList<MangaChapter>(ARG_CHAPTERS)?.let(this::replaceData)
currentChapterId = arguments?.getLong(ARG_CURRENT_ID, 0L)?.takeUnless { it == 0L }
// arguments?.getParcelableArrayList<MangaChapter>(ARG_CHAPTERS)?.let(this::setItems)
// currentChapterId = arguments?.getLong(ARG_CURRENT_ID, 0L)?.takeUnless { it == 0L }
}
}
override fun onResume() {
super.onResume()
val pos = (recyclerView_chapters.adapter as? ChaptersAdapter)?.currentChapterPosition
?: RecyclerView.NO_POSITION
if (pos != RecyclerView.NO_POSITION) {
(recyclerView_chapters.layoutManager as? LinearLayoutManager)
?.scrollToPositionWithOffset(pos, 100)
}
}
override fun onItemClick(item: MangaChapter, position: Int, view: View) {
override fun onItemClick(item: MangaChapter, view: View) {
((parentFragment as? OnChapterChangeListener)
?: (activity as? OnChapterChangeListener))?.let {
dismiss()

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.flow.MutableStateFlow
class SelectionController {
private val state = MutableStateFlow(emptySet<Int>())
}

View File

@@ -9,8 +9,8 @@ import coil.executeBlocking
import coil.request.ImageRequest
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.utils.ext.requireBitmap
import java.io.IOException
@@ -52,7 +52,7 @@ class RecentListFactory(
views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder)
}
val intent = Intent()
intent.putExtra(DetailsActivity.EXTRA_MANGA_ID, item.id)
intent.putExtra(MangaIntent.KEY_ID, item.id)
views.setOnClickFillInIntent(R.id.imageView_cover, intent)
return views
}

View File

@@ -9,9 +9,9 @@ import coil.executeBlocking
import coil.request.ImageRequest
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppWidgetConfig
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.utils.ext.requireBitmap
import java.io.IOException
@@ -63,7 +63,7 @@ class ShelfListFactory(
views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder)
}
val intent = Intent()
intent.putExtra(DetailsActivity.EXTRA_MANGA_ID, item.id)
intent.putExtra(MangaIntent.KEY_ID, item.id)
views.setOnClickFillInIntent(R.id.rootLayout, intent)
return views
}

View File

@@ -21,20 +21,6 @@
android:textColor="?android:textColorSecondaryInverse"
tools:text="13" />
<ImageView
android:id="@+id/imageView_check"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_alignParentBottom="true"
android:layout_toStartOf="@id/textView_title"
android:contentDescription="@null"
android:scaleType="centerInside"
android:src="@drawable/ic_check"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"