Explorar o código

更新目录界面
添加目录界面的长按功能:全选、反选、选择已选章节之后章节、添加书签、下载

HapeLee hai 1 ano
pai
achega
3a8166b74d

+ 7 - 4
app/src/main/java/io/legato/kazusa/model/CacheBook.kt

@@ -71,13 +71,12 @@ object CacheBook {
         }
     }
 
-    fun start(context: Context, book: Book, start: Int, end: Int) {
-        if (!book.isLocal) {
+    fun start(context: Context, book: Book, selectedIndices: List<Int>) {
+        if (!book.isLocal && selectedIndices.isNotEmpty()) {
             context.startService<CacheBookService> {
                 action = IntentAction.start
                 putExtra("bookUrl", book.bookUrl)
-                putExtra("start", start)
-                putExtra("end", end)
+                putIntegerArrayListExtra("indices", ArrayList(selectedIndices))
             }
         }
     }
@@ -184,6 +183,10 @@ object CacheBook {
             postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
         }
 
+        fun addDownload(index: Int) {
+            addDownload(index, index)
+        }
+
         @Synchronized
         private fun onSuccess(chapter: BookChapter) {
             onDownloadSet.remove(chapter.index)

+ 41 - 17
app/src/main/java/io/legato/kazusa/service/CacheBookService.kt

@@ -78,12 +78,19 @@ class CacheBookService : BaseService() {
     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
         intent?.action?.let { action ->
             when (action) {
-                IntentAction.start -> addDownloadData(
-                    intent.getStringExtra("bookUrl"),
-                    intent.getIntExtra("start", 0),
-                    intent.getIntExtra("end", 0)
-                )
-
+                IntentAction.start -> {
+                    val bookUrl = intent.getStringExtra("bookUrl") ?: return@let
+                    val indices = intent.getIntegerArrayListExtra("indices")
+                    if (indices != null && indices.isNotEmpty()) {
+                        addDownloadData(bookUrl, indices)
+                    } else {
+                        addDownloadData(
+                            bookUrl,
+                            intent.getIntExtra("start", 0),
+                            intent.getIntExtra("end", 0)
+                        )
+                    }
+                }
                 IntentAction.remove -> {
                     val bookUrl = intent.getStringExtra("bookUrl")
                     CacheBook.cacheBookMap[bookUrl]?.stop()
@@ -103,12 +110,16 @@ class CacheBookService : BaseService() {
         postEvent(EventBus.UP_DOWNLOAD, "")
     }
 
-    private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
+    private fun addDownloadData(bookUrl: String?, indices: List<Int>) {
         bookUrl ?: return
+        if (indices.isEmpty()) return
+
         execute {
             val cacheBook = CacheBook.getOrCreate(bookUrl) ?: return@execute
-            val chapterCount = appDb.bookChapterDao.getChapterCount(bookUrl)
+
             val book = cacheBook.book
+            val chapterCount = appDb.bookChapterDao.getChapterCount(bookUrl)
+
             if (chapterCount == 0) {
                 mutex.withLock {
                     val name = book.name
@@ -116,31 +127,39 @@ class CacheBookService : BaseService() {
                         kotlin.runCatching {
                             WebBook.getBookInfoAwait(cacheBook.bookSource, book)
                         }.onFailure {
-                            val msg = "《$name》目录为空且加载详情页失败\n${it.localizedMessage}"
-                            AppLog.put(msg, it, true)
+                            AppLog.put(
+                                "《$name》目录为空且加载详情页失败\n${it.localizedMessage}",
+                                it,
+                                true
+                            )
                             return@execute
                         }
                     }
+
                     WebBook.getChapterListAwait(cacheBook.bookSource, book).onFailure {
                         if (book.totalChapterNum > 0) {
                             book.totalChapterNum = 0
                             book.update()
                         }
-                        val msg = "《$name》目录为空且加载目录失败\n${it.localizedMessage}"
-                        AppLog.put(msg, it, true)
+                        AppLog.put(
+                            "《$name》目录为空且加载目录失败\n${it.localizedMessage}",
+                            it,
+                            true
+                        )
                         return@execute
                     }.getOrNull()?.let { toc ->
                         appDb.bookChapterDao.insert(*toc.toTypedArray())
                     }
+
                     book.update()
                 }
             }
-            val end2 = if (end < 0) {
-                book.lastChapterIndex
-            } else {
-                min(end, book.lastChapterIndex)
+
+            //添加每一章到下载队列
+            indices.forEach { index ->
+                cacheBook.addDownload(index)
             }
-            cacheBook.addDownload(start, end2)
+
             notificationContent = CacheBook.downloadSummary
             upCacheBookNotification()
         }.onFinally {
@@ -150,6 +169,11 @@ class CacheBookService : BaseService() {
         }
     }
 
+    private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
+        addDownloadData(bookUrl, (start..end).toList())
+    }
+
+
     private fun removeDownload(bookUrl: String?) {
         CacheBook.cacheBookMap[bookUrl]?.stop()
         postEvent(EventBus.UP_DOWNLOAD, "")

+ 4 - 12
app/src/main/java/io/legato/kazusa/ui/book/cache/CacheActivity.kt

@@ -173,12 +173,8 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
             R.id.menu_download_after -> {
                 if (!CacheBook.isRun) {
                     adapter.getItems().forEach { book ->
-                        CacheBook.start(
-                            this@CacheActivity,
-                            book,
-                            book.durChapterIndex,
-                            book.lastChapterIndex
-                        )
+                        val indices = (book.durChapterIndex..book.lastChapterIndex).toList()
+                        CacheBook.start(this@CacheActivity, book, indices)
                     }
                 } else {
                     CacheBook.stop(this@CacheActivity)
@@ -188,12 +184,8 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
             R.id.menu_download_all -> {
                 if (!CacheBook.isRun) {
                     adapter.getItems().forEach { book ->
-                        CacheBook.start(
-                            this@CacheActivity,
-                            book,
-                            0,
-                            book.lastChapterIndex
-                        )
+                        val indices = (0..book.lastChapterIndex).toList()
+                        CacheBook.start(this@CacheActivity, book, indices)
                     }
                 } else {
                     CacheBook.stop(this@CacheActivity)

+ 4 - 2
app/src/main/java/io/legato/kazusa/ui/book/cache/CacheAdapter.kt

@@ -102,10 +102,12 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
                         if (!it.isStop()) {
                             CacheBook.remove(context, book.bookUrl)
                         } else {
-                            CacheBook.start(context, book, 0, book.lastChapterIndex)
+                            val indices = (0..book.lastChapterIndex).toList()
+                            CacheBook.start(context, book, indices)
                         }
                     } ?: let {
-                        CacheBook.start(context, book, 0, book.lastChapterIndex)
+                        val indices = (0..book.lastChapterIndex).toList()
+                        CacheBook.start(context, book, indices)
                     }
                 }
             }

+ 2 - 1
app/src/main/java/io/legato/kazusa/ui/book/read/BaseReadBookActivity.kt

@@ -276,7 +276,8 @@ abstract class BaseReadBookActivity :
                         val end = editEnd.text!!.toString().let {
                             if (it.isEmpty()) book.totalChapterNum else it.toInt()
                         }
-                        CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1)
+                        val indices = (start - 1..end - 1).toList()
+                        CacheBook.start(this@BaseReadBookActivity, book, indices)
                     }
                 }
                 cancelButton()

+ 5 - 5
app/src/main/java/io/legato/kazusa/ui/book/searchContent/SearchContentActivity.kt

@@ -1,5 +1,8 @@
 package io.legato.kazusa.ui.book.searchContent
 
+//import io.legado.app.lib.theme.bottomBackground
+//import io.legado.app.lib.theme.getPrimaryTextColor
+//import io.legado.app.lib.theme.primaryTextColor
 import android.annotation.SuppressLint
 import android.content.Intent
 import android.os.Bundle
@@ -23,12 +26,9 @@ import io.legato.kazusa.databinding.ActivitySearchContentBinding
 import io.legato.kazusa.help.IntentData
 import io.legato.kazusa.help.book.BookHelp
 import io.legato.kazusa.help.book.isLocal
-//import io.legado.app.lib.theme.bottomBackground
-//import io.legado.app.lib.theme.getPrimaryTextColor
-//import io.legado.app.lib.theme.primaryTextColor
 import io.legato.kazusa.ui.widget.recycler.UpLinearLayoutManager
 import io.legato.kazusa.ui.widget.recycler.VerticalDivider
-import io.legato.kazusa.utils.applyNavigationBarMargin
+import io.legato.kazusa.utils.applyNavigationBarPadding
 import io.legato.kazusa.utils.hideSoftInput
 import io.legato.kazusa.utils.invisible
 import io.legato.kazusa.utils.observeEvent
@@ -64,7 +64,7 @@ class SearchContentActivity :
 //        val bbg = bottomBackground
 //        val btc = getPrimaryTextColor(ColorUtils.isColorLight(bbg))
 //        binding.llSearchBaseInfo.setBackgroundColor(bbg)
-        binding.llSearchBaseInfo.applyNavigationBarMargin()
+        binding.llSearchBaseInfo.applyNavigationBarPadding()
 //        binding.tvCurrentSearchInfo.setTextColor(btc)
 //        binding.ivSearchContentTop.setColorFilter(btc)
 //        binding.ivSearchContentBottom.setColorFilter(btc)

+ 3 - 3
app/src/main/java/io/legato/kazusa/ui/book/toc/BookmarkAdapter.kt

@@ -7,6 +7,7 @@ import io.legato.kazusa.base.adapter.RecyclerAdapter
 import io.legato.kazusa.data.entities.Bookmark
 import io.legato.kazusa.databinding.ItemBookmarkBinding
 import io.legato.kazusa.utils.gone
+import splitties.views.onClick
 import splitties.views.onLongClick
 
 class BookmarkAdapter(context: Context, val callback: Callback) :
@@ -30,17 +31,16 @@ class BookmarkAdapter(context: Context, val callback: Callback) :
     }
 
     override fun registerListener(holder: ItemViewHolder, binding: ItemBookmarkBinding) {
-        binding.root.setOnClickListener {
+        binding.root.onLongClick {
             getItem(holder.layoutPosition)?.let { bookmark ->
                 callback.onLongClick(bookmark, holder.layoutPosition)
             }
         }
-        binding.root.onLongClick {
+        binding.root.onClick {
             getItem(holder.layoutPosition)?.let { bookmark ->
                 callback.onClick(bookmark)
             }
         }
-
     }
 
     interface Callback {

+ 137 - 76
app/src/main/java/io/legato/kazusa/ui/book/toc/ChapterListAdapter.kt

@@ -6,6 +6,7 @@ import android.os.Handler
 import android.os.Looper
 import android.view.ViewGroup
 import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
 import io.legato.kazusa.R
 import io.legato.kazusa.base.adapter.DiffRecyclerAdapter
 import io.legato.kazusa.base.adapter.ItemViewHolder
@@ -16,7 +17,6 @@ import io.legato.kazusa.help.book.ContentProcessor
 import io.legato.kazusa.help.config.AppConfig
 import io.legato.kazusa.lib.theme.ThemeUtils
 import io.legato.kazusa.utils.gone
-import io.legato.kazusa.utils.longToastOnUi
 import io.legato.kazusa.utils.themeColor
 import io.legato.kazusa.utils.visible
 import kotlinx.coroutines.CoroutineScope
@@ -27,28 +27,25 @@ import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import java.util.concurrent.ConcurrentHashMap
 
-class ChapterListAdapter(context: Context, val callback: Callback) :
-    DiffRecyclerAdapter<BookChapter, ItemChapterListBinding>(context) {
+class ChapterListAdapter(
+    context: Context,
+    val callback: Callback
+) : DiffRecyclerAdapter<BookChapter, ItemChapterListBinding>(context) {
 
     val cacheFileNames = hashSetOf<String>()
     private val displayTitleMap = ConcurrentHashMap<String, String>()
+    private val selectedIndices = LinkedHashSet<Int>()
 
     private val handler = Handler(Looper.getMainLooper())
+    private var upDisplayTileJob: Job? = null
 
     override val diffItemCallback: DiffUtil.ItemCallback<BookChapter>
         get() = object : DiffUtil.ItemCallback<BookChapter>() {
-
-            override fun areItemsTheSame(
-                oldItem: BookChapter,
-                newItem: BookChapter
-            ): Boolean {
+            override fun areItemsTheSame(oldItem: BookChapter, newItem: BookChapter): Boolean {
                 return oldItem.index == newItem.index
             }
 
-            override fun areContentsTheSame(
-                oldItem: BookChapter,
-                newItem: BookChapter
-            ): Boolean {
+            override fun areContentsTheSame(oldItem: BookChapter, newItem: BookChapter): Boolean {
                 return oldItem.bookUrl == newItem.bookUrl
                         && oldItem.url == newItem.url
                         && oldItem.isVip == newItem.isVip
@@ -58,11 +55,8 @@ class ChapterListAdapter(context: Context, val callback: Callback) :
                         && oldItem.wordCount == newItem.wordCount
                         && oldItem.isVolume == newItem.isVolume
             }
-
         }
 
-    private var upDisplayTileJob: Job? = null
-
     override fun onCurrentListChanged() {
         super.onCurrentListChanged()
         callback.onListChanged()
@@ -80,7 +74,6 @@ class ChapterListAdapter(context: Context, val callback: Callback) :
             val replaceRules = ContentProcessor.get(book.name, book.origin).getTitleReplaceRules()
             val useReplace = AppConfig.tocUiUseReplace && book.getUseReplaceRule()
             val items = getItems()
-
             val indices = ((startIndex until items.size) + (startIndex downTo 0))
                 .filter { it in items.indices }
 
@@ -100,33 +93,132 @@ class ChapterListAdapter(context: Context, val callback: Callback) :
         }
     }
 
+    fun isInSelectionMode(): Boolean = selectedIndices.isNotEmpty()
+
+    fun toggleSelection(chapter: BookChapter, position: Int? = null) {
+        val pos = position ?: getItems().indexOfFirst { it.index == chapter.index }
+        if (pos == -1) return
+
+        if (selectedIndices.contains(chapter.index)) {
+            selectedIndices.remove(chapter.index)
+        } else {
+            selectedIndices.add(chapter.index)
+        }
+        notifyItemChanged(pos, true)
+        callback.onSelectionModeChanged(isInSelectionMode())
+    }
+
+    fun getSelectedChapters(): List<BookChapter> =
+        getItems().filter { selectedIndices.contains(it.index) }
+
+    fun clearSelection() {
+        if (selectedIndices.isNotEmpty()) {
+            val prevSelected = selectedIndices.toList()
+            selectedIndices.clear()
+            prevSelected.forEach { index ->
+                val pos = getItems().indexOfFirst { it.index == index }
+                if (pos != -1) notifyItemChanged(pos, true)
+            }
+            callback.onSelectionModeChanged(false)
+        }
+    }
 
-    private fun getDisplayTitle(chapter: BookChapter): String {
-        return displayTitleMap[chapter.title] ?: chapter.title
+    fun selectAll() {
+        val items = getItems()
+        val changedIndices = items.map { it.index }.filter { !selectedIndices.contains(it) }
+        selectedIndices.clear()
+        selectedIndices.addAll(items.map { it.index })
+        changedIndices.forEach { index ->
+            val pos = items.indexOfFirst { it.index == index }
+            if (pos != -1) notifyItemChanged(pos, true)
+        }
+        callback.onSelectionModeChanged(true)
     }
 
-    override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding {
-        return ItemChapterListBinding.inflate(inflater, parent, false)
+    fun invertSelection() {
+        val items = getItems()
+        val newSelected = items.map { it.index }.toSet() - selectedIndices
+        val changedIndices = (selectedIndices + newSelected) // 之前选中 + 新选中 = 改变的项
+        selectedIndices.clear()
+        selectedIndices.addAll(newSelected)
+        changedIndices.forEach { index ->
+            val pos = items.indexOfFirst { it.index == index }
+            if (pos != -1) notifyItemChanged(pos, true)
+        }
+        callback.onSelectionModeChanged(selectedIndices.isNotEmpty())
     }
 
+    fun selectFrom() {
+        val items = getItems()
+        val startPos = selectedIndices.lastOrNull()?.let { lastIndex ->
+            items.indexOfFirst { it.index == lastIndex }
+        } ?: return
+
+        if (startPos !in items.indices) return
+
+        val changedIndices = mutableListOf<Int>()
+        for (i in startPos until items.size) {
+            val chapterIndex = items[i].index
+            if (!selectedIndices.contains(chapterIndex)) {
+                selectedIndices.add(chapterIndex)
+                changedIndices.add(i)
+            }
+        }
+
+        changedIndices.forEach { notifyItemChanged(it, true) }
+        callback.onSelectionModeChanged(isInSelectionMode())
+    }
+
+    private fun getDisplayTitle(chapter: BookChapter): String =
+        displayTitleMap[chapter.title] ?: chapter.title
+
+    override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding =
+        ItemChapterListBinding.inflate(inflater, parent, false)
+
     override fun convert(
         holder: ItemViewHolder,
         binding: ItemChapterListBinding,
         item: BookChapter,
         payloads: MutableList<Any>
     ) {
+        val isDur = callback.durChapterIndex() == item.index
+        val cached =
+            callback.isLocalBook || item.isVolume || cacheFileNames.contains(item.getFileName())
+
         binding.run {
-            val isDur = callback.durChapterIndex() == item.index
-            val cached = callback.isLocalBook
-                    || item.isVolume
-                    || cacheFileNames.contains(item.getFileName())
             if (payloads.isEmpty()) {
+                tvChapterName.text = getDisplayTitle(item)
+                tvChapterItem.foreground =
+                    ThemeUtils.resolveDrawable(context, android.R.attr.selectableItemBackground)
+
+                if (!item.tag.isNullOrEmpty() && !item.isVolume) {
+                    tvTag.text = item.tag
+                    tvTag.visible()
+                } else tvTag.gone()
+
+                if (AppConfig.tocCountWords && !item.wordCount.isNullOrEmpty() && !item.isVolume) {
+                    tvWordCount.text = item.wordCount
+                    tvWordCount.visible()
+                } else tvWordCount.gone()
+
+                if (item.isVip && !item.isPay) ivLocked.visible() else ivLocked.gone()
+
+                upHasCache(binding, cached)
 
+            } else {
+                tvChapterName.text = getDisplayTitle(item)
+                upHasCache(binding, cached)
+            }
+
+            if (selectedIndices.contains(item.index)) {
+                ivVolume.gone()
+                tvChapterItem.setBackgroundColor(context.themeColor(com.google.android.material.R.attr.colorSurfaceBright))
+            } else {
                 if (item.isVolume) {
                     ivVolume.visible()
-                    tvChapterName.text = getDisplayTitle(item)
                     tvChapterName.textSize = 12f
                     tvChapterName.setTextColor(context.themeColor(com.google.android.material.R.attr.colorTertiary))
+                    tvChapterItem.setBackgroundColor(context.themeColor(com.google.android.material.R.attr.colorSurface))
                 } else {
                     ivVolume.gone()
                     if (isDur) {
@@ -136,70 +228,39 @@ class ChapterListAdapter(context: Context, val callback: Callback) :
                         tvChapterName.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurface))
                         tvChapterItem.setBackgroundColor(context.themeColor(com.google.android.material.R.attr.colorSurface))
                     }
-                    tvChapterName.text = getDisplayTitle(item)
-                    tvChapterItem.foreground =
-                        ThemeUtils.resolveDrawable(context, android.R.attr.selectableItemBackground)
-                }
-
-                //卷名不显示
-                if (!item.tag.isNullOrEmpty() && !item.isVolume) {
-                    //更新时间规则
-                    tvTag.text = item.tag
-                    tvTag.visible()
-                } else {
-                    tvTag.gone()
-                }
-                if (AppConfig.tocCountWords && !item.wordCount.isNullOrEmpty() && !item.isVolume) {
-                    //章节字数
-                    tvWordCount.text = item.wordCount
-                    tvWordCount.visible()
-                } else {
-                    tvWordCount.gone()
-                }
-
-                if (item.isVip && !item.isPay) {
-                    ivLocked.visible()
-                } else {
-                    ivLocked.gone()
                 }
-
-                upHasCache(binding, cached)
-            } else {
-                tvChapterName.text = getDisplayTitle(item)
-                upHasCache(binding, cached)
             }
         }
     }
 
+
     override fun registerListener(holder: ItemViewHolder, binding: ItemChapterListBinding) {
         holder.itemView.setOnClickListener {
-            getItem(holder.layoutPosition)?.let {
-                callback.openChapter(it)
+            val pos = holder.bindingAdapterPosition
+            if (pos == RecyclerView.NO_POSITION) return@setOnClickListener
+            val item = getItem(pos) ?: return@setOnClickListener
+
+            if (isInSelectionMode()) {
+                toggleSelection(item, pos)
+            } else {
+                callback.openChapter(item)
             }
         }
+
         holder.itemView.setOnLongClickListener {
-            getItem(holder.layoutPosition)?.let { item ->
-                context.longToastOnUi(getDisplayTitle(item))
-            }
+            val pos = holder.bindingAdapterPosition
+            if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener true
+            val item = getItem(pos) ?: return@setOnLongClickListener true
+
+            toggleSelection(item, pos)
             true
         }
     }
 
-    private fun upHasCache(binding: ItemChapterListBinding, cached: Boolean) =
-        binding.apply {
-            when {
-                cached -> {
-                    ivChecked.setImageResource(R.drawable.ic_download_done)
-                    ivChecked.visible()
-                }
-
-                else -> {
-                    ivChecked.setImageResource(R.drawable.ic_outline_cloud_24)
-                    ivChecked.visible()
-                }
-            }
-        }
-
+    private fun upHasCache(binding: ItemChapterListBinding, cached: Boolean) = binding.apply {
+        ivChecked.setImageResource(if (cached) R.drawable.ic_download_done else R.drawable.ic_outline_cloud_24)
+        ivChecked.visible()
+    }
 
     interface Callback {
         val scope: CoroutineScope
@@ -208,6 +269,6 @@ class ChapterListAdapter(context: Context, val callback: Callback) :
         fun openChapter(bookChapter: BookChapter)
         fun durChapterIndex(): Int
         fun onListChanged()
+        fun onSelectionModeChanged(enabled: Boolean)
     }
-
 }

+ 77 - 4
app/src/main/java/io/legato/kazusa/ui/book/toc/ChapterListFragment.kt

@@ -6,6 +6,7 @@ import android.content.Intent
 import android.graphics.Canvas
 import android.graphics.Rect
 import android.os.Bundle
+import android.transition.TransitionManager
 import android.view.View
 import androidx.core.content.ContextCompat
 import androidx.core.graphics.drawable.toDrawable
@@ -14,6 +15,7 @@ import androidx.interpolator.view.animation.FastOutSlowInInterpolator
 import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.ItemTouchHelper
 import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.transition.platform.MaterialFadeThrough
 import io.legato.kazusa.R
 import io.legato.kazusa.base.VMBaseFragment
 import io.legato.kazusa.constant.EventBus
@@ -26,14 +28,15 @@ import io.legato.kazusa.help.book.isLocal
 import io.legato.kazusa.help.book.simulatedTotalChapterNum
 import io.legato.kazusa.model.CacheBook
 import io.legato.kazusa.ui.widget.recycler.UpLinearLayoutManager
-import io.legato.kazusa.ui.widget.recycler.VerticalDivider
 import io.legato.kazusa.utils.VibrationUtils
 import io.legato.kazusa.utils.applyNavigationBarPadding
 import io.legato.kazusa.utils.dpToPx
+import io.legato.kazusa.utils.gone
 import io.legato.kazusa.utils.observeEvent
 import io.legato.kazusa.utils.themeColor
 import io.legato.kazusa.utils.toastOnUi
 import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
+import io.legato.kazusa.utils.visible
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
@@ -76,7 +79,7 @@ class ChapterListFragment : VMBaseFragment<TocViewModel>(R.layout.fragment_chapt
                 val book = book ?: return
 
                 toastOnUi("开始下载: ${adapter.getItem(position)?.title}")
-                CacheBook.start(requireContext(), book, chapter.index, chapter.index)
+                CacheBook.start(requireContext(), book, listOf(chapter.index))
                 VibrationUtils.vibratePattern(requireContext(), longArrayOf(0, 50, 30, 50), -1)
                 adapter.notifyItemChanged(position)
             }
@@ -147,7 +150,7 @@ class ChapterListFragment : VMBaseFragment<TocViewModel>(R.layout.fragment_chapt
         itemTouchHelper.attachToRecyclerView(binding.recyclerView)
 
         binding.recyclerView.layoutManager = mLayoutManager
-        binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
+
         binding.recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
             override fun getItemOffsets(
                 outRect: Rect,
@@ -177,6 +180,38 @@ class ChapterListFragment : VMBaseFragment<TocViewModel>(R.layout.fragment_chapt
         btnLocate.setOnClickListener {
             mLayoutManager.scrollToPositionWithOffset(durChapterIndex, 0)
         }
+        btnSelectAll.setOnClickListener {
+            adapter.selectAll()
+            toastOnUi("已全选 ${adapter.itemCount} 个章节")
+        }
+        btnInvertSelection.setOnClickListener {
+            adapter.invertSelection()
+            toastOnUi("已反选")
+        }
+        btnFromSelection.setOnClickListener {
+            adapter.selectFrom()
+            toastOnUi("已选择之后的章节")
+        }
+
+        btnDownloadSelected.setOnClickListener {
+            val chapters = adapter.getSelectedChapters()
+            if (chapters.isNotEmpty()) {
+                val book = book ?: return@setOnClickListener
+                val indices = chapters.map { it.index }
+                CacheBook.start(requireContext(), book, indices)
+                toastOnUi("开始下载 ${chapters.size} 个章节")
+                adapter.clearSelection()
+            } else {
+                toastOnUi("未选择章节")
+            }
+        }
+        btnExit.setOnClickListener {
+            adapter.clearSelection()
+        }
+        btnBookmarkSelection.setOnClickListener {
+            addSelectedChaptersBookmarkDirectly()
+            toastOnUi("已添加书签")
+        }
     }
 
     @SuppressLint("SetTextI18n")
@@ -185,7 +220,9 @@ class ChapterListFragment : VMBaseFragment<TocViewModel>(R.layout.fragment_chapt
             upChapterList(null)
             durChapterIndex = book.durChapterIndex
             binding.tvCurrentChapterInfo.text =
-                "${book.durChapterTitle}(${book.durChapterIndex + 1}/${book.simulatedTotalChapterNum()})"
+                "${book.durChapterTitle}"
+            binding.tvCurrentChapterAll.text =
+                "${book.durChapterIndex + 1}/${book.simulatedTotalChapterNum()}"
             initCacheFileNames(book)
         }
     }
@@ -199,6 +236,42 @@ class ChapterListFragment : VMBaseFragment<TocViewModel>(R.layout.fragment_chapt
         }
     }
 
+    fun addSelectedChaptersBookmarkDirectly() {
+        val book = viewModel.bookData.value ?: return
+        val selectedChapters = adapter.getSelectedChapters()
+        if (selectedChapters.isEmpty()) return
+
+        lifecycleScope.launch {
+            withContext(IO) {
+                selectedChapters.forEach { chapter ->
+                    val page = chapter.title
+                    val bookmark = book.createBookMark().apply {
+                        chapterIndex = chapter.index
+                        chapterPos = 0
+                        chapterName = page
+                        // 不设置 content 或 bookText,或者只存标题
+                    }
+                    appDb.bookmarkDao.insert(bookmark)
+                }
+            }
+
+            // 可选:清空选择状态并提示
+            adapter.clearSelection()
+        }
+    }
+
+
+    override fun onSelectionModeChanged(enabled: Boolean) {
+        TransitionManager.beginDelayedTransition(binding.coordinatorLayout, MaterialFadeThrough())
+        if (enabled) {
+            binding.llBase.gone()
+            binding.floatingToolbarBottom.visible()
+        } else {
+            binding.llBase.visible()
+            binding.floatingToolbarBottom.gone()
+        }
+    }
+
     override fun observeLiveBus() {
         observeEvent<Pair<Book, BookChapter>>(EventBus.SAVE_CONTENT) { (book, chapter) ->
             viewModel.bookData.value?.bookUrl?.let { bookUrl ->

+ 32 - 0
app/src/main/java/io/legato/kazusa/ui/book/toc/TocActivity.kt

@@ -2,6 +2,7 @@
 
 package io.legato.kazusa.ui.book.toc
 
+import android.annotation.SuppressLint
 import android.content.Intent
 import android.os.Bundle
 import android.view.Menu
@@ -16,8 +17,11 @@ import io.legato.kazusa.R
 import io.legato.kazusa.base.VMBaseActivity
 import io.legato.kazusa.data.entities.Book
 import io.legato.kazusa.databinding.ActivityChapterListBinding
+import io.legato.kazusa.databinding.DialogDownloadChoiceBinding
 import io.legato.kazusa.help.book.isLocalTxt
 import io.legato.kazusa.help.config.AppConfig
+import io.legato.kazusa.lib.dialogs.alert
+import io.legato.kazusa.model.CacheBook
 //import io.legado.app.lib.theme.accentColor
 //import io.legado.app.lib.theme.primaryTextColor
 import io.legato.kazusa.model.ReadBook
@@ -143,6 +147,8 @@ class TocActivity : VMBaseActivity<ActivityChapterListBinding, TocViewModel>(),
                 TxtTocRuleDialog(viewModel.bookData.value?.tocUrl)
             )
 
+            R.id.menu_download -> showDownloadDialog()
+
             R.id.menu_split_long_chapter -> {
                 viewModel.bookData.value?.let { book ->
                     item.isChecked = !item.isChecked
@@ -204,6 +210,32 @@ class TocActivity : VMBaseActivity<ActivityChapterListBinding, TocViewModel>(),
         }
     }
 
+    @SuppressLint("SetTextI18n")
+    fun showDownloadDialog() {
+        ReadBook.book?.let { book ->
+            alert(titleResource = R.string.offline_cache) {
+                val alertBinding = DialogDownloadChoiceBinding.inflate(layoutInflater).apply {
+                    editStart.setText((book.durChapterIndex + 1).toString())
+                    editEnd.setText(book.totalChapterNum.toString())
+                }
+                customView { alertBinding.root }
+                okButton {
+                    alertBinding.run {
+                        val start = editStart.text!!.toString().let {
+                            if (it.isEmpty()) 0 else it.toInt()
+                        }
+                        val end = editEnd.text!!.toString().let {
+                            if (it.isEmpty()) book.totalChapterNum else it.toInt()
+                        }
+                        val indices = (start - 1..end - 1).toList()
+                        CacheBook.start(this@TocActivity, book, indices)
+                    }
+                }
+                cancelButton()
+            }
+        }
+    }
+
     @Suppress("DEPRECATION")
     private inner class TabFragmentPageAdapter :
         FragmentPagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {

+ 11 - 0
app/src/main/res/drawable/ic_select_all.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M280,680L280,280L680,280L680,680L280,680ZM360,600L600,600L600,360L360,360L360,600ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,840L280,760L360,760L360,840L280,840ZM280,200L280,120L360,120L360,200L280,200ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,840L760,760L840,760Q840,793 816.5,816.5Q793,840 760,840ZM760,680L760,600L840,600L840,680L760,680ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200Z" />
+</vector>

+ 11 - 0
app/src/main/res/drawable/ic_select_from.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M680,640L624,584L687,520L440,520L440,440L687,440L624,376L680,320L840,480L680,640ZM200,760L280,760L280,200L200,200L200,760ZM120,840L120,120L360,120L360,840L120,840ZM440,840L440,760L520,760L520,840L440,840ZM440,200L440,120L520,120L520,200L440,200ZM600,840L600,760L680,760L680,840L600,840ZM600,200L600,120L680,120L680,200L600,200ZM760,840L760,760L840,760L840,840L760,840ZM760,200L760,120L840,120L840,200L760,200ZM200,760L200,760L280,760L280,760L200,760Z" />
+</vector>

+ 11 - 0
app/src/main/res/drawable/ic_select_invert.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,280L200,280L200,760Q200,760 200,760Q200,760 200,760L680,760L680,840L200,840ZM360,600L360,680Q327,680 303.5,656.5Q280,633 280,600L360,600ZM280,520L280,440L360,440L360,520L280,520ZM280,360L280,280L360,280L360,360L280,360ZM360,200L280,200Q280,167 303.5,143.5Q327,120 360,120L360,200ZM440,680L440,600L520,600L520,680L440,680ZM440,200L440,120L520,120L520,200L440,200ZM600,200L600,120L680,120L680,200L600,200ZM600,680L600,600L680,600L680,680L600,680ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200ZM760,600L840,600Q840,633 816.5,656.5Q793,680 760,680L760,600ZM760,520L760,440L840,440L840,520L760,520ZM760,360L760,280L840,280L840,360L760,360Z" />
+</vector>

+ 12 - 10
app/src/main/res/layout/activity_search_content.xml

@@ -17,6 +17,7 @@
         android:id="@+id/refresh_progress_bar"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
+        android:visibility="gone"
         android:indeterminate="true"
         app:layout_constraintTop_toBottomOf="@id/title_bar" />
 
@@ -32,8 +33,10 @@
     <LinearLayout
         android:id="@+id/ll_search_base_info"
         android:layout_width="match_parent"
-        android:layout_height="36dp"
-        android:layout_marginHorizontal="16dp"
+        android:layout_height="wrap_content"
+        android:background="?attr/colorSurfaceContainer"
+        android:paddingHorizontal="16dp"
+        android:paddingTop="8dp"
         android:gravity="center_vertical"
         android:orientation="horizontal"
         app:layout_constraintBottom_toBottomOf="parent">
@@ -41,13 +44,12 @@
         <TextView
             android:id="@+id/tv_current_search_info"
             android:layout_width="0dp"
-            android:layout_height="match_parent"
+            android:layout_height="wrap_content"
             android:layout_weight="1"
-            android:background="?attr/selectableItemBackgroundBorderless"
-            android:ellipsize="middle"
             android:gravity="center_vertical"
             android:singleLine="true"
-            android:textSize="12sp" />
+            android:textSize="14sp"
+            android:textStyle="bold" />
 
         <Space
             android:layout_width="20dp"
@@ -55,8 +57,8 @@
 
         <com.google.android.material.button.MaterialButton
             android:id="@+id/iv_search_content_top"
-            android:layout_width="36dp"
-            android:layout_height="36dp"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
             style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
             android:contentDescription="@string/go_to_top"
             app:icon="@drawable/ic_arrow_drop_up"
@@ -66,8 +68,8 @@
 
         <com.google.android.material.button.MaterialButton
             android:id="@+id/iv_search_content_bottom"
-            android:layout_width="36dp"
-            android:layout_height="36dp"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
             style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
             android:contentDescription="@string/go_to_bottom"
             app:icon="@drawable/ic_arrow_drop_down"

+ 144 - 59
app/src/main/res/layout/fragment_chapter_list.xml

@@ -1,6 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/coordinator_layout"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:descendantFocusability="blocksDescendants">
@@ -8,82 +9,166 @@
     <io.legato.kazusa.ui.widget.recycler.scroller.FastScrollRecyclerView
         android:id="@+id/recycler_view"
         android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:overScrollMode="never"
-        app:layout_constraintTop_toTopOf="parent"
-        app:layout_constraintBottom_toBottomOf="parent" />
+        android:layout_height="match_parent"
+        android:overScrollMode="never" />
 
-    <LinearLayout
+    <com.google.android.material.floatingtoolbar.FloatingToolbarLayout
+        android:id="@+id/floating_toolbar_bottom"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center|bottom"
+        android:layout_margin="16dp"
+        android:visibility="gone">
+
+        <com.google.android.material.overflow.OverflowLinearLayout
+            android:id="@+id/floating_toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:orientation="horizontal">
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/btn_select_all"
+                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:contentDescription="@string/locate_current"
+                app:icon="@drawable/ic_select_all"
+                app:iconGravity="textStart"
+                app:iconPadding="0dp"
+                app:strokeWidth="0dp" />
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/btn_invert_selection"
+                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:contentDescription="@string/locate_current"
+                app:icon="@drawable/ic_select_invert"
+                app:iconGravity="textStart"
+                app:iconPadding="0dp"
+                app:strokeWidth="0dp" />
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/btn_from_selection"
+                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:contentDescription="@string/locate_current"
+                app:icon="@drawable/ic_select_from"
+                app:iconGravity="textStart"
+                app:iconPadding="0dp"
+                app:strokeWidth="0dp" />
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/btn_bookmark_selection"
+                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:contentDescription="@string/locate_current"
+                app:icon="@drawable/ic_bookmark"
+                app:iconGravity="textStart"
+                app:iconPadding="0dp"
+                app:strokeWidth="0dp" />
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/btn_download_selected"
+                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:contentDescription="@string/locate_current"
+                app:icon="@drawable/ic_download"
+                app:iconGravity="textStart"
+                app:iconPadding="0dp"
+                app:strokeWidth="0dp" />
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/btn_exit"
+                style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:contentDescription="@string/locate_current"
+                app:icon="@drawable/ic_baseline_close"
+                app:iconGravity="textStart"
+                app:iconPadding="0dp" />
+        </com.google.android.material.overflow.OverflowLinearLayout>
+
+    </com.google.android.material.floatingtoolbar.FloatingToolbarLayout>
+
+    <com.google.android.material.dockedtoolbar.DockedToolbarLayout
         android:id="@+id/ll_base"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:orientation="horizontal"
-        app:layout_constraintBottom_toBottomOf="parent">
+        android:layout_gravity="bottom"
+        app:layout_behavior="com.google.android.material.behavior.HideViewOnScrollBehavior">
 
-        <com.google.android.material.card.MaterialCardView
-            android:id="@+id/cd_base"
+        <com.google.android.material.overflow.OverflowLinearLayout
+            android:id="@+id/docked_toolbar_child"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:layout_marginHorizontal="16dp"
-            android:layout_marginVertical="8dp"
-            app:cardBackgroundColor="?attr/colorSurfaceContainer"
-            app:cardElevation="4dp"
-            app:strokeWidth="0dp">
+            android:layout_gravity="center"
+            android:orientation="horizontal">
 
             <LinearLayout
-                android:id="@+id/ll_chapter_base_info"
-                android:layout_width="match_parent"
-                android:layout_height="match_parent"
-                android:gravity="center"
-                android:orientation="horizontal"
-                android:paddingVertical="8dp"
-                android:paddingStart="16dp"
-                android:paddingEnd="10dp">
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_gravity="center_vertical"
+                android:layout_weight="1"
+                android:orientation="vertical">
 
                 <TextView
                     android:id="@+id/tv_current_chapter_info"
-                    android:layout_width="0dp"
-                    android:layout_height="match_parent"
-                    android:layout_weight="1"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:gravity="center_vertical"
+                    android:singleLine="true"
+                    android:textSize="14sp"
+                    android:textStyle="bold" />
+
+                <TextView
+                    android:id="@+id/tv_current_chapter_all"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
                     android:gravity="center_vertical"
                     android:singleLine="true"
                     android:textSize="12sp"
                     android:textStyle="bold" />
+            </LinearLayout>
 
-                <com.google.android.material.button.MaterialButton
-                    android:id="@+id/btn_locate"
-                    style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
-                    android:layout_width="40dp"
-                    android:layout_height="40dp"
-                    android:contentDescription="@string/locate_current"
-                    app:icon="@drawable/ic_locate"
-                    app:iconGravity="textStart"
-                    app:iconPadding="0dp"
-                    app:iconSize="16dp" />
-
-                <com.google.android.material.button.MaterialButton
-                    android:id="@+id/btn_chapter_top"
-                    style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
-                    android:layout_width="40dp"
-                    android:layout_height="40dp"
-                    android:contentDescription="@string/go_to_top"
-                    app:icon="@drawable/ic_arrow_drop_up"
-                    app:iconGravity="textStart"
-                    app:iconPadding="0dp" />
-
-                <com.google.android.material.button.MaterialButton
-                    android:id="@+id/btn_chapter_bottom"
-                    style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
-                    android:layout_width="40dp"
-                    android:layout_height="40dp"
-                    android:contentDescription="@string/go_to_bottom"
-                    app:icon="@drawable/ic_arrow_drop_down"
-                    app:iconGravity="textStart"
-                    app:iconPadding="0dp" />
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/btn_locate"
+                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:contentDescription="@string/locate_current"
+                app:icon="@drawable/ic_locate"
+                app:iconGravity="textStart"
+                app:iconPadding="0dp"
+                app:iconSize="16dp" />
 
-            </LinearLayout>
-        </com.google.android.material.card.MaterialCardView>
-    </LinearLayout>
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/btn_chapter_top"
+                style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:contentDescription="@string/go_to_top"
+                app:icon="@drawable/ic_arrow_drop_up"
+                app:iconGravity="textStart"
+                app:iconPadding="0dp" />
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/btn_chapter_bottom"
+                style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:contentDescription="@string/go_to_bottom"
+                app:icon="@drawable/ic_arrow_drop_down"
+                app:iconGravity="textStart"
+                app:iconPadding="0dp" />
+
+        </com.google.android.material.overflow.OverflowLinearLayout>
 
+    </com.google.android.material.dockedtoolbar.DockedToolbarLayout>
 
-</androidx.constraintlayout.widget.ConstraintLayout>
+</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 7 - 4
app/src/main/res/layout/item_bookmark.xml

@@ -4,20 +4,22 @@
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
     android:paddingHorizontal="16dp"
-    android:paddingVertical="8dp"
+    android:paddingVertical="12dp"
     android:background="?attr/selectableItemBackground">
 
     <TextView
         android:id="@+id/tv_chapter_name"
-        style="@style/ChapterNameText"
         android:layout_width="match_parent"
-        android:layout_height="wrap_content" />
+        android:layout_height="wrap_content"
+        android:paddingBottom="2dp"
+        android:textSize="16sp"
+        android:textStyle="bold" />
 
     <TextView
         android:id="@+id/tv_book_text"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:paddingBottom="4dp"
+        android:paddingBottom="2dp"
         android:textSize="14sp"
         android:singleLine="true" />
 
@@ -25,6 +27,7 @@
         android:id="@+id/tv_content"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
+        android:textColor="?attr/colorSecondary"
         android:textSize="12sp"
         android:singleLine="true" />
 

+ 2 - 2
app/src/main/res/layout/item_chapter_list.xml

@@ -31,7 +31,7 @@
             android:id="@+id/iv_volume"
             android:layout_width="4dp"
             android:layout_height="16dp"
-            android:layout_marginEnd="4dp"
+            android:layout_marginEnd="8dp"
             android:visibility="visible"
             app:cardBackgroundColor="?attr/colorTertiary"
             app:strokeWidth="0dp" />
@@ -40,7 +40,7 @@
     <LinearLayout
         android:layout_width="0dp"
         android:layout_height="wrap_content"
-        android:layout_marginHorizontal="4dp"
+
         android:orientation="vertical"
         app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintEnd_toStartOf="@id/iv_checked"

+ 3 - 2
app/src/main/res/layout/item_download.xml

@@ -104,8 +104,9 @@
         style="@style/Widget.Material3.LinearProgressIndicator"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        app:waveAmplitude="8dp"
-        app:wavelength="80dp"
+        app:waveAmplitude="2dp"
+        app:waveSpeed="50dp"
+        app:wavelength="40dp"
         android:layout_marginTop="2dp"
         android:visibility="gone"
         app:layout_constraintTop_toBottomOf="@id/iv_download" />

+ 6 - 0
app/src/main/res/menu/book_toc.xml

@@ -10,6 +10,12 @@
         app:actionViewClass="androidx.appcompat.widget.SearchView"
         app:showAsAction="always" />
 
+    <item
+        android:id="@+id/menu_download"
+        android:icon="@drawable/ic_download"
+        android:title="@string/offline_cache"
+        app:showAsAction="always" />
+
     <group
         android:id="@+id/menu_group_text"
         android:visible="false">

+ 1 - 1
gradle/libs.versions.toml

@@ -3,7 +3,7 @@
 datastorePreferences = "1.1.7"
 kotlin = "2.2.10"
 ksp = "2.2.10-2.0.2"
-agp = "8.12.2"
+agp = "8.12.3"
 appcompat = "1.7.1"
 colorpicker = "1.1.0"
 commonsText = "1.14.0"