[新增] 补全阅读记录的备份删除等逻辑,修复了其他界面的一些问题

This commit is contained in:
HapeLee
2026-01-30 01:45:52 +08:00
parent 7638c1b450
commit df5387fb3c
14 changed files with 461 additions and 72 deletions
@@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -67,7 +66,7 @@ abstract class BaseRuleViewModel<T : SelectableItem<ID>, Entity, ID, S : RuleAct
private val itemsFlow: Flow<List<T>> by lazy { private val itemsFlow: Flow<List<T>> by lazy {
combine( combine(
rawDataFlow, rawDataFlow,
_searchKey.debounce(300L), _searchKey,
_localItems _localItems
) { data, key, local -> ) { data, key, local ->
if (local != null && key.isEmpty()) { if (local != null && key.isEmpty()) {
@@ -18,6 +18,12 @@ interface ReadRecordDao {
@get:Query("select * from readRecord") @get:Query("select * from readRecord")
val all: List<ReadRecord> val all: List<ReadRecord>
@get:Query("select * from readRecordDetail")
val allDetail: List<ReadRecordDetail>
@get:Query("select * from readRecordSession")
val allSession: List<ReadRecordSession>
@get:Query( @get:Query(
""" """
select bookName, sum(readTime) as readTime, max(lastRead) as lastRead select bookName, sum(readTime) as readTime, max(lastRead) as lastRead
@@ -127,7 +133,7 @@ interface ReadRecordDao {
SELECT * FROM readRecordSession SELECT * FROM readRecordSession
WHERE deviceId = :deviceId WHERE deviceId = :deviceId
AND bookName = :bookName AND bookName = :bookName
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch')) = :date AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
ORDER BY startTime ASC ORDER BY startTime ASC
""") """)
suspend fun getSessionsByBookAndDate(deviceId: String, bookName: String, date: String): List<ReadRecordSession> suspend fun getSessionsByBookAndDate(deviceId: String, bookName: String, date: String): List<ReadRecordSession>
@@ -159,6 +165,31 @@ interface ReadRecordDao {
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC") @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC")
fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>> fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun getSessionsByBook(deviceId: String, bookName: String): List<ReadRecordSession>
@Delete @Delete
suspend fun deleteDetail(detail: ReadRecordDetail) suspend fun deleteDetail(detail: ReadRecordDetail)
@Query(
"""
DELETE FROM readRecordSession
WHERE deviceId = :deviceId
AND bookName = :bookName
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
"""
)
suspend fun deleteSessionsByBookAndDate(deviceId: String, bookName: String, date: String)
@Delete
suspend fun deleteSession(session: ReadRecordSession)
@Delete
suspend fun deleteReadRecord(record: ReadRecord)
@Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun deleteDetailsByBook(deviceId: String, bookName: String)
@Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun deleteSessionsByBook(deviceId: String, bookName: String)
} }
@@ -1,5 +1,6 @@
package io.legado.app.data.repository package io.legado.app.data.repository
import androidx.room.Transaction
import cn.hutool.core.date.DatePattern import cn.hutool.core.date.DatePattern
import cn.hutool.core.date.DateUtil import cn.hutool.core.date.DateUtil
import io.legado.app.data.dao.ReadRecordDao import io.legado.app.data.dao.ReadRecordDao
@@ -114,9 +115,59 @@ class ReadRecordRepository(
suspend fun deleteDetail(detail: ReadRecordDetail) { suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail) dao.deleteDetail(detail)
dao.deleteSessionsByBookAndDate(detail.deviceId, detail.bookName, detail.date)
updateReadRecordTotal(detail.deviceId, detail.bookName)
} }
suspend fun clearAll() { @Transaction
dao.clear() suspend fun deleteSession(session: ReadRecordSession) {
dao.deleteSession(session)
val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
val remainingSessions =
dao.getSessionsByBookAndDate(session.deviceId, session.bookName, dateString)
if (remainingSessions.isEmpty()) {
val detail = dao.getDetail(session.deviceId, session.bookName, dateString)
detail?.let { dao.deleteDetail(it) }
} else {
val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
val totalWords = remainingSessions.sumOf { it.words }
val firstRead = remainingSessions.minOf { it.startTime }
val lastRead = remainingSessions.maxOf { it.endTime }
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
existingDetail?.copy(
readTime = totalTime,
readWords = totalWords,
firstReadTime = firstRead,
lastReadTime = lastRead
)?.let { dao.insertDetail(it) }
}
updateReadRecordTotal(session.deviceId, session.bookName)
} }
private suspend fun updateReadRecordTotal(deviceId: String, bookName: String) {
val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName)
if (allRemainingSessions.isEmpty()) {
dao.getReadRecord(deviceId, bookName)?.let { dao.deleteReadRecord(it) }
} else {
val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
val lastRead = allRemainingSessions.maxOf { it.endTime }
dao.getReadRecord(deviceId, bookName)?.copy(
readTime = totalTime,
lastRead = lastRead
)?.let { dao.update(it) }
}
}
suspend fun deleteReadRecord(record: ReadRecord) {
dao.deleteReadRecord(record)
dao.deleteDetailsByBook(record.deviceId, record.bookName)
dao.deleteSessionsByBook(record.deviceId, record.bookName)
}
} }
@@ -2,6 +2,8 @@ package io.legado.app.help.storage
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
@@ -15,8 +17,20 @@ import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.config.ThemeConfig import io.legado.app.help.config.ThemeConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
import io.legado.app.utils.* import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.LogUtils
import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFolderIfNotExist
import io.legado.app.utils.defaultSharedPreferences
import io.legado.app.utils.externalFiles
import io.legado.app.utils.getFile
import io.legado.app.utils.getSharedPreferences
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.normalizeFileName
import io.legado.app.utils.openOutputStream
import io.legado.app.utils.outputStream
import io.legado.app.utils.writeToOutputStream
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
@@ -28,11 +42,9 @@ import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
import androidx.core.content.edit
import androidx.core.net.toUri
/** /**
* 备份 * 备份
@@ -58,6 +70,8 @@ object Backup {
"rssStar.json", "rssStar.json",
"replaceRule.json", "replaceRule.json",
"readRecord.json", "readRecord.json",
"readRecordDetail.json",
"readRecordSession.json",
"searchHistory.json", "searchHistory.json",
"sourceSub.json", "sourceSub.json",
"txtTocRule.json", "txtTocRule.json",
@@ -130,6 +144,8 @@ object Backup {
writeListToJson(appDb.rssStarDao.all, "rssStar.json", backupPath) writeListToJson(appDb.rssStarDao.all, "rssStar.json", backupPath)
writeListToJson(appDb.replaceRuleDao.all, "replaceRule.json", backupPath) writeListToJson(appDb.replaceRuleDao.all, "replaceRule.json", backupPath)
writeListToJson(appDb.readRecordDao.all, "readRecord.json", backupPath) writeListToJson(appDb.readRecordDao.all, "readRecord.json", backupPath)
writeListToJson(appDb.readRecordDao.allDetail, "readRecordDetail.json", backupPath)
writeListToJson(appDb.readRecordDao.allSession, "readRecordSession.json", backupPath)
writeListToJson(appDb.searchKeywordDao.all, "searchHistory.json", backupPath) writeListToJson(appDb.searchKeywordDao.all, "searchHistory.json", backupPath)
writeListToJson(appDb.ruleSubDao.all, "sourceSub.json", backupPath) writeListToJson(appDb.ruleSubDao.all, "sourceSub.json", backupPath)
writeListToJson(appDb.txtTocRuleDao.all, "txtTocRule.json", backupPath) writeListToJson(appDb.txtTocRuleDao.all, "txtTocRule.json", backupPath)
@@ -18,7 +18,6 @@ import io.legado.app.data.entities.Bookmark
import io.legado.app.data.entities.DictRule import io.legado.app.data.entities.DictRule
import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.KeyboardAssist import io.legado.app.data.entities.KeyboardAssist
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssSource
import io.legado.app.data.entities.RssStar import io.legado.app.data.entities.RssStar
@@ -26,6 +25,9 @@ import io.legado.app.data.entities.RuleSub
import io.legado.app.data.entities.SearchKeyword import io.legado.app.data.entities.SearchKeyword
import io.legado.app.data.entities.Server import io.legado.app.data.entities.Server
import io.legado.app.data.entities.TxtTocRule import io.legado.app.data.entities.TxtTocRule
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.help.DirectLinkUpload import io.legado.app.help.DirectLinkUpload
import io.legado.app.help.LauncherIconHelp import io.legado.app.help.LauncherIconHelp
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
@@ -182,6 +184,16 @@ object Restore {
} }
} }
} }
fileToListT<ReadRecordDetail>(path, "readRecordDetail.json")?.let {
it.forEach { detail ->
appDb.readRecordDao.insertDetail(detail)
}
}
fileToListT<ReadRecordSession>(path, "readRecordSession.json")?.let {
it.forEach { session ->
appDb.readRecordDao.insertSession(session)
}
}
File(path, "servers.json").takeIf { File(path, "servers.json").takeIf {
it.exists() it.exists()
}?.runCatching { }?.runCatching {
@@ -2,6 +2,7 @@ package io.legado.app.ui.book.readRecord
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -22,12 +23,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Timeline import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -36,6 +39,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -47,6 +51,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -66,6 +71,9 @@ import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.SectionHeader import io.legado.app.ui.widget.components.SectionHeader
import io.legado.app.ui.widget.components.button.AlertButton
import io.legado.app.ui.widget.components.swipe.SwipeAction
import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
import io.legado.app.utils.StringUtils.formatFriendlyDate import io.legado.app.utils.StringUtils.formatFriendlyDate
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate import java.time.LocalDate
@@ -86,6 +94,16 @@ fun ReadRecordScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
var skipDeleteConfirm by remember { mutableStateOf(false) }
var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
val onConfirmDelete: (() -> Unit) -> Unit = { action ->
if (skipDeleteConfirm) {
action()
} else {
pendingDeleteAction = action
}
}
LaunchedEffect(state.searchKey) { LaunchedEffect(state.searchKey) {
if (state.searchKey.isNullOrBlank()) { if (state.searchKey.isNullOrBlank()) {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
@@ -205,14 +223,71 @@ fun ReadRecordScreen(
item(key = "summary_card") { item(key = "summary_card") {
SummarySection(state, viewModel) SummarySection(state, viewModel)
} }
renderListByMode(
renderListByMode(displayMode, state, viewModel, onBookClick) displayMode = displayMode,
state = state,
viewModel = viewModel,
onBookClick = onBookClick,
onConfirmDelete = onConfirmDelete
)
} }
} }
} }
} }
} }
} }
if (pendingDeleteAction != null) {
var skipDeleteConfirmTemp by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = {
pendingDeleteAction = null
},
title = { Text("确认删除") },
text = {
Column {
Text("确定要删除这条记录吗?")
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.background(
color = if (skipDeleteConfirmTemp) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
shape = MaterialTheme.shapes.small
)
.clickable { skipDeleteConfirmTemp = !skipDeleteConfirmTemp }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = skipDeleteConfirmTemp, onCheckedChange = null)
Text(
text = "不再提示",
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
},
confirmButton = {
AlertButton(
onClick = {
pendingDeleteAction?.invoke()
pendingDeleteAction = null
skipDeleteConfirm = skipDeleteConfirmTemp
},
text = "删除"
)
},
dismissButton = {
TextButton(onClick = {
pendingDeleteAction = null
}) {
Text("取消")
}
}
)
}
} }
@Composable @Composable
@@ -260,7 +335,8 @@ fun LazyListScope.renderListByMode(
displayMode: DisplayMode, displayMode: DisplayMode,
state: ReadRecordUiState, state: ReadRecordUiState,
viewModel: ReadRecordViewModel, viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit onBookClick: (String) -> Unit,
onConfirmDelete: (() -> Unit) -> Unit
) { ) {
when (displayMode) { when (displayMode) {
DisplayMode.AGGREGATE -> { DisplayMode.AGGREGATE -> {
@@ -268,37 +344,68 @@ fun LazyListScope.renderListByMode(
stickyHeader(key = "header_$date") { stickyHeader(key = "header_$date") {
DateHeader(date, details.sumOf { it.readTime }) DateHeader(date, details.sumOf { it.readTime })
} }
items(items = details, key = { "${it.bookName}_${it.readTime}_$date" }) { detail -> items(items = details, key = { "${it.bookName}_${it.date}" }) { detail ->
ReadRecordItem( SwipeActionContainer(
detail = detail, modifier = Modifier.animateItem(),
viewModel = viewModel, startAction = SwipeAction(
onClick = { onBookClick(detail.bookName) }, icon = Icons.Default.Delete,
onDelete = { viewModel.deleteDetail(detail) }, background = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.animateItem() onSwipe = {
) onConfirmDelete { viewModel.deleteDetail(detail) }
}
)
) {
ReadRecordItem(
detail,
viewModel,
onClick = { onBookClick(detail.bookName) })
}
} }
} }
} }
DisplayMode.TIMELINE -> { DisplayMode.TIMELINE -> {
state.timelineRecords.forEach { (date, sessions) -> state.timelineRecords.forEach { (date, sessions) ->
stickyHeader(key = "timeline_header_$date") { DateHeader(date) } stickyHeader(key = "timeline_header_$date") { DateHeader(date) }
items(items = sessions, key = { it.id }) { session -> items(items = sessions, key = { it.id }) { session ->
TimelineSessionItem( SwipeActionContainer(
item = TimelineItem(session, true), modifier = Modifier.animateItem(),
onBookClick = onBookClick, startAction = SwipeAction(
viewModel = viewModel icon = Icons.Default.Delete,
) background = MaterialTheme.colorScheme.onErrorContainer,
onSwipe = {
onConfirmDelete { viewModel.deleteSession(session) }
}
)
) {
TimelineSessionItem(
item = TimelineItem(session, true),
onBookClick = onBookClick,
viewModel = viewModel
)
}
} }
} }
} }
DisplayMode.LATEST -> { DisplayMode.LATEST -> {
items(items = state.latestRecords, key = { it.bookName }) { record -> items(items = state.latestRecords, key = { it.bookName }) { record ->
LatestReadItem( SwipeActionContainer(
record = record, modifier = Modifier.animateItem(),
viewModel = viewModel, startAction = SwipeAction(
onClick = { onBookClick(record.bookName) }, icon = Icons.Default.Delete,
modifier = Modifier.animateItem() background = MaterialTheme.colorScheme.error,
) onSwipe = {
onConfirmDelete { viewModel.deleteReadRecord(record) }
}
)
) {
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName) }
)
}
} }
} }
} }
@@ -445,7 +552,6 @@ fun ReadRecordItem(
detail: ReadRecordDetail, detail: ReadRecordDetail,
viewModel: ReadRecordViewModel, viewModel: ReadRecordViewModel,
onClick: () -> Unit, onClick: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var coverPath by remember { mutableStateOf<String?>(null) } var coverPath by remember { mutableStateOf<String?>(null) }
@@ -478,10 +584,6 @@ fun ReadRecordItem(
color = Color.Gray color = Color.Gray
) )
} }
IconButton(onClick = onDelete) {
Icon(Icons.Default.Close, "Delete", tint = Color.LightGray)
}
} }
} }
@@ -31,7 +31,7 @@ data class ReadRecordUiState(
//最后阅读列表 //最后阅读列表
val latestRecords: List<ReadRecord> = emptyList(), val latestRecords: List<ReadRecord> = emptyList(),
val selectedDate: LocalDate? = null, val selectedDate: LocalDate? = null,
val searchKey: String? = null, val searchKey: String? = null
) )
enum class DisplayMode { enum class DisplayMode {
@@ -48,11 +48,9 @@ class ReadRecordViewModel(
private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE) private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
val displayMode = _displayMode.asStateFlow() val displayMode = _displayMode.asStateFlow()
private val _searchKey = MutableStateFlow("") private val _searchKey = MutableStateFlow("")
private val _selectedDate = MutableStateFlow<LocalDate?>(null) private val _selectedDate = MutableStateFlow<LocalDate?>(null)
// 数据源:直接从 Repository 获取 Flow
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private val loadedDataFlow = _searchKey private val loadedDataFlow = _searchKey
.flatMapLatest { query -> .flatMapLatest { query ->
@@ -71,11 +69,11 @@ class ReadRecordViewModel(
_selectedDate, _selectedDate,
_searchKey _searchKey
) { data, selectedDate, searchKey -> ) { data, selectedDate, searchKey ->
val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
val filteredDetails = if (dateStr != null) {
data.details.filter { it.date == dateStr } val filteredDetails = data.details.filter { detail ->
} else data.details dateStr == null || detail.date == dateStr
}
val timelineMap = data.sessions val timelineMap = data.sessions
.asSequence() .asSequence()
@@ -123,6 +121,14 @@ class ReadRecordViewModel(
viewModelScope.launch { repository.deleteDetail(detail) } viewModelScope.launch { repository.deleteDetail(detail) }
} }
fun deleteSession(session: ReadRecordSession) {
viewModelScope.launch { repository.deleteSession(session) }
}
fun deleteReadRecord(record: ReadRecord) {
viewModelScope.launch { repository.deleteReadRecord(record) }
}
private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> { private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
if (sessions.isEmpty()) return emptyList() if (sessions.isEmpty()) return emptyList()
val mergedList = mutableListOf<ReadRecordSession>() val mergedList = mutableListOf<ReadRecordSession>()
@@ -250,7 +250,7 @@ fun TxtRuleScreen(
} }
RuleListScaffold( RuleListScaffold(
title = "字典规则", title = "目录规则",
state = uiState, state = uiState,
onBackClick = { onBackClick() }, onBackClick = { onBackClick() },
onSearchToggle = { active -> onSearchToggle = { active ->
@@ -95,7 +95,7 @@ fun ReplaceRuleScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val rules = uiState.items val rules = uiState.items
val groups = uiState.groups val groups by viewModel.allGroups.collectAsStateWithLifecycle()
val selectedIds = uiState.selectedIds val selectedIds = uiState.selectedIds
val inSelectionMode = selectedIds.isNotEmpty() val inSelectionMode = selectedIds.isNotEmpty()
@@ -116,10 +116,10 @@ fun ReplaceRuleScreen(
var selectedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabItems = remember(groups) { listOf("全部") + groups } val tabItems = remember(groups) { listOf("全部") + groups }
val filteredRules = remember(uiState.items, selectedTabIndex, tabItems) { val filteredRules = remember(uiState.items, selectedTabIndex, tabItems) {
if (selectedTabIndex == 0) { val targetGroup = tabItems.getOrNull(selectedTabIndex)
if (targetGroup == null || selectedTabIndex == 0) {
uiState.items uiState.items
} else { } else {
val targetGroup = tabItems[selectedTabIndex]
uiState.items.filter { it.group == targetGroup } uiState.items.filter { it.group == targetGroup }
} }
} }
@@ -221,8 +221,10 @@ fun ReplaceRuleScreen(
} }
LaunchedEffect(groups) { LaunchedEffect(groups) {
if (selectedTabIndex > groups.size) { val maxIndex = groups.size
if (selectedTabIndex > maxIndex) {
selectedTabIndex = 0 selectedTabIndex = 0
viewModel.setGroup("全部")
} }
} }
@@ -339,7 +341,10 @@ fun ReplaceRuleScreen(
tabItems.forEachIndexed { index, title -> tabItems.forEachIndexed { index, title ->
Tab( Tab(
selected = selectedTabIndex == index, selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index }, onClick = {
selectedTabIndex = index
viewModel.setGroup(title)
},
text = { text = {
Text( Text(
text = title, text = title,
@@ -3,6 +3,7 @@ package io.legado.app.ui.replace
import android.app.Application import android.app.Application
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseRuleEvent
import io.legado.app.base.BaseRuleViewModel import io.legado.app.base.BaseRuleViewModel
import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
@@ -25,10 +26,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -67,6 +71,21 @@ class ReplaceRuleViewModel(
private val _group = MutableStateFlow<String?>(null) private val _group = MutableStateFlow<String?>(null)
val group = _group.asStateFlow() val group = _group.asStateFlow()
val allGroups: StateFlow<List<String>> = repository.flowGroups()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun setGroup(groupName: String?) {
_group.value = if (groupName == "全部" || groupName.isNullOrBlank()) {
null
} else {
groupName
}
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override val rawDataFlow: Flow<List<ReplaceRule>> = override val rawDataFlow: Flow<List<ReplaceRule>> =
combine(_group, _sortMode) { group, sortMode -> combine(_group, _sortMode) { group, sortMode ->
@@ -156,9 +175,14 @@ class ReplaceRuleViewModel(
} }
rule rule
} }
repository.update(*rulesToSave.toTypedArray()) if (rulesToSave.isNotEmpty()) {
withContext(Dispatchers.Main) { rulesToSave.forEach { rule ->
_importState.value = BaseImportUiState.Idle repository.insert(rule)
}
withContext(Dispatchers.Main) {
_importState.value = BaseImportUiState.Idle
_eventChannel.send(BaseRuleEvent.ShowSnackbar("成功导入 ${rulesToSave.size} 条规则"))
}
} }
} }
} }
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.HelpOutline import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
@@ -35,7 +34,6 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -49,7 +47,6 @@ import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -73,6 +70,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.button.AlertButton
import io.legado.app.ui.widget.components.button.SmallTopBarButton
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
@@ -114,12 +113,7 @@ fun ReplaceEditScreen(
GlassMediumFlexibleTopAppBar( GlassMediumFlexibleTopAppBar(
title = { Text(if (state.id > 0) "编辑替换规则" else "新增替换规则") }, title = { Text(if (state.id > 0) "编辑替换规则" else "新增替换规则") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { SmallTopBarButton(onClick = onBack)
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
"Back"
)
}
}, },
actions = { actions = {
@@ -437,18 +431,13 @@ fun ManageGroupDialog(
} }
}, },
confirmButton = { confirmButton = {
OutlinedButton( AlertButton(
onClick = { onClick = {
val toDelete = selected.filter { it.value }.keys.toList() val toDelete = selected.filter { it.value }.keys.toList()
onDelete(toDelete) onDelete(toDelete)
}, },
colors = ButtonDefaults.outlinedButtonColors( text = "删除选中"
contentColor = MaterialTheme.colorScheme.error, )
containerColor = Color.Transparent,
),
) {
Text("删除选中")
}
}, },
dismissButton = { TextButton(onClick = onDismiss) { Text("关闭") } } dismissButton = { TextButton(onClick = onDismiss) { Text("关闭") } }
) )
@@ -0,0 +1,29 @@
package io.legado.app.ui.widget.components.button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@Composable
fun AlertButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "删除",
enabled: Boolean = true
) {
OutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
containerColor = Color.Transparent,
)
) {
Text(text = text)
}
}
@@ -0,0 +1,11 @@
package io.legado.app.ui.widget.components.swipe
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
data class SwipeAction(
val icon: ImageVector,
val background: Color,
val onSwipe: () -> Unit,
val hapticFeedback: Boolean = true
)
@@ -0,0 +1,114 @@
package io.legado.app.ui.widget.components.swipe
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeActionContainer(
modifier: Modifier = Modifier,
startAction: SwipeAction? = null,
content: @Composable () -> Unit
) {
val haptic = LocalHapticFeedback.current
val dismissState = rememberSwipeToDismissBoxState(
positionalThreshold = { totalDistance ->
totalDistance * 0.7f
}
)
val isThresholdReached =
dismissState.progress > 0.5f && dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd
LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) {
if (startAction?.hapticFeedback == true) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
}
LaunchedEffect(dismissState.settledValue) {
if (dismissState.settledValue == SwipeToDismissBoxValue.StartToEnd) {
startAction?.onSwipe?.invoke()
dismissState.reset()
}
}
SwipeToDismissBox(
state = dismissState,
modifier = modifier,
enableDismissFromStartToEnd = startAction != null,
enableDismissFromEndToStart = false,
backgroundContent = {
val direction = dismissState.dismissDirection
val progress = dismissState.progress
if (direction == SwipeToDismissBoxValue.StartToEnd) {
val backgroundColor by animateColorAsState(
targetValue = if (isThresholdReached) startAction!!.background
else MaterialTheme.colorScheme.surfaceVariant,
label = "bgColor"
)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.padding(horizontal = 24.dp),
contentAlignment = Alignment.CenterStart
) {
val iconScale by animateFloatAsState(
targetValue = if (isThresholdReached) 1.3f else progress.coerceIn(0.5f, 1f),
label = "iconScale"
)
Icon(
imageVector = startAction!!.icon,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
},
tint = if (isThresholdReached)
contentColorFor(startAction.background)
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
content = {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
shape = RectangleShape
) {
content()
}
}
)
}