From df5387fb3c14f4b58359c194e2523b6bfd5b0341 Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Fri, 30 Jan 2026 01:45:52 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=B0=E5=A2=9E]=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E8=AE=B0=E5=BD=95=E7=9A=84=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=AD=89=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BA=86=E5=85=B6=E4=BB=96=E7=95=8C=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E4=B8=80=E4=BA=9B=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/legado/app/base/BaseRuleViewModel.kt | 3 +- .../io/legado/app/data/dao/ReadRecordDao.kt | 33 +++- .../data/repository/ReadRecordRepository.kt | 55 +++++- .../java/io/legado/app/help/storage/Backup.kt | 26 ++- .../io/legado/app/help/storage/Restore.kt | 14 +- .../ui/book/readRecord/ReadRecordScreen.kt | 158 ++++++++++++++---- .../ui/book/readRecord/ReadRecordViewModel.kt | 20 ++- .../app/ui/book/toc/rule/TxtRuleScreen.kt | 2 +- .../app/ui/replace/ReplaceRuleScreen.kt | 15 +- .../app/ui/replace/ReplaceRuleViewModel.kt | 30 +++- .../app/ui/replace/edit/ReplaceEditScreen.kt | 23 +-- .../widget/components/button/AlertButton.kt | 29 ++++ .../ui/widget/components/swipe/SwipeAction.kt | 11 ++ .../components/swipe/SwipeActionContainer.kt | 114 +++++++++++++ 14 files changed, 461 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/button/AlertButton.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeAction.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt diff --git a/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt b/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt index 7a6b91330..87f3097e4 100644 --- a/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt @@ -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, Entity, ID, S : RuleAct private val itemsFlow: Flow> by lazy { combine( rawDataFlow, - _searchKey.debounce(300L), + _searchKey, _localItems ) { data, key, local -> if (local != null && key.isEmpty()) { diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt index 1a103f816..edbf02d20 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt @@ -18,6 +18,12 @@ interface ReadRecordDao { @get:Query("select * from readRecord") val all: List + @get:Query("select * from readRecordDetail") + val allDetail: List + + @get:Query("select * from readRecordSession") + val allSession: List + @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 @@ -159,6 +165,31 @@ interface ReadRecordDao { @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC") fun getAllSessions(deviceId: String): Flow> + @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName") + suspend fun getSessionsByBook(deviceId: String, bookName: String): List + @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) } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt index 6cb8bd819..144c8ed5c 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt @@ -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) + } + } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/storage/Backup.kt b/app/src/main/java/io/legado/app/help/storage/Backup.kt index 14c6a05fa..291701084 100644 --- a/app/src/main/java/io/legado/app/help/storage/Backup.kt +++ b/app/src/main/java/io/legado/app/help/storage/Backup.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/help/storage/Restore.kt b/app/src/main/java/io/legado/app/help/storage/Restore.kt index 1b8421e0b..8a454ad9a 100644 --- a/app/src/main/java/io/legado/app/help/storage/Restore.kt +++ b/app/src/main/java/io/legado/app/help/storage/Restore.kt @@ -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(path, "readRecordDetail.json")?.let { + it.forEach { detail -> + appDb.readRecordDao.insertDetail(detail) + } + } + fileToListT(path, "readRecordSession.json")?.let { + it.forEach { session -> + appDb.readRecordDao.insertSession(session) + } + } File(path, "servers.json").takeIf { it.exists() }?.runCatching { diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt index a0fdabdf9..09e1184e7 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt @@ -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(null) } @@ -478,10 +584,6 @@ fun ReadRecordItem( color = Color.Gray ) } - - IconButton(onClick = onDelete) { - Icon(Icons.Default.Close, "Delete", tint = Color.LightGray) - } } } diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt index ef48f98e6..d24528709 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt @@ -31,7 +31,7 @@ data class ReadRecordUiState( //最后阅读列表 val latestRecords: List = 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(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): List { if (sessions.isEmpty()) return emptyList() val mergedList = mutableListOf() diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt index 4ca0926c9..8e6031bae 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt @@ -250,7 +250,7 @@ fun TxtRuleScreen( } RuleListScaffold( - title = "字典规则", + title = "目录规则", state = uiState, onBackClick = { onBackClick() }, onSearchToggle = { active -> diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index 6d050b432..f1a9145fe 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt index 9f791a1f6..ae32aa77b 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt @@ -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(null) val group = _group.asStateFlow() + val allGroups: StateFlow> = 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> = 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} 条规则")) + } } } } diff --git a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt index f76895336..e9dfbe1f6 100644 --- a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt @@ -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("关闭") } } ) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/AlertButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/AlertButton.kt new file mode 100644 index 000000000..e6f18d3fb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/AlertButton.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeAction.kt b/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeAction.kt new file mode 100644 index 000000000..9079ae6d3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeAction.kt @@ -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 +) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt b/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt new file mode 100644 index 000000000..477f90601 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt @@ -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() + } + } + ) +} \ No newline at end of file