添加批量操作等功能。

This commit is contained in:
ocean 2025-09-29 14:50:04 +08:00
parent c7af68a8ee
commit e20a8ab016
14 changed files with 238 additions and 30 deletions

View File

@ -5,6 +5,9 @@ import java.io.File
sealed class FileActionEvent {
data class Rename(val renameResult: RenameResult) : FileActionEvent()
data class Delete(val deleteResult: DeleteResult) : FileActionEvent()
data class DeleteAll(val status: Status, val deleteResult: DeleteResult? = null) : FileActionEvent() {
enum class Status { START, COMPLETE }
}
data class Favorite(val isFavorite: Boolean) : FileActionEvent()
data class Duplicate(val file: File?) : FileActionEvent()

View File

@ -31,6 +31,9 @@ interface BookmarkDao {
@Query("DELETE FROM bookmarks WHERE filePath = :filePath")
suspend fun deleteAllByPdf(filePath: String): Int
@Query("DELETE FROM bookmarks WHERE filePath IN (:filePaths)")
suspend fun deleteAllByPdfs(filePaths: List<String>): Int
@Query("DELETE FROM bookmarks WHERE filePath = :filePath AND pageNumber = :pageNumber")
suspend fun deleteByPage(filePath: String, pageNumber: Int)

View File

@ -33,7 +33,10 @@ interface NoteDao {
@Query("DELETE FROM notes WHERE filePath = :filePath")
suspend fun deleteAllByPdf(filePath: String)
@Query("DELETE FROM notes WHERE filePath IN (:filePaths)")
suspend fun deleteAllByPdfs(filePaths: List<String>)
@Query("DELETE FROM notes WHERE filePath = :filePath AND pageNumber = :pageNumber")
suspend fun deleteByPage(filePath: String, pageNumber: Int)
}

View File

@ -41,6 +41,14 @@ interface PdfDocumentDao {
@Query("DELETE FROM pdf_documents WHERE filePath = :filePath")
suspend fun deleteByPath(filePath: String)
// 批量删除
@Query("DELETE FROM pdf_documents WHERE filePath IN (:filePaths)")
suspend fun deleteByPaths(filePaths: List<String>)
// 批量取消收藏设置isFavorite = 0
@Query("UPDATE pdf_documents SET isFavorite = 0 WHERE filePath IN (:filePaths)")
suspend fun cancelFavoriteStatus(filePaths: List<String>)
//@Query 会响应flow
@Query("UPDATE pdf_documents SET filePath = :newFilePath, fileName = :newName WHERE filePath = :oldFilePath")
suspend fun updateFilePathAndFileName(oldFilePath: String, newFilePath: String, newName: String)
@ -51,4 +59,7 @@ interface PdfDocumentDao {
@Query("UPDATE pdf_documents SET lastOpenedTime = :time WHERE filePath = :filePath")
suspend fun updateLastOpenTime(filePath: String, time: Long)
@Query("UPDATE pdf_documents SET lastOpenedTime = :time WHERE filePath IN (:filePaths)")
suspend fun updateLastOpenTimes(filePaths: List<String>, time: Long)
}

View File

@ -30,7 +30,11 @@ interface RecentReadDao {
@Query("DELETE FROM recently_read WHERE filePath = :filePath")
suspend fun deleteByPdfPath(filePath: String)
@Query("DELETE FROM recently_read WHERE filePath IN (:filePaths)")
suspend fun deleteByPdfPaths(filePaths: List<String>)
@Query("DELETE FROM recently_read WHERE lastOpenedTime < :cutoffTime")
suspend fun deleteOldRecents(cutoffTime: Long)
}

View File

@ -62,10 +62,14 @@ class PdfRepository private constructor(context: Context) {
}
//更新最后打开时间可以设置为0L相当于更新成未打开过。
suspend fun updateLastOpenTime(filePath: String, time: Long) {
suspend fun updateLastOpenTime(filePath: String, time: Long = 0L) {
pdfDao.updateLastOpenTime(filePath, time)
}
suspend fun updateLastOpenTimes(filePaths: List<String>, time: Long = 0L) {
pdfDao.updateLastOpenTimes(filePaths, time)
}
suspend fun updateFavoriteStatus(filePath: String, isFavorite: Boolean) {
val document = pdfDao.getByPath(filePath)?.copy(
isFavorite = isFavorite,
@ -74,6 +78,11 @@ class PdfRepository private constructor(context: Context) {
document?.let { pdfDao.update(it) }
}
//批量取消收藏
suspend fun cancelFavorites(filePaths: List<String>) {
pdfDao.cancelFavoriteStatus(filePaths)
}
suspend fun updateReadingProgress(filePath: String, page: Int, progress: Float) {
val document = pdfDao.getByPath(filePath)?.copy(
lastOpenedTime = System.currentTimeMillis(),
@ -188,6 +197,14 @@ class PdfRepository private constructor(context: Context) {
noteDao.deleteAllByPdf(filePath)
}
// 数据清理集合
suspend fun deleteDocuments(filePaths: List<String>) {
pdfDao.deleteByPaths(filePaths)
recentDao.deleteByPdfPaths(filePaths)
bookmarkDao.deleteAllByPdfs(filePaths)
noteDao.deleteAllByPdfs(filePaths)
}
companion object {
@Volatile
private var INSTANCE: PdfRepository? = null

View File

@ -2,6 +2,7 @@ package com.all.pdfreader.pro.app.ui.act
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
@ -15,18 +16,22 @@ import com.all.pdfreader.pro.app.model.FragmentType
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
import com.all.pdfreader.pro.app.ui.dialog.PermissionDialogFragment
import com.all.pdfreader.pro.app.ui.dialog.ProgressDialogFragment
import com.all.pdfreader.pro.app.ui.dialog.PromptDialogFragment
import com.all.pdfreader.pro.app.ui.dialog.SortDialogFragment
import com.all.pdfreader.pro.app.ui.fragment.FavoriteFrag
import com.all.pdfreader.pro.app.ui.fragment.HomeFrag
import com.all.pdfreader.pro.app.ui.fragment.RecentlyFrag
import com.all.pdfreader.pro.app.ui.fragment.ToolsFrag
import com.all.pdfreader.pro.app.util.AppUtils.setClickWithAnimation
import com.all.pdfreader.pro.app.util.AppUtils.setOnSingleClickListener
import com.all.pdfreader.pro.app.util.PdfScanner
import com.all.pdfreader.pro.app.util.PdfUtils
import com.all.pdfreader.pro.app.util.StoragePermissionHelper
import com.all.pdfreader.pro.app.viewmodel.PdfViewModel
import com.all.pdfreader.pro.app.viewmodel.observeEvent
import com.gyf.immersionbar.ImmersionBar
import kotlinx.coroutines.launch
import java.io.File
class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback,
PermissionDialogFragment.CloseCallback, HomeFrag.OnItemLongClickListener,
@ -136,6 +141,26 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
}
}
}
viewModel.fileActionEvent.observeEvent<FileActionEvent.DeleteAll>(this) { event ->
when (event.status) {
FileActionEvent.DeleteAll.Status.START -> {
progressDialog = ProgressDialogFragment()
progressDialog?.show(supportFragmentManager, "progressDialog")
}
FileActionEvent.DeleteAll.Status.COMPLETE -> {
progressDialog?.dismiss()
progressDialog = null
event.deleteResult?.let {
if (event.deleteResult.success) {
showToast(getString(R.string.delete_successfully))
} else {
showToast(event.deleteResult.errorMessage.toString())
}
}
}
}
}
}
private fun setupFragments() {
@ -147,12 +172,12 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
//按钮点击事件
private fun setupNavigation() {
binding.homeLlBtn.setOnClickListener { switchFragment(homeFragment) }
binding.recentlyLlBtn.setOnClickListener { switchFragment(recentlyFragment) }
binding.favoriteLlBtn.setOnClickListener { switchFragment(favoriteFragment) }
binding.toolsLayoutBtn.setOnClickListener { switchFragment(toolsFragment) }
binding.homeLlBtn.setOnSingleClickListener { switchFragment(homeFragment) }
binding.recentlyLlBtn.setOnSingleClickListener { switchFragment(recentlyFragment) }
binding.favoriteLlBtn.setOnSingleClickListener { switchFragment(favoriteFragment) }
binding.toolsLayoutBtn.setOnSingleClickListener { switchFragment(toolsFragment) }
binding.pnGoBtn.setOnClickListener {
binding.pnGoBtn.setOnSingleClickListener {
//直接跳转到权限设置页面
requestPermissions()
}
@ -171,13 +196,10 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
(activeFragment as? SortableFragment)?.onSortTypeChanged(it)
}).show(supportFragmentManager, TAG)
}
binding.multiSelectBackBtn.setOnClickListener {
homeFragment.exitMultiSelectMode()
favoriteFragment.exitMultiSelectMode()
recentlyFragment.exitMultiSelectMode()
updateMultiSelectUi(false, fragmentType)
binding.multiSelectBackBtn.setOnSingleClickListener {
exitAllMultiSelect()
}
binding.multiSelectAllBtn.setOnClickListener {
binding.multiSelectAllBtn.setOnSingleClickListener {
homeFragment.adapter.toggleSelectAll()
val isAllSelected = homeFragment.adapter.isAllSelected()
if (isAllSelected) {
@ -187,18 +209,56 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
}
updateSelectNumber(homeFragment.adapter.getSelectedItems().size)
}
binding.multiSelectDeleteBtn.setOnClickListener {
binding.multiSelectDeleteBtn.setOnSingleClickListener {
val selectedItems = homeFragment.adapter.getSelectedItems()
logDebug("selectedItems->${selectedItems.size}")
if (selectedItems.isNotEmpty()) {
val filesToDelete = selectedItems.map { File(it.filePath) }
var title = getString(R.string.delete_file_title)
var desc = getString(R.string.delete_file_desc)
if (selectedItems.size > 1) {
title = getString(R.string.delete_all_file_title)
desc = getString(R.string.delete_all_file_desc)
}
PromptDialogFragment(
title, desc, onOkClick = {
viewModel.deleteFiles(filesToDelete)
exitAllMultiSelect()
}).show(supportFragmentManager, "deleteFiles")
}
}
binding.multiSelectMergeBtn.setOnClickListener {
binding.multiSelectMergeBtn.setOnSingleClickListener {
logDebug("合并")
val selectedItems = recentlyFragment.adapter.getSelectedItems()
// if (selectedItems.isNotEmpty()) {
// val inputFile = selectedItems.map { File(it.filePath) }
// val outputDir = File(
// Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
// "PDFReaderPro/merge"
// ).apply { if (!exists()) mkdirs() }
// PdfUtils.mergePdfFiles(inputFiles = inputFile, outputDir =outputDir, onProgress = {} )
// }
}
binding.multiSelectRemoveBtn.setOnClickListener {
logDebug("移除最新阅读")
binding.multiSelectRemoveBtn.setOnSingleClickListener {
val selectedItems = recentlyFragment.adapter.getSelectedItems()
if (selectedItems.isNotEmpty()) {
val filePaths = selectedItems.map { it.filePath }
PromptDialogFragment(
getString(R.string.remove_dialog_title),
getString(R.string.remove_dialog_desc),
getString(R.string.remove),
onOkClick = {
viewModel.removeRecentAll(filePaths)
exitAllMultiSelect()
}).show(supportFragmentManager, "removeRecent")
}
}
binding.multiSelectUnFavoriteBtn.setOnClickListener {
logDebug("移除收藏")
binding.multiSelectUnFavoriteBtn.setOnSingleClickListener {
val selectedItems = favoriteFragment.adapter.getSelectedItems()
if (selectedItems.isNotEmpty()) {
val filePaths = selectedItems.map { it.filePath }
viewModel.cancelCollectState(filePaths)
exitAllMultiSelect()
}
}
}
@ -296,6 +356,13 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
}
}
private fun exitAllMultiSelect() {
homeFragment.exitMultiSelectMode()
favoriteFragment.exitMultiSelectMode()
recentlyFragment.exitMultiSelectMode()
updateMultiSelectUi(false, fragmentType)
}
// 授权后续操作
override fun onPermissionGranted() {
logDebug("main onPermissionGranted")

View File

@ -19,6 +19,7 @@ import com.all.pdfreader.pro.app.ui.adapter.SplitPdfAdapter
import com.all.pdfreader.pro.app.ui.adapter.SplitSelectedPdfAdapter
import com.all.pdfreader.pro.app.ui.dialog.PromptDialogFragment
import com.all.pdfreader.pro.app.ui.dialog.RenameDialogFragment
import com.all.pdfreader.pro.app.util.AppUtils.setOnSingleClickListener
import com.all.pdfreader.pro.app.util.FileUtils.toUnderscoreDateTime
import com.all.pdfreader.pro.app.util.PdfUtils
import com.gyf.immersionbar.ImmersionBar
@ -109,8 +110,8 @@ class SplitPdfActivity : BaseActivity() {
}
private fun setupClick() {
binding.backBtn.setOnClickListener { onBackPressedDispatcher.onBackPressed() }
binding.selectAllBtn.setOnClickListener {
binding.backBtn.setOnSingleClickListener { onBackPressedDispatcher.onBackPressed() }
binding.selectAllBtn.setOnSingleClickListener {
val selectAll = splitList.any { !it.isSelected }//如果列表里有一页没选中 → 返回 true
adapter.setAllSelected(selectAll)
binding.title.text = getString(R.string.selected_page, adapter.getSelPages())
@ -118,7 +119,7 @@ class SplitPdfActivity : BaseActivity() {
updateSelectAllState(selectAll)
updateContinueNowBtnState(selectAll)
}
binding.continueNowBtn.setOnClickListener {
binding.continueNowBtn.setOnSingleClickListener {
val selectedPages = splitList.filter { it.isSelected }.map { it.copy() }
val name = getString(R.string.split) + "_" + System.currentTimeMillis().toUnderscoreDateTime()
val item = PdfSelectedPagesItem(filePath, name, selectedPages)
@ -127,14 +128,14 @@ class SplitPdfActivity : BaseActivity() {
isSelectedViewShow = true
updateViewState(true)
}
binding.addBtn.setOnClickListener {
binding.addBtn.setOnSingleClickListener {
binding.continueNowBtn.isEnabled = false//继续按钮不可点击
updateContinueNowBtnState(false)//重置继续按钮背景
adapter.setAllSelected(false)
updateSelectAllState(false)
updateViewState(false)
}
binding.splitBtn.setOnClickListener {
binding.splitBtn.setOnSingleClickListener {
//因为图片做的路径缓存方式所以这里直接传入整个集合到result页处理
val intent = SplitPdfResultActivity.createIntent(this, ArrayList(selectedList))
startActivity(intent)

View File

@ -35,6 +35,16 @@ import java.util.regex.Pattern
object AppUtils {
fun View.setOnSingleClickListener(interval: Long = 1000, onClick: (View) -> Unit) {
var lastClickTime = 0L
setOnClickListener {
val now = System.currentTimeMillis()
if (now - lastClickTime < interval) return@setOnClickListener
lastClickTime = now
onClick(it)
}
}
/**
* 添加点击动画点击立即执行逻辑动画期间禁用点击
*

View File

@ -122,6 +122,7 @@ object PdfUtils {
newDocument.importPage(document.getPage(pageItem.pageIndex))
// 回调进度
onProgress?.invoke(index + 1, total)
delay(1)
}
// 保存新 PDF 文件
newDocument.save(outputFile)
@ -133,4 +134,50 @@ object PdfUtils {
null
}
}
/**
* 合并多个 PDF 文件到一个新的 PDF 文件
*
* 使用 PDFBox PDDocument 合并多个 PDF支持进度回调
* 避免直接生成缩略图以保持原 PDF 的矢量质量
*
* @param inputFiles 要合并的 PDF 文件列表
* @param outputDir 输出目录如果不存在会自动创建
* @param outputFileName 输出文件名例如 "merged.pdf"
* @param onProgress 可选回调当前处理进度 (current 文件, total 文件)
* @return 新生成的 PDF 文件失败返回 null
*/
suspend fun mergePdfFiles(
inputFiles: List<File>,
outputDir: File,
outputFileName: String,
onProgress: ((current: Int, total: Int) -> Unit)? = null
): File? = withContext(Dispatchers.IO) {
if (inputFiles.isEmpty()) return@withContext null
if (!outputDir.exists()) outputDir.mkdirs()
val outputFile = File(outputDir, outputFileName)
try {
PDDocument().use { mergedDocument ->
val totalFiles = inputFiles.size
inputFiles.forEachIndexed { index, file ->
PDDocument.load(file).use { doc ->
for (page in doc.pages) {
mergedDocument.addPage(page)
}
}
// 回调进度:按文件计数,也可以按总页数进一步精细化
onProgress?.invoke(index + 1, totalFiles)
delay(1) // 给 UI 更新留点时间
}
mergedDocument.save(outputFile)
}
outputFile
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}

View File

@ -7,6 +7,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.all.pdfreader.pro.app.PRApp
import com.all.pdfreader.pro.app.model.DeleteResult
import com.all.pdfreader.pro.app.model.FileActionEvent
import com.all.pdfreader.pro.app.room.entity.BookmarkEntity
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
@ -88,6 +89,30 @@ class PdfViewModel : ViewModel() {
}
}
fun deleteFiles(files: List<File>) {
viewModelScope.launch {
_fileActionEvent.postValue(FileActionEvent.DeleteAll(FileActionEvent.DeleteAll.Status.START))
val deleteResult = withContext(Dispatchers.IO) {
try {
val result = FileDeleteUtil.deleteFiles(files)
if (result.success) {
// 批量清理数据库
val paths = files.map { it.absolutePath }
pdfRepository.deleteDocuments(paths)
}
result
} catch (e: Exception) {
DeleteResult.failure(e.message ?: "Unknown error")
}
}
_fileActionEvent.postValue(
FileActionEvent.DeleteAll(
FileActionEvent.DeleteAll.Status.COMPLETE, deleteResult
)
)
}
}
fun saveCollectState(filePath: String, isFavorite: Boolean) {
viewModelScope.launch {
pdfRepository.updateFavoriteStatus(filePath, isFavorite)
@ -95,6 +120,13 @@ class PdfViewModel : ViewModel() {
}
}
fun cancelCollectState(filePaths: List<String>) {
viewModelScope.launch {
pdfRepository.cancelFavorites(filePaths)
_fileActionEvent.postValue(FileActionEvent.Favorite(false))
}
}
fun duplicateFile(context: Context, filePath: String) {
viewModelScope.launch {
val file = FileUtils.duplicateFile(File(filePath))
@ -214,7 +246,7 @@ class PdfViewModel : ViewModel() {
}
}
fun gotoPage(number: Int){
fun gotoPage(number: Int) {
viewModelScope.launch {
_fileActionEvent.postValue(FileActionEvent.GotoPage(number))
}
@ -222,7 +254,13 @@ class PdfViewModel : ViewModel() {
fun removeRecent(filePath: String) {
viewModelScope.launch {
pdfRepository.updateLastOpenTime(filePath, 0L)
pdfRepository.updateLastOpenTime(filePath)
}
}
fun removeRecentAll(filePaths: List<String>) {
viewModelScope.launch {
pdfRepository.updateLastOpenTimes(filePaths)
}
}

View File

@ -26,7 +26,7 @@
style="@style/TextViewFont_PopMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_files_yet"
android:text="@string/no_favorites_yet"
android:textColor="#B6BFCC"
android:textSize="20sp" />
</LinearLayout>

View File

@ -26,7 +26,7 @@
style="@style/TextViewFont_PopMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_files_yet"
android:text="@string/no_recent_reads_yet"
android:textColor="#B6BFCC"
android:textSize="20sp" />
</LinearLayout>

View File

@ -97,6 +97,8 @@
<string name="error_cannot_delete_protected_directory">Cannot delete protected system directory</string>
<string name="delete_file_title">Delete this file permanently?</string>
<string name="delete_file_desc">Deleting this file will remove it permanently from your device.</string>
<string name="delete_all_file_title">Delete selected files permanently?</string>
<string name="delete_all_file_desc">Deleting the selected files will remove them permanently from your device.</string>
<string name="file_information">File Information</string>
<string name="file_information_desc">Everything you need to know about the file.</string>
<string name="last_modified">Last Modified</string>
@ -122,6 +124,8 @@
<string name="delete_bookmarks_desc">Are you sure you want to delete all Bookmarks?</string>
<string name="bookmark_loading">Loading bookmarks, please try again later</string>
<string name="no_files_yet">no files yet</string>
<string name="no_favorites_yet">No favorites yet</string>
<string name="no_recent_reads_yet">No recent reads yet</string>
<string name="split_pdf">Split PDF</string>
<string name="split">Split</string>
<string name="merge_pdf">Merge PDF</string>