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