From c0e94f8415973af2c9f230be9903013c3d27e173 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 17 Oct 2023 12:19:44 +0300 Subject: [PATCH] Show chapters list in downloads --- .../core/parser/RemoteMangaRepository.kt | 4 ++ .../kotatsu/download/domain/DownloadState.kt | 12 +++- .../download/ui/list/DownloadItemAD.kt | 17 ++++++ .../download/ui/list/DownloadItemModel.kt | 6 ++ .../download/ui/list/DownloadsActivity.kt | 6 +- .../download/ui/list/DownloadsViewModel.kt | 61 ++++++++++++++++--- .../ui/list/chapters/DownloadChapter.kt | 14 +++++ .../ui/list/chapters/DownloadChapterAD.kt | 20 ++++++ .../download/ui/worker/DownloadWorker.kt | 3 + .../kotatsu/list/ui/adapter/ListItemType.kt | 1 + .../ui/adapter/TypedListSpacingDecoration.kt | 5 +- .../main/res/layout/item_chapter_download.xml | 42 +++++++++++++ app/src/main/res/layout/item_download.xml | 30 +++++++-- 13 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapter.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapterAD.kt create mode 100644 app/src/main/res/layout/item_chapter_download.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index 949c30415..1a66dc4e6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -128,6 +128,10 @@ class RemoteMangaRepository( return details.await() } + suspend fun peekDetails(manga: Manga): Manga? { + return cache.getDetails(source, manga.url) + } + suspend fun find(manga: Manga): Manga? { val list = getList(0, manga.title) return list.find { x -> x.id == manga.id } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt index 8adf4a953..050fd0e3a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt @@ -19,6 +19,7 @@ data class DownloadState( val eta: Long = -1L, val localManga: LocalManga? = null, val downloadedChapters: LongArray = LongArray(0), + val scheduledChapters: LongArray = LongArray(0), val timestamp: Long = System.currentTimeMillis(), ) { @@ -42,6 +43,7 @@ data class DownloadState( .putLong(DATA_TIMESTAMP, timestamp) .putString(DATA_ERROR, error) .putLongArray(DATA_CHAPTERS, downloadedChapters) + .putLongArray(DATA_CHAPTERS_SRC, scheduledChapters) .putBoolean(DATA_INDETERMINATE, isIndeterminate) .putBoolean(DATA_PAUSED, isPaused) .build() @@ -64,10 +66,13 @@ data class DownloadState( if (eta != other.eta) return false if (localManga != other.localManga) return false if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false + if (!scheduledChapters.contentEquals(other.scheduledChapters)) return false if (timestamp != other.timestamp) return false if (max != other.max) return false if (progress != other.progress) return false - return percent == other.percent + if (percent != other.percent) return false + + return true } override fun hashCode(): Int { @@ -83,6 +88,7 @@ data class DownloadState( result = 31 * result + eta.hashCode() result = 31 * result + (localManga?.hashCode() ?: 0) result = 31 * result + downloadedChapters.contentHashCode() + result = 31 * result + scheduledChapters.contentHashCode() result = 31 * result + timestamp.hashCode() result = 31 * result + max result = 31 * result + progress @@ -90,12 +96,14 @@ data class DownloadState( return result } + companion object { private const val DATA_MANGA_ID = "manga_id" private const val DATA_MAX = "max" private const val DATA_PROGRESS = "progress" private const val DATA_CHAPTERS = "chapter" + private const val DATA_CHAPTERS_SRC = "chapters_src" private const val DATA_ETA = "eta" private const val DATA_TIMESTAMP = "timestamp" private const val DATA_ERROR = "error" @@ -119,5 +127,7 @@ data class DownloadState( fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L)) fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0) + + fun getScheduledChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS_SRC) ?: LongArray(0) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt index 0a6b741f2..1cc1d10cd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt @@ -1,18 +1,26 @@ package org.koitharu.kotatsu.download.ui.list import android.view.View +import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView import androidx.work.WorkInfo import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.image.TrimTransformation +import org.koitharu.kotatsu.core.util.ext.drawableEnd 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.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemDownloadBinding +import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter +import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD +import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.util.format @@ -25,6 +33,9 @@ fun downloadItemAD( ) { val percentPattern = context.resources.getString(R.string.percent_string_pattern) + val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse) + val chaptersAdapter = BaseListAdapter() + .addDelegate(ListItemType.CHAPTER, downloadChapterAD()) val clickListener = object : View.OnClickListener, View.OnLongClickListener { override fun onClick(v: View) { @@ -45,6 +56,8 @@ fun downloadItemAD( binding.buttonResume.setOnClickListener(clickListener) itemView.setOnClickListener(clickListener) itemView.setOnLongClickListener(clickListener) + binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL)) + binding.recyclerViewChapters.adapter = chaptersAdapter bind { payloads -> binding.textViewTitle.text = item.manga.title @@ -57,6 +70,10 @@ fun downloadItemAD( source(item.manga.source) enqueueWith(coil) } + binding.textViewTitle.isChecked = item.isExpanded + binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null + binding.cardDetails.isVisible = item.isExpanded + chaptersAdapter.items = item.chapters when (item.workState) { WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt index 42305f769..8cad3b244 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.download.ui.list import android.text.format.DateUtils import androidx.work.WorkInfo +import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import java.util.Date @@ -19,6 +20,8 @@ data class DownloadItemModel( val progress: Int, val eta: Long, val timestamp: Date, + val chapters: List, + val isExpanded: Boolean, ) : ListModel, Comparable { val percent: Float @@ -33,6 +36,9 @@ data class DownloadItemModel( val canResume: Boolean get() = workState == WorkInfo.State.RUNNING && isPaused + val isExpandable: Boolean + get() = chapters.isNotEmpty() + fun getEtaString(): CharSequence? = if (hasEta) { DateUtils.getRelativeTimeSpanString( eta, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index c7387dda6..f01eb4803 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -82,7 +82,11 @@ class DownloadsActivity : BaseActivity(), if (selectionController.onItemClick(item.id.mostSignificantBits)) { return } - startActivity(DetailsActivity.newIntent(view.context, item.manga)) + if (item.isExpandable) { + viewModel.expandCollapse(item) + } else { + startActivity(DetailsActivity.newIntent(view.context, item.manga)) + } } override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt index af02a056d..20955249d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -8,15 +8,19 @@ import androidx.work.Data import androidx.work.WorkInfo import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.util.ReversibleAction @@ -24,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.download.domain.DownloadState +import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListHeader @@ -31,6 +36,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.Date import java.util.LinkedList import java.util.UUID @@ -41,13 +47,18 @@ import javax.inject.Inject class DownloadsViewModel @Inject constructor( private val workScheduler: DownloadWorker.Scheduler, private val mangaDataRepository: MangaDataRepository, + private val mangaRepositoryFactory: MangaRepository.Factory, ) : BaseViewModel() { private val mangaCache = LongSparseArray() private val cacheMutex = Mutex() - private val works = workScheduler.observeWorks() - .mapLatest { it.toDownloadsList() } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + private val expanded = MutableStateFlow(emptySet()) + private val works = combine( + workScheduler.observeWorks(), + expanded, + ) { list, exp -> + list.toDownloadsList(exp) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val onActionDone = MutableEventFlow() @@ -169,11 +180,21 @@ class DownloadsViewModel @Inject constructor( it.id.mostSignificantBits } ?: emptySet() - private suspend fun List.toDownloadsList(): List { + fun expandCollapse(item: DownloadItemModel) { + expanded.update { + if (item.id in it) { + it - item.id + } else { + it + item.id + } + } + } + + private suspend fun List.toDownloadsList(exp: Set): List { if (isEmpty()) { return emptyList() } - val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() } + val list = mapNotNullTo(ArrayList(size)) { it.toUiModel(it.id in exp) } list.sortByDescending { it.timestamp } return list } @@ -213,11 +234,13 @@ class DownloadsViewModel @Inject constructor( return destination } - private suspend fun WorkInfo.toUiModel(): DownloadItemModel? { + private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? { val workData = if (outputData == Data.EMPTY) progress else outputData val mangaId = DownloadState.getMangaId(workData) if (mangaId == 0L) return null val manga = getManga(mangaId) ?: return null + val downloadedChapters = DownloadState.getDownloadedChapters(workData) + val scheduledChapters = DownloadState.getScheduledChapters(workData).toSet() return DownloadItemModel( id = id, workState = state, @@ -229,7 +252,19 @@ class DownloadsViewModel @Inject constructor( progress = DownloadState.getProgress(workData), eta = DownloadState.getEta(workData), timestamp = DownloadState.getTimestamp(workData), - totalChapters = DownloadState.getDownloadedChapters(workData).size, + totalChapters = downloadedChapters.size, + isExpanded = isExpanded, + chapters = manga.chapters?.mapNotNull { + if (it.id in scheduledChapters) { + DownloadChapter( + number = it.number, + name = it.name, + isDownloaded = it.id in downloadedChapters, + ) + } else { + null + } + }.orEmpty(), ) } @@ -261,8 +296,16 @@ class DownloadsViewModel @Inject constructor( } return cacheMutex.withLock { mangaCache.getOrElse(mangaId) { - mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null + mangaDataRepository.findMangaById(mangaId)?.let { + tryLoad(it) ?: it + }?.also { + mangaCache[mangaId] = it + } ?: return null } } } + + private suspend fun tryLoad(manga: Manga) = runCatchingCancellable { + (mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).peekDetails(manga) + }.getOrNull() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapter.kt new file mode 100644 index 000000000..378a28b42 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapter.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.download.ui.list.chapters + +import org.koitharu.kotatsu.list.ui.model.ListModel + +data class DownloadChapter( + val number: Int, + val name: String, + val isDownloaded: Boolean, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is DownloadChapter && other.name == name + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapterAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapterAD.kt new file mode 100644 index 000000000..30ebff4ef --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapterAD.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.download.ui.list.chapters + +import androidx.core.content.ContextCompat +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.drawableEnd +import org.koitharu.kotatsu.databinding.ItemChapterDownloadBinding + +fun downloadChapterAD() = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemChapterDownloadBinding.inflate(layoutInflater, parent, false) }, +) { + + val iconDone = ContextCompat.getDrawable(context, R.drawable.ic_check) + + bind { + binding.textViewNumber.text = item.number.toString() + binding.textViewTitle.text = item.name + binding.textViewTitle.drawableEnd = if (item.isDownloaded) iconDone else null + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 5fb8d4d85..97f6ba192 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -178,6 +178,9 @@ class DownloadWorker @AssistedInject constructor( } } val chapters = getChapters(mangaDetails, includedIds) + publishState( + currentState.copy(scheduledChapters = LongArray(chapters.size) { i -> chapters[i].id }), + ) for ((chapterIndex, chapter) in chapters.withIndex()) { if (chaptersToSkip.remove(chapter.id)) { publishState( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt index b937e0ae7..63cbd2c45 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt @@ -26,4 +26,5 @@ enum class ListItemType { CATEGORY_LARGE, MANGA_SCROBBLING, NAV_ITEM, + CHAPTER, } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt index 72f2aebd4..a1b3d8ca2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt @@ -59,6 +59,7 @@ class TypedListSpacingDecoration( ListItemType.MANGA_NESTED_GROUP, ListItemType.CATEGORY_LARGE, ListItemType.NAV_ITEM, + ListItemType.CHAPTER, null, -> outRect.set(0) @@ -77,6 +78,6 @@ class TypedListSpacingDecoration( private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing) private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP - || this == ListItemType.FILTER_SORT - || this == ListItemType.FILTER_TAG + || this == ListItemType.FILTER_SORT + || this == ListItemType.FILTER_TAG } diff --git a/app/src/main/res/layout/item_chapter_download.xml b/app/src/main/res/layout/item_chapter_download.xml new file mode 100644 index 000000000..35c341cdf --- /dev/null +++ b/app/src/main/res/layout/item_chapter_download.xml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml index 95ba3763a..f9f060cb3 100644 --- a/app/src/main/res/layout/item_download.xml +++ b/app/src/main/res/layout/item_download.xml @@ -24,7 +24,7 @@ app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Medium" tools:src="@tools:sample/backgrounds/scenic" /> - + + + + + +