Show chapters in downloads list

This commit is contained in:
Koitharu
2023-12-02 11:42:17 +02:00
parent a7a9ee9d59
commit 91179ef901
14 changed files with 275 additions and 39 deletions

View File

@@ -79,7 +79,11 @@ sealed class DateTimeAgo {
private val day = date.daysDiff(0)
override fun format(resources: Resources): String {
return date.format("d MMMM")
return if (date.time == 0L) {
resources.getString(R.string.unknown)
} else {
date.format("d MMMM")
}
}
override fun equals(other: Any?): Boolean {

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.core.content.withStyledAttributes
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
class NestedRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
private var maxHeight: Int = 0
init {
context.withStyledAttributes(attrs, R.styleable.NestedRecyclerView) {
maxHeight = getDimensionPixelSize(R.styleable.NestedRecyclerView_maxHeight, maxHeight)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent?): Boolean {
if (e?.actionMasked == MotionEvent.ACTION_UP) {
requestDisallowInterceptTouchEvent(false)
} else {
requestDisallowInterceptTouchEvent(true)
}
return super.onTouchEvent(e)
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
super.onMeasure(
widthSpec,
if (maxHeight == 0) {
heightSpec
} else {
MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
},
)
}
}

View File

@@ -1,12 +1,14 @@
package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint
import androidx.work.Data
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.WorkRequest
import androidx.work.await
import androidx.work.impl.WorkManagerImpl
import androidx.work.impl.model.WorkSpec
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -69,5 +71,24 @@ suspend fun WorkManager.awaitUpdateWork(request: WorkRequest): WorkManager.Updat
return updateWork(request).await()
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.getWorkSpec(id: UUID): WorkSpec? = suspendCoroutine { cont ->
workManagerImpl.workTaskExecutor.executeOnTaskThread {
try {
val spec = workManagerImpl.workDatabase.workSpecDao().getWorkSpec(id.toString())
cont.resume(spec)
} catch (e: Exception) {
cont.resumeWithException(e)
}
}
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.getWorkInputData(id: UUID): Data? = getWorkSpec(id)?.input
val Data.isEmpty: Boolean
get() = this == Data.EMPTY
private val WorkManager.workManagerImpl
@SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl

View File

@@ -53,7 +53,7 @@ data class DownloadState(
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter_cnt"
private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp"
const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate"
private const val DATA_PAUSED = "paused"

View File

@@ -1,23 +1,31 @@
package org.koitharu.kotatsu.download.ui.list
import android.transition.TransitionManager
import android.view.View
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.work.WorkInfo
import coil.ImageLoader
import coil.request.SuccessResult
import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
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.enqueueWith
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
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.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format
@@ -30,7 +38,7 @@ fun downloadItemAD(
) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
// val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
var chaptersJob: Job? = null
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
@@ -38,6 +46,7 @@ fun downloadItemAD(
R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_resume -> listener.onResumeClick(item)
R.id.button_pause -> listener.onPauseClick(item)
R.id.imageView_expand -> listener.onExpandClick(item)
else -> listener.onItemClick(item, v)
}
}
@@ -46,31 +55,60 @@ fun downloadItemAD(
return listener.onItemLongClick(item, v)
}
}
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
binding.recyclerViewChapters.adapter = chaptersAdapter
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonPause.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
binding.imageViewExpand.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
bind { payloads ->
if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(binding.constraintLayout)
fun scrollToCurrentChapter() {
val rv = binding.recyclerViewChapters
if (!rv.isVisible) {
return
}
binding.textViewTitle.text = item.manga.title
val chapters = chaptersAdapter.items
if (chapters.isEmpty()) {
return
}
val targetPos = item.chaptersDownloaded.coerceIn(chapters.indices)
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos, rv.height / 3)
}
bind { payloads ->
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.unknown)
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga?.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
transformations(TrimTransformation())
memoryCacheKey(item.coverCacheKey)
source(item.manga.source)
source(item.manga?.source)
enqueueWith(coil)
}
}
// binding.textViewTitle.isChecked = item.isExpanded
// binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
if (chaptersJob == null || payloads.isEmpty()) {
chaptersJob?.cancel()
chaptersJob = lifecycleOwner.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
item.chapters.collect { chapters ->
binding.imageViewExpand.isGone = chapters.isNullOrEmpty()
chaptersAdapter.emit(chapters)
scrollToCurrentChapter()
}
}
} else if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) {
binding.recyclerViewChapters.post {
scrollToCurrentChapter()
}
}
binding.imageViewExpand.isChecked = item.isExpanded
binding.recyclerViewChapters.isVisible = item.isExpanded
when (item.workState) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> {

View File

@@ -9,4 +9,6 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
fun onPauseClick(item: DownloadItemModel)
fun onResumeClick(item: DownloadItemModel)
fun onExpandClick(item: DownloadItemModel)
}

View File

@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils
import androidx.work.WorkInfo
import coil.memory.MemoryCache
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
@@ -14,7 +16,7 @@ data class DownloadItemModel(
val workState: WorkInfo.State,
val isIndeterminate: Boolean,
val isPaused: Boolean,
val manga: Manga,
val manga: Manga?,
val error: String?,
val max: Int,
val progress: Int,
@@ -22,9 +24,10 @@ data class DownloadItemModel(
val timestamp: Date,
val chaptersDownloaded: Int,
val isExpanded: Boolean,
val chapters: StateFlow<List<DownloadChapter>?>,
) : ListModel, Comparable<DownloadItemModel> {
val coverCacheKey = MemoryCache.Key(manga.coverUrl, mapOf("dl" to "1"))
val coverCacheKey = MemoryCache.Key(manga?.coverUrl.orEmpty(), mapOf("dl" to "1"))
val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f
@@ -38,9 +41,6 @@ data class DownloadItemModel(
val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused
val isExpandable: Boolean
get() = false // TODO
fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString(
eta,

View File

@@ -84,17 +84,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
if (item.isExpandable) {
viewModel.expandCollapse(item)
} else {
startActivity(DetailsActivity.newIntent(view.context, item.manga))
}
startActivity(DetailsActivity.newIntent(view.context, item.manga ?: return))
}
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
return selectionController.onItemLongClick(item.id.mostSignificantBits)
}
override fun onExpandClick(item: DownloadItemModel) {
if (!selectionController.onItemClick(item.id.mostSignificantBits)) {
viewModel.expandCollapse(item)
}
}
override fun onCancelClick(item: DownloadItemModel) {
viewModel.cancel(item.id)
}

View File

@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.download.ui.list
import androidx.collection.ArrayMap
import androidx.collection.LongSparseArray
import androidx.collection.getOrElse
import androidx.collection.set
import androidx.lifecycle.viewModelScope
import androidx.work.Data
import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@@ -27,12 +30,17 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction
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.core.util.ext.isEmpty
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
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -47,11 +55,15 @@ class DownloadsViewModel @Inject constructor(
private val workScheduler: DownloadWorker.Scheduler,
private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
private val localMangaRepository: LocalMangaRepository,
) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex()
private val expanded = MutableStateFlow(emptySet<UUID>())
private val chaptersCache = ArrayMap<UUID, StateFlow<List<DownloadChapter>?>>()
private val works = combine(
workScheduler.observeWorks(),
expanded,
@@ -234,10 +246,18 @@ class DownloadsViewModel @Inject constructor(
}
private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? {
val workData = if (outputData == Data.EMPTY) progress else outputData
val workData = outputData.takeUnless { it.isEmpty }
?: progress.takeUnless { it.isEmpty }
?: workScheduler.getInputData(id)
?: return null
val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null
val chapters = synchronized(chaptersCache) {
chaptersCache.getOrPut(id) {
observeChapters(manga, id)
}
}
return DownloadItemModel(
id = id,
workState = state,
@@ -251,6 +271,7 @@ class DownloadsViewModel @Inject constructor(
timestamp = DownloadState.getTimestamp(workData),
chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
isExpanded = isExpanded,
chapters = chapters,
)
}
@@ -282,16 +303,42 @@ class DownloadsViewModel @Inject constructor(
}
return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.let {
tryLoad(it) ?: it
}?.also {
mangaDataRepository.findMangaById(mangaId)?.also {
mangaCache[mangaId] = it
} ?: return null
}
}
}
private fun observeChapters(manga: Manga, workId: UUID): StateFlow<List<DownloadChapter>?> = flow {
val chapterIds = workScheduler.getInputChaptersIds(workId)?.toSet()
val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow
suspend fun mapChapters(): List<DownloadChapter> {
val size = chapterIds?.size ?: chapters.size
val localChapters =
localMangaRepository.findSavedManga(manga)?.manga?.chapters?.mapToSet { it.id }.orEmpty()
return chapters.mapNotNullTo(ArrayList(size)) {
if (chapterIds == null || it.id in chapterIds) {
DownloadChapter(
number = it.number,
name = it.name,
isDownloaded = it.id in localChapters,
)
} else {
null
}
}
}
emit(mapChapters())
localStorageChanges.collect {
if (it?.manga?.id == manga.id) {
emit(mapChapters())
}
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).peekDetails(manga)
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga)
}.getOrNull()
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui.list.chapters
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
data class DownloadChapter(
@@ -11,4 +12,12 @@ data class DownloadChapter(
override fun areItemsTheSame(other: ListModel): Boolean {
return other is DownloadChapter && other.name == name
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is DownloadChapter && previousState.name == name && previousState.number == number) {
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui.worker
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
@@ -55,6 +56,8 @@ import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork
import org.koitharu.kotatsu.core.util.ext.deleteWorks
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
@@ -121,7 +124,9 @@ class DownloadWorker @AssistedInject constructor(
val notification = notificationFactory.create(currentState.copy(isStopped = true))
notificationManager.notify(id.hashCode(), notification)
}
throw e
Result.failure(
currentState.copy(eta = -1L).toWorkData(),
)
} catch (e: IOException) {
e.printStackTraceDebug()
Result.retry()
@@ -417,6 +422,19 @@ class DownloadWorker @AssistedInject constructor(
fun observeWorks(): Flow<List<WorkInfo>> = workManager
.getWorkInfosByTagFlow(TAG)
@SuppressLint("RestrictedApi")
suspend fun getInputData(id: UUID): Data? {
val spec = workManager.getWorkSpec(id) ?: return null
return Data.Builder()
.putAll(spec.input)
.putLong(DownloadState.DATA_TIMESTAMP, spec.scheduleRequestedAt)
.build()
}
suspend fun getInputChaptersIds(workId: UUID): LongArray? {
return workManager.getWorkInputData(workId)?.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
}
suspend fun cancel(id: UUID) {
workManager.cancelWorkById(id).await()
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="?m3ColorCardBackground" />
</shape>

View File

@@ -25,30 +25,43 @@
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Medium"
tools:src="@tools:sample/backgrounds/scenic" />
<CheckedTextView
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall"
app:drawableTint="?android:colorControlNormal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/imageView_expand"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="parent"
tools:drawableEndCompat="@drawable/ic_expand_collapse"
app:layout_goneMarginEnd="12dp"
tools:text="@tools:sample/lorem" />
<org.koitharu.kotatsu.core.ui.widgets.CheckableImageView
android:id="@+id/imageView_expand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:minWidth="?minTouchTargetSize"
android:minHeight="?minTouchTargetSize"
android:scaleType="center"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_expand_collapse"
app:tint="?colorControlActivated"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="imageView_cover, textView_status" />
app:constraint_referenced_ids="imageView_cover,textView_status,imageView_expand" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
@@ -63,6 +76,32 @@
app:trackColor="?android:colorBackground"
tools:progress="25" />
<org.koitharu.kotatsu.core.ui.widgets.NestedRecyclerView
android:id="@+id/recyclerView_chapters"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:background="@drawable/bg_card"
android:clipToOutline="true"
android:clipToPadding="false"
android:fadeScrollbars="false"
android:nestedScrollingEnabled="false"
android:orientation="vertical"
android:outlineProvider="background"
android:paddingVertical="8dp"
android:scrollbarStyle="insideOverlay"
android:scrollbars="vertical"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:maxHeight="240dp"
tools:listitem="@layout/item_chapter_download"
tools:visibility="visible" />
<TextView
android:id="@+id/textView_status"
android:layout_width="0dp"
@@ -114,7 +153,7 @@
android:text="@string/pause"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_resume"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
tools:visibility="visible" />
<Button
@@ -127,7 +166,7 @@
android:text="@string/resume"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_cancel"
app:layout_constraintTop_toBottomOf="@id/progressBar" />
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters" />
<Button
android:id="@+id/button_cancel"
@@ -139,7 +178,7 @@
android:text="@android:string/cancel"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:layout_constraintTop_toBottomOf="@id/recyclerView_chapters"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -157,4 +157,8 @@
<attr name="pieChartTextAmount" format="string"/>
</declare-styleable>
<declare-styleable name="NestedRecyclerView">
<attr name="maxHeight" />
</declare-styleable>
</resources>