[新增] 补全阅读记录的备份删除等逻辑,修复了其他界面的一些问题
This commit is contained in:
@@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
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 {
|
||||
combine(
|
||||
rawDataFlow,
|
||||
_searchKey.debounce(300L),
|
||||
_searchKey,
|
||||
_localItems
|
||||
) { data, key, local ->
|
||||
if (local != null && key.isEmpty()) {
|
||||
|
||||
@@ -18,6 +18,12 @@ interface ReadRecordDao {
|
||||
@get:Query("select * from 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(
|
||||
"""
|
||||
select bookName, sum(readTime) as readTime, max(lastRead) as lastRead
|
||||
@@ -127,7 +133,7 @@ interface ReadRecordDao {
|
||||
SELECT * FROM readRecordSession
|
||||
WHERE deviceId = :deviceId
|
||||
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
|
||||
""")
|
||||
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")
|
||||
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
|
||||
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
|
||||
|
||||
import androidx.room.Transaction
|
||||
import cn.hutool.core.date.DatePattern
|
||||
import cn.hutool.core.date.DateUtil
|
||||
import io.legado.app.data.dao.ReadRecordDao
|
||||
@@ -114,9 +115,59 @@ class ReadRecordRepository(
|
||||
|
||||
suspend fun deleteDetail(detail: ReadRecordDetail) {
|
||||
dao.deleteDetail(detail)
|
||||
dao.deleteSessionsByBookAndDate(detail.deviceId, detail.bookName, detail.date)
|
||||
updateReadRecordTotal(detail.deviceId, detail.bookName)
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dao.clear()
|
||||
@Transaction
|
||||
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.net.Uri
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.legado.app.constant.AppLog
|
||||
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.coroutine.Coroutine
|
||||
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.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.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
@@ -28,11 +42,9 @@ import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
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",
|
||||
"replaceRule.json",
|
||||
"readRecord.json",
|
||||
"readRecordDetail.json",
|
||||
"readRecordSession.json",
|
||||
"searchHistory.json",
|
||||
"sourceSub.json",
|
||||
"txtTocRule.json",
|
||||
@@ -130,6 +144,8 @@ object Backup {
|
||||
writeListToJson(appDb.rssStarDao.all, "rssStar.json", backupPath)
|
||||
writeListToJson(appDb.replaceRuleDao.all, "replaceRule.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.ruleSubDao.all, "sourceSub.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.HttpTTS
|
||||
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.RssSource
|
||||
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.Server
|
||||
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.LauncherIconHelp
|
||||
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 {
|
||||
it.exists()
|
||||
}?.runCatching {
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.legado.app.ui.book.readRecord
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.List
|
||||
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.Search
|
||||
import androidx.compose.material.icons.filled.Timeline
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -36,6 +39,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -47,6 +51,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.rotate
|
||||
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.SearchBarSection
|
||||
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 org.koin.androidx.compose.koinViewModel
|
||||
import java.time.LocalDate
|
||||
@@ -86,6 +94,16 @@ fun ReadRecordScreen(
|
||||
val listState = rememberLazyListState()
|
||||
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) {
|
||||
if (state.searchKey.isNullOrBlank()) {
|
||||
listState.animateScrollToItem(0)
|
||||
@@ -205,14 +223,71 @@ fun ReadRecordScreen(
|
||||
item(key = "summary_card") {
|
||||
SummarySection(state, viewModel)
|
||||
}
|
||||
|
||||
renderListByMode(displayMode, state, viewModel, onBookClick)
|
||||
renderListByMode(
|
||||
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
|
||||
@@ -260,7 +335,8 @@ fun LazyListScope.renderListByMode(
|
||||
displayMode: DisplayMode,
|
||||
state: ReadRecordUiState,
|
||||
viewModel: ReadRecordViewModel,
|
||||
onBookClick: (String) -> Unit
|
||||
onBookClick: (String) -> Unit,
|
||||
onConfirmDelete: (() -> Unit) -> Unit
|
||||
) {
|
||||
when (displayMode) {
|
||||
DisplayMode.AGGREGATE -> {
|
||||
@@ -268,37 +344,68 @@ fun LazyListScope.renderListByMode(
|
||||
stickyHeader(key = "header_$date") {
|
||||
DateHeader(date, details.sumOf { it.readTime })
|
||||
}
|
||||
items(items = details, key = { "${it.bookName}_${it.readTime}_$date" }) { detail ->
|
||||
ReadRecordItem(
|
||||
detail = detail,
|
||||
viewModel = viewModel,
|
||||
onClick = { onBookClick(detail.bookName) },
|
||||
onDelete = { viewModel.deleteDetail(detail) },
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
items(items = details, key = { "${it.bookName}_${it.date}" }) { detail ->
|
||||
SwipeActionContainer(
|
||||
modifier = Modifier.animateItem(),
|
||||
startAction = SwipeAction(
|
||||
icon = Icons.Default.Delete,
|
||||
background = MaterialTheme.colorScheme.errorContainer,
|
||||
onSwipe = {
|
||||
onConfirmDelete { viewModel.deleteDetail(detail) }
|
||||
}
|
||||
)
|
||||
) {
|
||||
ReadRecordItem(
|
||||
detail,
|
||||
viewModel,
|
||||
onClick = { onBookClick(detail.bookName) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisplayMode.TIMELINE -> {
|
||||
state.timelineRecords.forEach { (date, sessions) ->
|
||||
stickyHeader(key = "timeline_header_$date") { DateHeader(date) }
|
||||
items(items = sessions, key = { it.id }) { session ->
|
||||
TimelineSessionItem(
|
||||
item = TimelineItem(session, true),
|
||||
onBookClick = onBookClick,
|
||||
viewModel = viewModel
|
||||
)
|
||||
SwipeActionContainer(
|
||||
modifier = Modifier.animateItem(),
|
||||
startAction = SwipeAction(
|
||||
icon = Icons.Default.Delete,
|
||||
background = MaterialTheme.colorScheme.onErrorContainer,
|
||||
onSwipe = {
|
||||
onConfirmDelete { viewModel.deleteSession(session) }
|
||||
}
|
||||
)
|
||||
) {
|
||||
TimelineSessionItem(
|
||||
item = TimelineItem(session, true),
|
||||
onBookClick = onBookClick,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisplayMode.LATEST -> {
|
||||
items(items = state.latestRecords, key = { it.bookName }) { record ->
|
||||
LatestReadItem(
|
||||
record = record,
|
||||
viewModel = viewModel,
|
||||
onClick = { onBookClick(record.bookName) },
|
||||
modifier = Modifier.animateItem()
|
||||
)
|
||||
SwipeActionContainer(
|
||||
modifier = Modifier.animateItem(),
|
||||
startAction = SwipeAction(
|
||||
icon = Icons.Default.Delete,
|
||||
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,
|
||||
viewModel: ReadRecordViewModel,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var coverPath by remember { mutableStateOf<String?>(null) }
|
||||
@@ -478,10 +584,6 @@ fun ReadRecordItem(
|
||||
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 selectedDate: LocalDate? = null,
|
||||
val searchKey: String? = null,
|
||||
val searchKey: String? = null
|
||||
)
|
||||
|
||||
enum class DisplayMode {
|
||||
@@ -48,11 +48,9 @@ class ReadRecordViewModel(
|
||||
|
||||
private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
|
||||
val displayMode = _displayMode.asStateFlow()
|
||||
|
||||
private val _searchKey = MutableStateFlow("")
|
||||
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
|
||||
|
||||
// 数据源:直接从 Repository 获取 Flow
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val loadedDataFlow = _searchKey
|
||||
.flatMapLatest { query ->
|
||||
@@ -71,11 +69,11 @@ class ReadRecordViewModel(
|
||||
_selectedDate,
|
||||
_searchKey
|
||||
) { data, selectedDate, searchKey ->
|
||||
|
||||
val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
val filteredDetails = if (dateStr != null) {
|
||||
data.details.filter { it.date == dateStr }
|
||||
} else data.details
|
||||
|
||||
val filteredDetails = data.details.filter { detail ->
|
||||
dateStr == null || detail.date == dateStr
|
||||
}
|
||||
|
||||
val timelineMap = data.sessions
|
||||
.asSequence()
|
||||
@@ -123,6 +121,14 @@ class ReadRecordViewModel(
|
||||
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> {
|
||||
if (sessions.isEmpty()) return emptyList()
|
||||
val mergedList = mutableListOf<ReadRecordSession>()
|
||||
|
||||
@@ -250,7 +250,7 @@ fun TxtRuleScreen(
|
||||
}
|
||||
|
||||
RuleListScaffold(
|
||||
title = "字典规则",
|
||||
title = "目录规则",
|
||||
state = uiState,
|
||||
onBackClick = { onBackClick() },
|
||||
onSearchToggle = { active ->
|
||||
|
||||
@@ -95,7 +95,7 @@ fun ReplaceRuleScreen(
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val rules = uiState.items
|
||||
val groups = uiState.groups
|
||||
val groups by viewModel.allGroups.collectAsStateWithLifecycle()
|
||||
val selectedIds = uiState.selectedIds
|
||||
val inSelectionMode = selectedIds.isNotEmpty()
|
||||
|
||||
@@ -116,10 +116,10 @@ fun ReplaceRuleScreen(
|
||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||
val tabItems = remember(groups) { listOf("全部") + groups }
|
||||
val filteredRules = remember(uiState.items, selectedTabIndex, tabItems) {
|
||||
if (selectedTabIndex == 0) {
|
||||
val targetGroup = tabItems.getOrNull(selectedTabIndex)
|
||||
if (targetGroup == null || selectedTabIndex == 0) {
|
||||
uiState.items
|
||||
} else {
|
||||
val targetGroup = tabItems[selectedTabIndex]
|
||||
uiState.items.filter { it.group == targetGroup }
|
||||
}
|
||||
}
|
||||
@@ -221,8 +221,10 @@ fun ReplaceRuleScreen(
|
||||
}
|
||||
|
||||
LaunchedEffect(groups) {
|
||||
if (selectedTabIndex > groups.size) {
|
||||
val maxIndex = groups.size
|
||||
if (selectedTabIndex > maxIndex) {
|
||||
selectedTabIndex = 0
|
||||
viewModel.setGroup("全部")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +341,10 @@ fun ReplaceRuleScreen(
|
||||
tabItems.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTabIndex == index,
|
||||
onClick = { selectedTabIndex = index },
|
||||
onClick = {
|
||||
selectedTabIndex = index
|
||||
viewModel.setGroup(title)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = title,
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.legado.app.ui.replace
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.base.BaseRuleEvent
|
||||
import io.legado.app.base.BaseRuleViewModel
|
||||
import io.legado.app.constant.AppPattern
|
||||
import io.legado.app.constant.PreferKey
|
||||
@@ -25,10 +26,13 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -67,6 +71,21 @@ class ReplaceRuleViewModel(
|
||||
private val _group = MutableStateFlow<String?>(null)
|
||||
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)
|
||||
override val rawDataFlow: Flow<List<ReplaceRule>> =
|
||||
combine(_group, _sortMode) { group, sortMode ->
|
||||
@@ -156,9 +175,14 @@ class ReplaceRuleViewModel(
|
||||
}
|
||||
rule
|
||||
}
|
||||
repository.update(*rulesToSave.toTypedArray())
|
||||
withContext(Dispatchers.Main) {
|
||||
_importState.value = BaseImportUiState.Idle
|
||||
if (rulesToSave.isNotEmpty()) {
|
||||
rulesToSave.forEach { rule ->
|
||||
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.verticalScroll
|
||||
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.filled.Check
|
||||
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.AssistChip
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -49,7 +47,6 @@ import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.unit.dp
|
||||
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
|
||||
|
||||
@Composable
|
||||
@@ -114,12 +113,7 @@ fun ReplaceEditScreen(
|
||||
GlassMediumFlexibleTopAppBar(
|
||||
title = { Text(if (state.id > 0) "编辑替换规则" else "新增替换规则") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
"Back"
|
||||
)
|
||||
}
|
||||
SmallTopBarButton(onClick = onBack)
|
||||
},
|
||||
actions = {
|
||||
|
||||
@@ -437,18 +431,13 @@ fun ManageGroupDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
OutlinedButton(
|
||||
AlertButton(
|
||||
onClick = {
|
||||
val toDelete = selected.filter { it.value }.keys.toList()
|
||||
onDelete(toDelete)
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
Text("删除选中")
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user