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

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.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()
}
}
)
}