Quellcode durchsuchen

feat: 阅读记录可清空和关闭,可任选某本书合并记录

HapeLee vor 4 Monaten
Ursprung
Commit
4021e2b0ab

+ 0 - 1
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -144,7 +144,6 @@ object PreferKey {
     const val uploadRule = "uploadRule"
     const val tocUiUseReplace = "tocUiUseReplace"
     const val tocCountWords = "tocCountWords"
-    const val enableReadRecord = "enableReadRecord"
     const val localBookImportSort = "localBookImportSort"
     const val pageTouchSlop = "pageTouchSlop"
     const val showAddToShelfAlert = "showAddToShelfAlert"

+ 45 - 0
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -23,6 +23,7 @@ interface ReadRecordDao {
 
     @get:Query("select * from readRecordSession")
     val allSession: List<ReadRecordSession>
+
     @Query("SELECT sum(readTime) FROM readRecord")
     fun getTotalReadTime(): Flow<Long?>
 
@@ -163,6 +164,21 @@ interface ReadRecordDao {
         excludeAuthor: String
     ): List<ReadRecord>
 
+    @Query(
+        """
+        SELECT * FROM readRecord
+        WHERE NOT (deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor)
+        ORDER BY
+            CASE WHEN bookName = :bookName THEN 0 ELSE 1 END,
+            lastRead DESC
+        """
+    )
+    suspend fun getMergeCandidates(
+        deviceId: String,
+        bookName: String,
+        bookAuthor: String
+    ): List<ReadRecord>
+
     /**
      * 获取某一天某一本书的所有会话记录
      */
@@ -211,6 +227,25 @@ interface ReadRecordDao {
     @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
     suspend fun getSessionsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordSession>
 
+    @Query(
+        """
+        SELECT * FROM readRecordSession
+        WHERE deviceId = :deviceId
+        AND bookName = :bookName
+        AND bookAuthor = :bookAuthor
+        AND startTime = :startTime
+        AND endTime = :endTime
+        LIMIT 1
+        """
+    )
+    suspend fun getSession(
+        deviceId: String,
+        bookName: String,
+        bookAuthor: String,
+        startTime: Long,
+        endTime: Long
+    ): ReadRecordSession?
+
     @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor ORDER BY startTime DESC")
     fun getSessionsByBookFlow(deviceId: String, bookName: String, bookAuthor: String): Flow<List<ReadRecordSession>>
 
@@ -242,6 +277,15 @@ interface ReadRecordDao {
     @Delete
     suspend fun deleteReadRecord(record: ReadRecord)
 
+    @Query("DELETE FROM readRecord")
+    suspend fun clearReadRecords()
+
+    @Query("DELETE FROM readRecordDetail")
+    suspend fun clearReadRecordDetails()
+
+    @Query("DELETE FROM readRecordSession")
+    suspend fun clearReadRecordSessions()
+
     @Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
     suspend fun deleteDetailsByBook(deviceId: String, bookName: String, bookAuthor: String)
 
@@ -250,4 +294,5 @@ interface ReadRecordDao {
 
     @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
     suspend fun getDetailsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordDetail>
+
 }

+ 3 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -25,6 +25,9 @@ object LocalPreferencesKeys {
     val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
     val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
     val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")
+
+    // ENABLE_READ_RECORD也许需要换个地方存,但先放这
+    val ENABLE_READ_RECORD = booleanPreferencesKey("enableReadRecord")
     val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
     val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
     val COVER_ALBUM_MIGRATED = booleanPreferencesKey("cover_album_migrated")

+ 37 - 3
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -9,7 +9,10 @@ import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import java.util.Date
 import kotlin.math.max
@@ -18,9 +21,17 @@ import kotlin.math.min
 class ReadRecordRepository(
     private val dao: ReadRecordDao,
     private val database: AppDatabase,
+    private val localPreferencesRepository: LocalPreferencesRepository,
 ) {
     private fun getCurrentDeviceId(): String = ""
 
+    val readRecordEnabled: Flow<Boolean> =
+        localPreferencesRepository.getPreference(LocalPreferencesKeys.ENABLE_READ_RECORD, true)
+
+    suspend fun setReadRecordEnabled(enabled: Boolean) {
+        localPreferencesRepository.updatePreference(LocalPreferencesKeys.ENABLE_READ_RECORD, enabled)
+    }
+
     /**
      * 获取总阅读时长流
      */
@@ -76,7 +87,7 @@ class ReadRecordRepository(
     }
 
     suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
-        return dao.getReadRecordsByNameExcludingAuthor(
+        return dao.getMergeCandidates(
             targetRecord.deviceId,
             targetRecord.bookName,
             targetRecord.bookAuthor
@@ -87,8 +98,18 @@ class ReadRecordRepository(
      * 保存一个完整的阅读会话.
      */
     suspend fun saveReadSession(newSession: ReadRecordSession) {
+        if (!readRecordEnabled.first()) return
         if (newSession.endTime <= newSession.startTime) return
         database.withTransaction {
+            val existingSession = dao.getSession(
+                newSession.deviceId,
+                newSession.bookName,
+                newSession.bookAuthor,
+                newSession.startTime,
+                newSession.endTime
+            )
+            if (existingSession != null) return@withTransaction
+
             val segmentDuration = newSession.endTime - newSession.startTime
             dao.insertSession(newSession)
             val dateString =
@@ -264,6 +285,14 @@ class ReadRecordRepository(
         }
     }
 
+    suspend fun clearReadRecords() {
+        database.withTransaction {
+            dao.clearReadRecordSessions()
+            dao.clearReadRecordDetails()
+            dao.clearReadRecords()
+        }
+    }
+
     suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
         database.withTransaction {
             sourceRecords.forEach { sourceRecord ->
@@ -275,7 +304,6 @@ class ReadRecordRepository(
     private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
         if (targetRecord == sourceRecord) return
         if (targetRecord.deviceId != sourceRecord.deviceId) return
-        if (targetRecord.bookName != sourceRecord.bookName) return
 
         val source = dao.getReadRecord(
             sourceRecord.deviceId,
@@ -311,6 +339,7 @@ class ReadRecordRepository(
             if (existingTargetDetail == null) {
                 dao.insertDetail(
                     detail.copy(
+                        bookName = targetRecord.bookName,
                         bookAuthor = targetRecord.bookAuthor
                     )
                 )
@@ -333,7 +362,12 @@ class ReadRecordRepository(
             sourceRecord.bookAuthor
         )
         sourceSessions.forEach { session ->
-            dao.updateSession(session.copy(bookAuthor = targetRecord.bookAuthor))
+            dao.updateSession(
+                session.copy(
+                    bookName = targetRecord.bookName,
+                    bookAuthor = targetRecord.bookAuthor
+                )
+            )
         }
 
         dao.deleteReadRecord(source)

+ 0 - 6
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -477,12 +477,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             ReadConfig.tocCountWords = value
         }
 
-    var enableReadRecord: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.enableReadRecord, true)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.enableReadRecord, value)
-        }
-
     val autoChangeSource: Boolean
         get() = ReadConfig.autoChangeSource
 

+ 57 - 17
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -7,7 +7,6 @@ import androidx.core.content.edit
 import androidx.documentfile.provider.DocumentFile
 import io.legado.app.BuildConfig
 import io.legado.app.R
-import io.legado.app.constant.AppConst.androidId
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
@@ -233,27 +232,16 @@ object Restore : KoinComponent {
         }
         fileToListT<ReadRecord>(path, "readRecord.json")?.let {
             it.forEach { readRecord ->
-                if (readRecord.deviceId != androidId) {
-                    try {
-                        appDb.readRecordDao.insert(readRecord)
-                    } catch (_: SQLiteConstraintException) {
-                    }
-                } else {
-                    val time = appDb.readRecordDao
-                        .getReadTime(readRecord.deviceId, readRecord.bookName, readRecord.bookAuthor)
-                    if (time == null || time < readRecord.readTime) {
-                        try {
-                            appDb.readRecordDao.insert(readRecord)
-                        } catch (_: SQLiteConstraintException) {
-                        }
-                    }
+                try {
+                    restoreReadRecord(readRecord)
+                } catch (_: SQLiteConstraintException) {
                 }
             }
         }
         fileToListT<ReadRecordDetail>(path, "readRecordDetail.json")?.let {
             it.forEach { detail ->
                 try {
-                    appDb.readRecordDao.insertDetail(detail)
+                    restoreReadRecordDetail(detail)
                 } catch (_: SQLiteConstraintException) {
                 }
             }
@@ -261,7 +249,7 @@ object Restore : KoinComponent {
         fileToListT<ReadRecordSession>(path, "readRecordSession.json")?.let {
             it.forEach { session ->
                 try {
-                    appDb.readRecordDao.insertSession(session)
+                    restoreReadRecordSession(session)
                 } catch (_: SQLiteConstraintException) {
                 }
             }
@@ -354,6 +342,58 @@ object Restore : KoinComponent {
         }
     }
 
+    private suspend fun restoreReadRecord(readRecord: ReadRecord) {
+        val existing = appDb.readRecordDao.getReadRecord(
+            readRecord.deviceId,
+            readRecord.bookName,
+            readRecord.bookAuthor
+        )
+        appDb.readRecordDao.insert(
+            existing?.copy(
+                readTime = maxOf(existing.readTime, readRecord.readTime),
+                lastRead = maxOf(existing.lastRead, readRecord.lastRead)
+            ) ?: readRecord
+        )
+    }
+
+    private suspend fun restoreReadRecordDetail(detail: ReadRecordDetail) {
+        val existing = appDb.readRecordDao.getDetail(
+            detail.deviceId,
+            detail.bookName,
+            detail.bookAuthor,
+            detail.date
+        )
+        appDb.readRecordDao.insertDetail(
+            existing?.copy(
+                readTime = maxOf(existing.readTime, detail.readTime),
+                readWords = maxOf(existing.readWords, detail.readWords),
+                firstReadTime = minPositive(existing.firstReadTime, detail.firstReadTime),
+                lastReadTime = maxOf(existing.lastReadTime, detail.lastReadTime)
+            ) ?: detail
+        )
+    }
+
+    private suspend fun restoreReadRecordSession(session: ReadRecordSession) {
+        val existing = appDb.readRecordDao.getSession(
+            session.deviceId,
+            session.bookName,
+            session.bookAuthor,
+            session.startTime,
+            session.endTime
+        )
+        if (existing == null) {
+            appDb.readRecordDao.insertSession(session)
+        }
+    }
+
+    private fun minPositive(left: Long, right: Long): Long {
+        return when {
+            left <= 0L -> right
+            right <= 0L -> left
+            else -> minOf(left, right)
+        }
+    }
+
     private suspend fun applyConfigMap(map: Map<String, Any?>, aes: BackupAES) {
         val finalMap = mutableMapOf<String, Any>()
         appCtx.defaultSharedPreferences.edit {

+ 412 - 113
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -1,9 +1,12 @@
 package io.legado.app.ui.book.readRecord
 
+import androidx.activity.compose.BackHandler
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -25,6 +28,7 @@ import androidx.compose.material.icons.automirrored.filled.List
 import androidx.compose.material.icons.filled.CalendarMonth
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Merge
+import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Schedule
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Timeline
@@ -98,6 +102,8 @@ import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
 import io.legado.app.ui.widget.components.swipe.SwipeAction
 import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
 import io.legado.app.ui.widget.components.text.AppText
@@ -131,19 +137,25 @@ fun ReadRecordScreen(
 
     val state by viewModel.uiState.collectAsState()
     val displayMode by viewModel.displayMode.collectAsState()
+    val readRecordEnabled by viewModel.readRecordEnabled.collectAsState()
     var showSearch by remember { mutableStateOf(false) }
     var showCalendar by remember { mutableStateOf(false) }
+    var showActionsSheet by remember { mutableStateOf(false) }
     var heatmapMode by remember { mutableStateOf(HeatmapMode.COUNT) }
+    var selectedItemKeys by remember { mutableStateOf(emptySet<String>()) }
     val listState = rememberLazyListState()
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val inSelectionMode = selectedItemKeys.isNotEmpty()
 
     var skipDeleteConfirm by remember { mutableStateOf(false) }
     var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+    var pendingDeleteCount by remember { mutableStateOf(1) }
     var mergeDialogData by remember { mutableStateOf<Pair<ReadRecord, List<ReadRecord>>?>(null) }
-    val onConfirmDelete: (() -> Unit) -> Unit = { action ->
+    val onConfirmDelete: (Int, () -> Unit) -> Unit = { count, action ->
         if (skipDeleteConfirm) {
             action()
         } else {
+            pendingDeleteCount = count
             pendingDeleteAction = action
         }
     }
@@ -184,14 +196,23 @@ fun ReadRecordScreen(
         }
     }
 
+    BackHandler(enabled = inSelectionMode) {
+        selectedItemKeys = emptySet()
+    }
+
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         snackbarHost = { SnackbarHost(snackbarHostState) },
         topBar = {
             Column {
                 GlassMediumFlexibleTopAppBar(
-                    title = stringResource(R.string.read_record),
+                    title = if (inSelectionMode) {
+                        stringResource(R.string.selected_count, selectedItemKeys.size)
+                    } else {
+                        stringResource(R.string.read_record)
+                    },
                     subtitle = run {
+                        if (inSelectionMode) return@run stringResource(R.string.long_press_select_mode)
                         val subTitle = when (displayMode) {
                             DisplayMode.AGGREGATE -> stringResource(R.string.read_record_view_aggregate)
                             DisplayMode.TIMELINE -> stringResource(R.string.read_record_view_timeline)
@@ -200,37 +221,67 @@ fun ReadRecordScreen(
                         subTitle
                     },
                     navigationIcon = {
-                        TopBarNavigationButton(onClick = onBackClick)
+                        TopBarNavigationButton(
+                            onClick = {
+                                if (inSelectionMode) {
+                                    selectedItemKeys = emptySet()
+                                } else {
+                                    onBackClick()
+                                }
+                            }
+                        )
                     },
                     actions = {
-                        AppIconButton(onClick = {
-                            val newMode = when (displayMode) {
-                                DisplayMode.AGGREGATE -> DisplayMode.TIMELINE
-                                DisplayMode.TIMELINE -> DisplayMode.LATEST
-                                DisplayMode.LATEST -> DisplayMode.AGGREGATE
+                        if (inSelectionMode) {
+                            AppIconButton(
+                                onClick = {
+                                    val action = {
+                                        deleteSelectedReadRecords(
+                                            state = state,
+                                            selectedItemKeys = selectedItemKeys,
+                                            viewModel = viewModel
+                                        )
+                                        selectedItemKeys = emptySet()
+                                    }
+                                    onConfirmDelete(selectedItemKeys.size, action)
+                                }
+                            ) {
+                                AppIcon(
+                                    Icons.Default.Delete,
+                                    contentDescription = stringResource(R.string.delete_selected)
+                                )
                             }
-                            viewModel.setDisplayMode(newMode)
-                        }) {
-                            val icon = when (displayMode) {
-                                DisplayMode.AGGREGATE -> Icons.Default.Timeline
-                                DisplayMode.TIMELINE -> Icons.Default.Schedule
-                                DisplayMode.LATEST -> Icons.AutoMirrored.Filled.List
+                        } else {
+                            AppIconButton(onClick = {
+                                val newMode = when (displayMode) {
+                                    DisplayMode.AGGREGATE -> DisplayMode.TIMELINE
+                                    DisplayMode.TIMELINE -> DisplayMode.LATEST
+                                    DisplayMode.LATEST -> DisplayMode.AGGREGATE
+                                }
+                                viewModel.setDisplayMode(newMode)
+                                selectedItemKeys = emptySet()
+                            }) {
+                                val icon = when (displayMode) {
+                                    DisplayMode.AGGREGATE -> Icons.Default.Timeline
+                                    DisplayMode.TIMELINE -> Icons.Default.Schedule
+                                    DisplayMode.LATEST -> Icons.AutoMirrored.Filled.List
+                                }
+                                val description = when (displayMode) {
+                                    DisplayMode.AGGREGATE -> stringResource(R.string.a11y_switch_to_timeline_view)
+                                    DisplayMode.TIMELINE -> stringResource(R.string.a11y_switch_to_latest_view)
+                                    DisplayMode.LATEST -> stringResource(R.string.a11y_switch_to_aggregate_view)
+                                }
+                                AppIcon(icon, description)
                             }
-                            val description = when (displayMode) {
-                                DisplayMode.AGGREGATE -> stringResource(R.string.a11y_switch_to_timeline_view)
-                                DisplayMode.TIMELINE -> stringResource(R.string.a11y_switch_to_latest_view)
-                                DisplayMode.LATEST -> stringResource(R.string.a11y_switch_to_aggregate_view)
+                            AppIconButton(onClick = { showSearch = !showSearch }) {
+                                AppIcon(Icons.Default.Search, contentDescription = stringResource(R.string.search))
+                            }
+                            AppIconButton(onClick = { showActionsSheet = true }) {
+                                AppIcon(
+                                    Icons.Default.MoreVert,
+                                    contentDescription = stringResource(R.string.more_actions)
+                                )
                             }
-                            AppIcon(icon, description)
-                        }
-                        AppIconButton(onClick = { showCalendar = !showCalendar }) {
-                            AppIcon(
-                                Icons.Default.CalendarMonth,
-                                contentDescription = stringResource(R.string.a11y_toggle_read_calendar)
-                            )
-                        }
-                        AppIconButton(onClick = { showSearch = !showSearch }) {
-                            AppIcon(Icons.Default.Search, contentDescription = stringResource(R.string.search))
                         }
                     },
                     scrollBehavior = scrollBehavior
@@ -304,6 +355,18 @@ fun ReadRecordScreen(
                                 viewModel = viewModel,
                                 onBookClick = onBookClick,
                                 onConfirmDelete = onConfirmDelete,
+                                selectedItemKeys = selectedItemKeys,
+                                inSelectionMode = inSelectionMode,
+                                onToggleSelection = { key ->
+                                    selectedItemKeys = if (selectedItemKeys.contains(key)) {
+                                        selectedItemKeys - key
+                                    } else {
+                                        selectedItemKeys + key
+                                    }
+                                },
+                                onEnterSelection = { key ->
+                                    selectedItemKeys = selectedItemKeys + key
+                                },
                                 onMergeClick = { record ->
                                     scope.launch {
                                         val candidates = viewModel.getMergeCandidates(record)
@@ -350,6 +413,27 @@ fun ReadRecordScreen(
         }
     }
 
+    ReadRecordActionsSheet(
+        show = showActionsSheet,
+        showCalendar = showCalendar,
+        readRecordEnabled = readRecordEnabled,
+        onDismissRequest = { showActionsSheet = false },
+        onToggleCalendar = {
+            showCalendar = !showCalendar
+            showActionsSheet = false
+        },
+        onReadRecordEnabledChange = { checked ->
+            viewModel.setReadRecordEnabled(checked)
+        },
+        onClearReadRecords = {
+            showActionsSheet = false
+            onConfirmDelete(-1) {
+                viewModel.clearReadRecords()
+                selectedItemKeys = emptySet()
+            }
+        }
+    )
+
     var skipDeleteConfirmTemp by remember(pendingDeleteAction != null) { mutableStateOf(false) }
 
     AppAlertDialog(
@@ -358,20 +442,32 @@ fun ReadRecordScreen(
         title = stringResource(R.string.confirm_delete_read_record),
         content = { _ ->
             Column {
-                AppText(stringResource(R.string.delete_read_record_message))
-                Spacer(modifier = Modifier.height(8.dp))
-                CheckboxItem(
-                    title = stringResource(R.string.do_not_remind_again),
-                    checked = skipDeleteConfirmTemp,
-                    onCheckedChange = { skipDeleteConfirmTemp = it }
+                AppText(
+                    if (pendingDeleteCount == -1) {
+                        stringResource(R.string.clear_read_records_message)
+                    } else if (pendingDeleteCount > 1) {
+                        stringResource(R.string.delete_selected_read_records_message, pendingDeleteCount)
+                    } else {
+                        stringResource(R.string.delete_read_record_message)
+                    }
                 )
+                if (pendingDeleteCount != -1) {
+                    Spacer(modifier = Modifier.height(8.dp))
+                    CheckboxItem(
+                        title = stringResource(R.string.do_not_remind_again),
+                        checked = skipDeleteConfirmTemp,
+                        onCheckedChange = { skipDeleteConfirmTemp = it }
+                    )
+                }
             }
         },
         confirmText = stringResource(R.string.delete),
         onConfirm = { action ->
             action.invoke()
             pendingDeleteAction = null
-            skipDeleteConfirm = skipDeleteConfirmTemp
+            if (pendingDeleteCount != -1) {
+                skipDeleteConfirm = skipDeleteConfirmTemp
+            }
         },
         dismissText = stringResource(R.string.cancel),
         onDismiss = {
@@ -410,10 +506,13 @@ fun ReadRecordScreen(
         )
     }
 
-    var selectedAuthors by remember(mergeDialogData != null) {
+    var selectedMergeKeys by remember(mergeDialogData != null) {
         mutableStateOf(
-            mergeDialogData?.let { (_, candidates) ->
-                candidates.map { it.bookAuthor }.toSet()
+            mergeDialogData?.let { (targetRecord, candidates) ->
+                candidates
+                    .filter { it.bookName == targetRecord.bookName }
+                    .map { it.mergeKey() }
+                    .toSet()
             } ?: emptySet()
         )
     }
@@ -434,21 +533,29 @@ fun ReadRecordScreen(
                 )
                 Spacer(modifier = Modifier.height(8.dp))
 
-                candidates.forEach { candidate ->
-                    val author = candidate.bookAuthor.ifBlank { unknownAuthor }
-                    val isChecked = selectedAuthors.contains(candidate.bookAuthor)
-
-                    CheckboxItem(
-                        title = "$author(${formatDuring(candidate.readTime)})",
-                        checked = isChecked,
-                        onCheckedChange = { checked ->
-                            selectedAuthors = if (checked) {
-                                selectedAuthors + candidate.bookAuthor
-                            } else {
-                                selectedAuthors - candidate.bookAuthor
+                LazyColumn(modifier = Modifier.height(320.dp)) {
+                    items(candidates, key = { it.mergeKey() }) { candidate ->
+                        val author = candidate.bookAuthor.ifBlank { unknownAuthor }
+                        val candidateKey = candidate.mergeKey()
+                        val isChecked = selectedMergeKeys.contains(candidateKey)
+
+                        CheckboxItem(
+                            title = stringResource(
+                                R.string.merge_read_record_candidate,
+                                candidate.bookName,
+                                author,
+                                formatDuring(candidate.readTime)
+                            ),
+                            checked = isChecked,
+                            onCheckedChange = { checked ->
+                                selectedMergeKeys = if (checked) {
+                                    selectedMergeKeys + candidateKey
+                                } else {
+                                    selectedMergeKeys - candidateKey
+                                }
                             }
-                        }
-                    )
+                        )
+                    }
                 }
             }
         },
@@ -456,7 +563,7 @@ fun ReadRecordScreen(
         onConfirm = { (targetRecord, candidates) ->
             viewModel.mergeReadRecords(
                 targetRecord,
-                candidates.filter { selectedAuthors.contains(it.bookAuthor) }
+                candidates.filter { selectedMergeKeys.contains(it.mergeKey()) }
             )
             mergeDialogData = null
         },
@@ -465,6 +572,109 @@ fun ReadRecordScreen(
     )
 }
 
+@Composable
+private fun ReadRecordActionsSheet(
+    show: Boolean,
+    showCalendar: Boolean,
+    readRecordEnabled: Boolean,
+    onDismissRequest: () -> Unit,
+    onToggleCalendar: () -> Unit,
+    onReadRecordEnabledChange: (Boolean) -> Unit,
+    onClearReadRecords: () -> Unit
+) {
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.read_record_options)
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(bottom = 16.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            CompactClickableSettingItem(
+                title = stringResource(
+                    if (showCalendar) R.string.hide_read_calendar else R.string.show_read_calendar
+                ),
+                imageVector = Icons.Default.CalendarMonth,
+                onClick = onToggleCalendar
+            )
+            CompactSwitchSettingItem(
+                title = stringResource(R.string.enable_read_record),
+                checked = readRecordEnabled,
+                description = stringResource(R.string.enable_read_record_summary),
+                imageVector = Icons.Default.Schedule,
+                onCheckedChange = onReadRecordEnabledChange
+            )
+            CompactClickableSettingItem(
+                title = stringResource(R.string.clear_read_records),
+                description = stringResource(R.string.clear_read_records_summary),
+                imageVector = Icons.Default.Delete,
+                color = LegadoTheme.colorScheme.error,
+                onClick = onClearReadRecords
+            )
+        }
+    }
+}
+
+private fun ReadRecord.mergeKey(): String {
+    return "$deviceId|$bookName|$bookAuthor"
+}
+
+private fun ReadRecordDetail.selectionKey(): String {
+    return "detail|$deviceId|$bookName|$bookAuthor|$date"
+}
+
+private fun ReadRecordSession.selectionKey(): String {
+    return "session|$id"
+}
+
+private fun ReadRecord.selectionKey(): String {
+    return "record|$deviceId|$bookName|$bookAuthor"
+}
+
+private fun deleteSelectedReadRecords(
+    state: ReadRecordUiState,
+    selectedItemKeys: Set<String>,
+    viewModel: ReadRecordViewModel
+) {
+    state.groupedRecords.values.flatten()
+        .filter { selectedItemKeys.contains(it.selectionKey()) }
+        .forEach(viewModel::deleteDetail)
+    state.timelineRecords.values.flatten()
+        .filter { selectedItemKeys.contains(it.selectionKey()) }
+        .forEach(viewModel::deleteSession)
+    state.latestRecords
+        .filter { selectedItemKeys.contains(it.selectionKey()) }
+        .forEach(viewModel::deleteReadRecord)
+}
+
+@Composable
+private fun Modifier.selectionBackground(isSelected: Boolean): Modifier {
+    return if (isSelected) {
+        background(LegadoTheme.colorScheme.primary.copy(alpha = 0.12f))
+    } else {
+        this
+    }
+}
+
+@Composable
+private fun SelectionCheckmark(
+    inSelectionMode: Boolean,
+    isSelected: Boolean
+) {
+    AnimatedVisibility(visible = inSelectionMode) {
+        AppText(
+            text = if (isSelected) "✓" else "",
+            modifier = Modifier.width(24.dp),
+            color = LegadoTheme.colorScheme.primary,
+            style = LegadoTheme.typography.titleMedium,
+            fontWeight = FontWeight.Bold
+        )
+    }
+}
+
 @Composable
 fun SummarySection(
     state: ReadRecordUiState,
@@ -592,7 +802,11 @@ fun LazyListScope.renderListByMode(
     state: ReadRecordUiState,
     viewModel: ReadRecordViewModel,
     onBookClick: (String, String) -> Unit,
-    onConfirmDelete: (() -> Unit) -> Unit,
+    onConfirmDelete: (Int, () -> Unit) -> Unit,
+    selectedItemKeys: Set<String>,
+    inSelectionMode: Boolean,
+    onToggleSelection: (String) -> Unit,
+    onEnterSelection: (String) -> Unit,
     onMergeClick: (ReadRecord) -> Unit
 ) {
 
@@ -604,24 +818,44 @@ fun LazyListScope.renderListByMode(
                 }
                 items(
                     items = details,
-                    key = { "agg_item_${date}|${it.bookName}_${it.bookAuthor}_${it.date}" }
+                    key = { "agg_item_${date}|${it.deviceId}_${it.bookName}_${it.bookAuthor}_${it.date}" }
                 ) { detail ->
-                    val deleteActionDescription = stringResource(R.string.del_read_record)
-                    SwipeActionContainer(
-                        modifier = Modifier.animateItem(),
-                        startAction = SwipeAction(
-                            icon = Icons.Default.Delete,
-                            background = LegadoTheme.colorScheme.error,
-                            onSwipe = {
-                                onConfirmDelete { viewModel.deleteDetail(detail) }
-                            },
-                            contentDescription = deleteActionDescription
-                        )
-                    ) {
+                    val itemKey = detail.selectionKey()
+                    val isSelected = selectedItemKeys.contains(itemKey)
+                    val itemContent: @Composable (Modifier) -> Unit = { modifier ->
                         ReadRecordItem(
                             detail,
                             viewModel,
-                            onClick = { onBookClick(detail.bookName, detail.bookAuthor) })
+                            onClick = {
+                                if (inSelectionMode) {
+                                    onToggleSelection(itemKey)
+                                } else {
+                                    onBookClick(detail.bookName, detail.bookAuthor)
+                                }
+                            },
+                            onLongClick = { onEnterSelection(itemKey) },
+                            inSelectionMode = inSelectionMode,
+                            isSelected = isSelected,
+                            modifier = modifier
+                        )
+                    }
+                    val deleteActionDescription = stringResource(R.string.del_read_record)
+                    if (inSelectionMode) {
+                        itemContent(Modifier.animateItem())
+                    } else {
+                        SwipeActionContainer(
+                            modifier = Modifier.animateItem(),
+                            startAction = SwipeAction(
+                                icon = Icons.Default.Delete,
+                                background = LegadoTheme.colorScheme.error,
+                                onSwipe = {
+                                    onConfirmDelete(1) { viewModel.deleteDetail(detail) }
+                                },
+                                contentDescription = deleteActionDescription
+                            )
+                        ) {
+                            itemContent(Modifier)
+                        }
                     }
                 }
             }
@@ -631,59 +865,97 @@ fun LazyListScope.renderListByMode(
             state.timelineRecords.forEach { (date, sessions) ->
                 item(key = "timeline_header_$date") { DateHeader(date) }
                 items(items = sessions, key = { "timeline_item_${date}|${it.id}" }) { session ->
-                    val deleteActionDescription = stringResource(R.string.del_read_record)
-                    SwipeActionContainer(
-                        modifier = Modifier.animateItem(),
-                        startAction = SwipeAction(
-                            icon = Icons.Default.Delete,
-                            background = LegadoTheme.colorScheme.error,
-                            onSwipe = {
-                                onConfirmDelete { viewModel.deleteSession(session) }
-                            },
-                            contentDescription = deleteActionDescription
-                        )
-                    ) {
+                    val itemKey = session.selectionKey()
+                    val isSelected = selectedItemKeys.contains(itemKey)
+                    val itemContent: @Composable (Modifier) -> Unit = { modifier ->
                         TimelineSessionItem(
                             item = TimelineItem(session, true),
-                            onBookClick = onBookClick,
-                            viewModel = viewModel
+                            onBookClick = { bookName, bookAuthor ->
+                                if (inSelectionMode) {
+                                    onToggleSelection(itemKey)
+                                } else {
+                                    onBookClick(bookName, bookAuthor)
+                                }
+                            },
+                            onLongClick = { onEnterSelection(itemKey) },
+                            inSelectionMode = inSelectionMode,
+                            isSelected = isSelected,
+                            viewModel = viewModel,
+                            modifier = modifier
                         )
                     }
+                    val deleteActionDescription = stringResource(R.string.del_read_record)
+                    if (inSelectionMode) {
+                        itemContent(Modifier.animateItem())
+                    } else {
+                        SwipeActionContainer(
+                            modifier = Modifier.animateItem(),
+                            startAction = SwipeAction(
+                                icon = Icons.Default.Delete,
+                                background = LegadoTheme.colorScheme.error,
+                                onSwipe = {
+                                    onConfirmDelete(1) { viewModel.deleteSession(session) }
+                                },
+                                contentDescription = deleteActionDescription
+                            )
+                        ) {
+                            itemContent(Modifier)
+                        }
+                    }
                 }
             }
         }
 
         DisplayMode.LATEST -> {
-            items(items = state.latestRecords, key = { "${it.bookName}_${it.bookAuthor}" }) { record ->
-                val deleteActionDescription = stringResource(R.string.del_read_record)
-                val mergeActionDescription = stringResource(R.string.a11y_merge_same_name_read_records)
-                SwipeActionContainer(
-                    modifier = Modifier.animateItem(),
-                    startAction = SwipeAction(
-                        icon = Icons.Default.Delete,
-                        background = LegadoTheme.colorScheme.error,
-                        onSwipe = {
-                            onConfirmDelete { viewModel.deleteReadRecord(record) }
-                        },
-                        contentDescription = deleteActionDescription
-                    ),
-                    endAction = SwipeAction(
-                        icon = Icons.Default.Merge,
-                        background = LegadoTheme.colorScheme.primary,
-                        onSwipe = {
-                            onMergeClick(record)
+            items(items = state.latestRecords, key = { "${it.deviceId}_${it.bookName}_${it.bookAuthor}" }) { record ->
+                val itemKey = record.selectionKey()
+                val isSelected = selectedItemKeys.contains(itemKey)
+                val itemContent: @Composable (Modifier) -> Unit = { modifier ->
+                    LatestReadItem(
+                        record = record,
+                        viewModel = viewModel,
+                        onClick = {
+                            if (inSelectionMode) {
+                                onToggleSelection(itemKey)
+                            } else {
+                                onBookClick(record.bookName, record.bookAuthor)
+                            }
                         },
-                        contentDescription = mergeActionDescription
+                        onLongClick = { onEnterSelection(itemKey) },
+                        inSelectionMode = inSelectionMode,
+                        isSelected = isSelected,
+                        modifier = modifier
                     )
-                ) {
-                        LatestReadItem(
-                            record = record,
-                            viewModel = viewModel,
-                            onClick = { onBookClick(record.bookName, record.bookAuthor) }
+                }
+                val deleteActionDescription = stringResource(R.string.del_read_record)
+                val mergeActionDescription = stringResource(R.string.a11y_merge_same_name_read_records)
+                if (inSelectionMode) {
+                    itemContent(Modifier.animateItem())
+                } else {
+                    SwipeActionContainer(
+                        modifier = Modifier.animateItem(),
+                        startAction = SwipeAction(
+                            icon = Icons.Default.Delete,
+                            background = LegadoTheme.colorScheme.error,
+                            onSwipe = {
+                                onConfirmDelete(1) { viewModel.deleteReadRecord(record) }
+                            },
+                            contentDescription = deleteActionDescription
+                        ),
+                        endAction = SwipeAction(
+                            icon = Icons.Default.Merge,
+                            background = LegadoTheme.colorScheme.primary,
+                            onSwipe = {
+                                onMergeClick(record)
+                            },
+                            contentDescription = mergeActionDescription
                         )
+                    ) {
+                        itemContent(Modifier)
                     }
                 }
             }
+        }
     }
 }
 
@@ -692,6 +964,9 @@ fun LatestReadItem(
     record: ReadRecord,
     viewModel: ReadRecordViewModel,
     onClick: () -> Unit,
+    onLongClick: () -> Unit = {},
+    inSelectionMode: Boolean = false,
+    isSelected: Boolean = false,
     modifier: Modifier = Modifier
 ) {
     var coverPath by remember { mutableStateOf<String?>(null) }
@@ -713,7 +988,11 @@ fun LatestReadItem(
     Row(
         modifier = modifier
             .fillMaxWidth()
-            .clickable(onClick = onClick)
+            .selectionBackground(isSelected)
+            .combinedClickable(
+                onClick = onClick,
+                onLongClick = onLongClick
+            )
             .semantics(mergeDescendants = true) {
                 contentDescription = itemDescription
                 role = Role.Button
@@ -728,7 +1007,9 @@ fun LatestReadItem(
             modifier = Modifier.width(44.dp)
         )
 
-        Spacer(modifier = Modifier.width(16.dp))
+        SelectionCheckmark(inSelectionMode, isSelected)
+
+        Spacer(modifier = Modifier.width(if (inSelectionMode) 8.dp else 16.dp))
 
         Column(modifier = Modifier.weight(1f)) {
             AppText(
@@ -773,7 +1054,11 @@ fun LatestReadItem(
 fun TimelineSessionItem(
     item: TimelineItem,
     viewModel: ReadRecordViewModel,
-    onBookClick: (String, String) -> Unit
+    onBookClick: (String, String) -> Unit,
+    onLongClick: () -> Unit = {},
+    inSelectionMode: Boolean = false,
+    isSelected: Boolean = false,
+    modifier: Modifier = Modifier
 ) {
     val session = item.session
     var coverPath by remember { mutableStateOf<String?>(null) }
@@ -809,9 +1094,13 @@ fun TimelineSessionItem(
     val nodeColor = MaterialTheme.colorScheme.primary
 
     Box(
-        modifier = Modifier
+        modifier = modifier
             .fillMaxWidth()
-            .clickable { onBookClick(session.bookName, session.bookAuthor) }
+            .selectionBackground(isSelected)
+            .combinedClickable(
+                onClick = { onBookClick(session.bookName, session.bookAuthor) },
+                onLongClick = onLongClick
+            )
             .semantics(mergeDescendants = true) {
                 contentDescription = itemDescription
                 role = Role.Button
@@ -860,6 +1149,7 @@ fun TimelineSessionItem(
                     path = coverPath,
                     modifier = Modifier.width(44.dp)
                 )
+                SelectionCheckmark(inSelectionMode, isSelected)
                 Spacer(modifier = Modifier.width(8.dp))
                 Column {
                     AppText(
@@ -894,6 +1184,9 @@ fun ReadRecordItem(
     detail: ReadRecordDetail,
     viewModel: ReadRecordViewModel,
     onClick: () -> Unit,
+    onLongClick: () -> Unit = {},
+    inSelectionMode: Boolean = false,
+    isSelected: Boolean = false,
     modifier: Modifier = Modifier
 ) {
     var coverPath by remember { mutableStateOf<String?>(null) }
@@ -913,7 +1206,11 @@ fun ReadRecordItem(
     Row(
         modifier = modifier
             .fillMaxWidth()
-            .clickable(onClick = onClick)
+            .selectionBackground(isSelected)
+            .combinedClickable(
+                onClick = onClick,
+                onLongClick = onLongClick
+            )
             .semantics(mergeDescendants = true) {
                 contentDescription = itemDescription
                 role = Role.Button
@@ -928,7 +1225,9 @@ fun ReadRecordItem(
             modifier = Modifier.width(44.dp)
         )
 
-        Spacer(modifier = Modifier.width(16.dp))
+        SelectionCheckmark(inSelectionMode, isSelected)
+
+        Spacer(modifier = Modifier.width(if (inSelectionMode) 8.dp else 16.dp))
 
         Column(modifier = Modifier.weight(1f)) {
             AppText(

+ 14 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -49,6 +49,12 @@ class ReadRecordViewModel(
     val displayMode = _displayMode.asStateFlow()
     private val _searchKey = MutableStateFlow("")
     private val _selectedDate = MutableStateFlow<LocalDate?>(null)
+    val readRecordEnabled: StateFlow<Boolean> = repository.readRecordEnabled
+        .stateIn(
+            scope = viewModelScope,
+            started = SharingStarted.WhileSubscribed(5000),
+            initialValue = true
+        )
 
     @OptIn(ExperimentalCoroutinesApi::class)
     private val loadedDataFlow = _searchKey
@@ -142,6 +148,14 @@ class ReadRecordViewModel(
         viewModelScope.launch { repository.deleteReadRecord(record) }
     }
 
+    fun clearReadRecords() {
+        viewModelScope.launch { repository.clearReadRecords() }
+    }
+
+    fun setReadRecordEnabled(enabled: Boolean) {
+        viewModelScope.launch { repository.setReadRecordEnabled(enabled) }
+    }
+
     private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
         if (sessions.isEmpty()) return emptyList()
         val mergedList = mutableListOf<ReadRecordSession>()

+ 16 - 3
app/src/main/res/values-zh-rCN/strings.xml

@@ -2326,13 +2326,26 @@
     <string name="a11y_switch_to_aggregate_view">切换到汇总视图</string>
     <string name="a11y_toggle_read_calendar">显示或隐藏阅读日历</string>
     <string name="no_read_record">没有阅读记录</string>
+    <string name="read_record_options">阅读记录选项</string>
+    <string name="show_read_calendar">显示阅读日历</string>
+    <string name="hide_read_calendar">隐藏阅读日历</string>
+    <string name="selected_count">已选 %1$d 项</string>
+    <string name="long_press_select_mode">点按项目选择,返回退出</string>
     <string name="confirm_delete_read_record">删除阅读记录?</string>
     <string name="delete_read_record_message">确定要删除这条记录吗?</string>
+    <string name="delete_selected_read_records_message">确定要删除选中的 %1$d 条阅读记录吗?</string>
+    <string name="clear_read_records">清空阅读记录</string>
+    <string name="clear_read_records_summary">删除全部阅读记录、每日明细和时间线会话</string>
+    <string name="clear_read_records_message">确定要清空全部阅读记录吗?此操作无法撤销。</string>
+    <string name="enable_read_record">开启阅读记录</string>
+    <string name="enable_read_record_summary">阅读书籍时记录之后的阅读会话</string>
+    <string name="disable_read_record">关闭阅读记录</string>
     <string name="do_not_remind_again">不再提示</string>
     <string name="merge_read_records">合并阅读记录</string>
-    <string name="merge_read_records_message">将以下作者的“%1$s”合并到 %2$s</string>
-    <string name="no_merge_candidates">没有可合并的同名记录</string>
-    <string name="a11y_merge_same_name_read_records">合并同名阅读记录</string>
+    <string name="merge_read_records_message">将选中的阅读记录合并到“%1$s”/%2$s。</string>
+    <string name="merge_read_record_candidate">%1$s / %2$s(%3$s)</string>
+    <string name="no_merge_candidates">没有可合并的其它阅读记录</string>
+    <string name="a11y_merge_same_name_read_records">将阅读记录合并到此书</string>
     <string name="read_record_date_overview">%1$d月%2$d日阅读概览</string>
     <string name="read_record_total_achievement">累计阅读成就</string>
     <string name="chapter_index_format">第 %1$d 章</string>

+ 16 - 3
app/src/main/res/values-zh-rHK/strings.xml

@@ -2285,13 +2285,26 @@
     <string name="a11y_switch_to_aggregate_view">切換到彙總檢視</string>
     <string name="a11y_toggle_read_calendar">顯示或隱藏閱讀日曆</string>
     <string name="no_read_record">沒有閱讀記錄</string>
+    <string name="read_record_options">閱讀記錄選項</string>
+    <string name="show_read_calendar">顯示閱讀日曆</string>
+    <string name="hide_read_calendar">隱藏閱讀日曆</string>
+    <string name="selected_count">已選 %1$d 項</string>
+    <string name="long_press_select_mode">點按項目選擇,返回退出</string>
     <string name="confirm_delete_read_record">刪除閱讀記錄?</string>
     <string name="delete_read_record_message">確定要刪除這條記錄嗎?</string>
+    <string name="delete_selected_read_records_message">確定要刪除所選的 %1$d 條閱讀記錄嗎?</string>
+    <string name="clear_read_records">清空閱讀記錄</string>
+    <string name="clear_read_records_summary">刪除全部閱讀記錄、每日明細和時間線會話</string>
+    <string name="clear_read_records_message">確定要清空全部閱讀記錄嗎?此操作無法復原。</string>
+    <string name="enable_read_record">開啟閱讀記錄</string>
+    <string name="enable_read_record_summary">閱讀書籍時記錄之後的閱讀會話</string>
+    <string name="disable_read_record">關閉閱讀記錄</string>
     <string name="do_not_remind_again">不再提示</string>
     <string name="merge_read_records">合併閱讀記錄</string>
-    <string name="merge_read_records_message">將以下作者的「%1$s」合併到 %2$s</string>
-    <string name="no_merge_candidates">沒有可合併的同名記錄</string>
-    <string name="a11y_merge_same_name_read_records">合併同名閱讀記錄</string>
+    <string name="merge_read_records_message">將選取的閱讀記錄合併到「%1$s」/%2$s。</string>
+    <string name="merge_read_record_candidate">%1$s / %2$s(%3$s)</string>
+    <string name="no_merge_candidates">沒有可合併的其他閱讀記錄</string>
+    <string name="a11y_merge_same_name_read_records">將閱讀記錄合併到此書</string>
     <string name="read_record_date_overview">%1$d月%2$d日閱讀概覽</string>
     <string name="read_record_total_achievement">累計閱讀成就</string>
     <string name="chapter_index_format">第 %1$d 章</string>

+ 16 - 3
app/src/main/res/values-zh-rTW/strings.xml

@@ -2290,13 +2290,26 @@
     <string name="a11y_switch_to_aggregate_view">切換到彙總檢視</string>
     <string name="a11y_toggle_read_calendar">顯示或隱藏閱讀日曆</string>
     <string name="no_read_record">沒有閱讀記錄</string>
+    <string name="read_record_options">閱讀記錄選項</string>
+    <string name="show_read_calendar">顯示閱讀日曆</string>
+    <string name="hide_read_calendar">隱藏閱讀日曆</string>
+    <string name="selected_count">已選 %1$d 項</string>
+    <string name="long_press_select_mode">點按項目選擇,返回退出</string>
     <string name="confirm_delete_read_record">刪除閱讀記錄?</string>
     <string name="delete_read_record_message">確定要刪除這條記錄嗎?</string>
+    <string name="delete_selected_read_records_message">確定要刪除所選的 %1$d 筆閱讀記錄嗎?</string>
+    <string name="clear_read_records">清空閱讀記錄</string>
+    <string name="clear_read_records_summary">刪除全部閱讀記錄、每日明細和時間線會話</string>
+    <string name="clear_read_records_message">確定要清空全部閱讀記錄嗎?此操作無法復原。</string>
+    <string name="enable_read_record">開啟閱讀記錄</string>
+    <string name="enable_read_record_summary">閱讀書籍時記錄之後的閱讀會話</string>
+    <string name="disable_read_record">關閉閱讀記錄</string>
     <string name="do_not_remind_again">不再提示</string>
     <string name="merge_read_records">合併閱讀記錄</string>
-    <string name="merge_read_records_message">將以下作者的「%1$s」合併到 %2$s</string>
-    <string name="no_merge_candidates">沒有可合併的同名記錄</string>
-    <string name="a11y_merge_same_name_read_records">合併同名閱讀記錄</string>
+    <string name="merge_read_records_message">將選取的閱讀記錄合併到「%1$s」/%2$s。</string>
+    <string name="merge_read_record_candidate">%1$s / %2$s(%3$s)</string>
+    <string name="no_merge_candidates">沒有可合併的其他閱讀記錄</string>
+    <string name="a11y_merge_same_name_read_records">將閱讀記錄合併到此書</string>
     <string name="read_record_date_overview">%1$d月%2$d日閱讀概覽</string>
     <string name="read_record_total_achievement">累計閱讀成就</string>
     <string name="chapter_index_format">第 %1$d 章</string>

+ 16 - 3
app/src/main/res/values/strings.xml

@@ -2331,13 +2331,26 @@
     <string name="a11y_switch_to_aggregate_view">Switch to summary view</string>
     <string name="a11y_toggle_read_calendar">Show or hide reading calendar</string>
     <string name="no_read_record">No reading records</string>
+    <string name="read_record_options">Reading record options</string>
+    <string name="show_read_calendar">Show reading calendar</string>
+    <string name="hide_read_calendar">Hide reading calendar</string>
+    <string name="selected_count">%1$d selected</string>
+    <string name="long_press_select_mode">Tap items to select, back to exit</string>
     <string name="confirm_delete_read_record">Delete reading record?</string>
     <string name="delete_read_record_message">Delete this reading record?</string>
+    <string name="delete_selected_read_records_message">Delete %1$d selected reading records?</string>
+    <string name="clear_read_records">Clear reading records</string>
+    <string name="clear_read_records_summary">Delete all reading records, daily details, and timeline sessions</string>
+    <string name="clear_read_records_message">Clear all reading records? This cannot be undone.</string>
+    <string name="enable_read_record">Enable reading records</string>
+    <string name="enable_read_record_summary">Record future reading sessions when you read books</string>
+    <string name="disable_read_record">Disable reading records</string>
     <string name="do_not_remind_again">Do not remind again</string>
     <string name="merge_read_records">Merge reading records</string>
-    <string name="merge_read_records_message">Merge the following authors of “%1$s” into %2$s</string>
-    <string name="no_merge_candidates">No same-title records can be merged</string>
-    <string name="a11y_merge_same_name_read_records">Merge same-title reading records</string>
+    <string name="merge_read_records_message">Merge selected reading records into “%1$s” by %2$s.</string>
+    <string name="merge_read_record_candidate">%1$s / %2$s (%3$s)</string>
+    <string name="no_merge_candidates">No other reading records can be merged</string>
+    <string name="a11y_merge_same_name_read_records">Merge reading records into this book</string>
     <string name="read_record_date_overview">%1$d/%2$d reading overview</string>
     <string name="read_record_total_achievement">Total reading achievement</string>
     <string name="chapter_index_format">Chapter %1$d</string>