[新增] 重写了Txt目录界面,优化了规则类的相关逻辑和界面
This commit is contained in:
35 files changed
+2161
-2120
No files matched your search
@@ -0,0 +1,289 @@
|
|||||||
|
package io.legado.app.base
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.legado.app.constant.AppConst
|
||||||
|
import io.legado.app.data.repository.UploadRepository
|
||||||
|
import io.legado.app.help.http.decompressed
|
||||||
|
import io.legado.app.help.http.newCallResponseBody
|
||||||
|
import io.legado.app.help.http.okHttpClient
|
||||||
|
import io.legado.app.help.http.text
|
||||||
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
|
import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
|
||||||
|
import io.legado.app.ui.widget.components.importComponents.ImportStatus
|
||||||
|
import io.legado.app.ui.widget.components.rules.RuleActionState
|
||||||
|
import io.legado.app.ui.widget.components.rules.SelectableItem
|
||||||
|
import io.legado.app.utils.isAbsUrl
|
||||||
|
import io.legado.app.utils.isUri
|
||||||
|
import io.legado.app.utils.readText
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
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.debounce
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// 统一事件接口
|
||||||
|
sealed interface BaseRuleEvent {
|
||||||
|
data class ShowSnackbar(
|
||||||
|
val message: String,
|
||||||
|
val actionLabel: String? = null,
|
||||||
|
val url: String? = null
|
||||||
|
) : BaseRuleEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class BaseRuleViewModel<T : SelectableItem<ID>, Entity, ID, S : RuleActionState<T>>(
|
||||||
|
application: Application,
|
||||||
|
initialState: S,
|
||||||
|
private val uploadRepository: UploadRepository? = null // 设为可空,提高灵活性
|
||||||
|
) : BaseViewModel(application) {
|
||||||
|
|
||||||
|
protected val _searchKey = MutableStateFlow("")
|
||||||
|
protected val _selectedIds = MutableStateFlow<Set<ID>>(emptySet())
|
||||||
|
protected val _isSearchMode = MutableStateFlow(false)
|
||||||
|
protected val _isUploading = MutableStateFlow(false)
|
||||||
|
protected val _localItems = MutableStateFlow<List<T>?>(null)
|
||||||
|
protected val _importState = MutableStateFlow<BaseImportUiState<Entity>>(BaseImportUiState.Idle)
|
||||||
|
val importState = _importState.asStateFlow()
|
||||||
|
protected val _eventChannel = Channel<BaseRuleEvent>()
|
||||||
|
val events = _eventChannel.receiveAsFlow()
|
||||||
|
|
||||||
|
abstract val rawDataFlow: Flow<List<Entity>>
|
||||||
|
|
||||||
|
open fun filterData(data: List<Entity>, key: String): List<Entity> = data
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
private val itemsFlow: Flow<List<T>> by lazy {
|
||||||
|
combine(
|
||||||
|
rawDataFlow,
|
||||||
|
_searchKey.debounce(300L),
|
||||||
|
_localItems
|
||||||
|
) { data, key, local ->
|
||||||
|
if (local != null && key.isEmpty()) {
|
||||||
|
local
|
||||||
|
} else {
|
||||||
|
filterData(data, key).map { it.toUiItem() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val uiState: StateFlow<S> by lazy {
|
||||||
|
combine(
|
||||||
|
itemsFlow,
|
||||||
|
_selectedIds,
|
||||||
|
_isSearchMode,
|
||||||
|
_isUploading,
|
||||||
|
_importState
|
||||||
|
) { items, selectedIds, isSearch, isUploading, importState ->
|
||||||
|
composeUiState(items, selectedIds, isSearch, isUploading, importState)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = initialState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun composeUiState(
|
||||||
|
items: List<T>,
|
||||||
|
selectedIds: Set<ID>,
|
||||||
|
isSearch: Boolean,
|
||||||
|
isUploading: Boolean,
|
||||||
|
importState: BaseImportUiState<Entity>
|
||||||
|
): S
|
||||||
|
|
||||||
|
// 抽象方法:将实体转换为UI Item
|
||||||
|
abstract fun Entity.toUiItem(): T
|
||||||
|
|
||||||
|
// 抽象方法:生成用于导出/上传的JSON
|
||||||
|
abstract suspend fun generateJson(entities: List<Entity>): String
|
||||||
|
|
||||||
|
// 抽象方法:解析导入的文本为实体列表
|
||||||
|
abstract fun parseImportRules(text: String): List<Entity>
|
||||||
|
|
||||||
|
// 抽象方法:判断新旧实体是否有变化 (用于导入去重)
|
||||||
|
abstract fun hasChanged(newRule: Entity, oldRule: Entity): Boolean
|
||||||
|
|
||||||
|
// 抽象方法:根据ID查找旧实体 (用于导入对比)
|
||||||
|
abstract suspend fun findOldRule(newRule: Entity): Entity?
|
||||||
|
|
||||||
|
// 抽象方法:保存导入的规则
|
||||||
|
abstract fun saveImportedRules()
|
||||||
|
|
||||||
|
fun moveItemInList(from: Int, to: Int) {
|
||||||
|
val currentList = uiState.value.items.toMutableList()
|
||||||
|
if (from !in currentList.indices || to !in currentList.indices) return
|
||||||
|
val item = currentList.removeAt(from)
|
||||||
|
currentList.add(to, item)
|
||||||
|
_localItems.value = currentList
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchKey(key: String?) {
|
||||||
|
_localItems.value = null
|
||||||
|
_searchKey.value = key ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchMode(active: Boolean) {
|
||||||
|
_isSearchMode.value = active
|
||||||
|
if (!active) setSearchKey("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSelection(id: ID) {
|
||||||
|
_selectedIds.update { if (it.contains(id)) it - id else it + id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSelection(ids: Set<ID>) {
|
||||||
|
_selectedIds.value = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportToUri(uri: Uri, rules: List<T>, selectedIds: Set<ID>) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val rulesToExport = rules
|
||||||
|
.filter { selectedIds.contains(it.id) }
|
||||||
|
.map { ruleItemToEntity(it) }
|
||||||
|
|
||||||
|
if (rulesToExport.isEmpty()) {
|
||||||
|
_eventChannel.send(BaseRuleEvent.ShowSnackbar("没有选中的规则可导出"))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val json = generateJson(rulesToExport)
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
outputStream.bufferedWriter().use { writer ->
|
||||||
|
writer.write(json)
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_eventChannel.send(BaseRuleEvent.ShowSnackbar("导出成功"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
_eventChannel.send(BaseRuleEvent.ShowSnackbar("导出失败: ${e.localizedMessage}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun ruleItemToEntity(item: T): Entity
|
||||||
|
|
||||||
|
fun uploadSelectedRules(selectedIds: Set<ID>, rules: List<T>) {
|
||||||
|
val repo = uploadRepository ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (selectedIds.isEmpty()) return@launch
|
||||||
|
|
||||||
|
_isUploading.value = true
|
||||||
|
try {
|
||||||
|
val json = withContext(Dispatchers.Default) {
|
||||||
|
val rulesToExport = rules
|
||||||
|
.filter { selectedIds.contains(it.id) }
|
||||||
|
.map { ruleItemToEntity(it) }
|
||||||
|
generateJson(rulesToExport)
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = repo.upload(
|
||||||
|
fileName = "export_rules.json",
|
||||||
|
file = json,
|
||||||
|
contentType = "application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
_eventChannel.send(
|
||||||
|
BaseRuleEvent.ShowSnackbar(
|
||||||
|
message = "上传成功: $url",
|
||||||
|
actionLabel = "复制链接",
|
||||||
|
url = url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_eventChannel.send(
|
||||||
|
BaseRuleEvent.ShowSnackbar(
|
||||||
|
message = "上传失败: ${e.localizedMessage}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
_isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importSource(text: String) {
|
||||||
|
_importState.value = BaseImportUiState.Loading
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val jsonText = resolveSource(text.trim())
|
||||||
|
val rules = parseImportRules(jsonText)
|
||||||
|
val wrappers = rules.map { newRule ->
|
||||||
|
val oldRule = findOldRule(newRule)
|
||||||
|
|
||||||
|
val status = when {
|
||||||
|
oldRule == null -> ImportStatus.New
|
||||||
|
hasChanged(newRule, oldRule) -> ImportStatus.Update
|
||||||
|
else -> ImportStatus.Existing
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportItemWrapper(
|
||||||
|
data = newRule,
|
||||||
|
oldData = oldRule,
|
||||||
|
status = status,
|
||||||
|
isSelected = status != ImportStatus.Existing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_importState.value = BaseImportUiState.Success(
|
||||||
|
source = text,
|
||||||
|
items = wrappers
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
_importState.value = BaseImportUiState.Error(it.localizedMessage ?: "Unknown Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected suspend fun resolveSource(text: String): String {
|
||||||
|
return when {
|
||||||
|
text.isAbsUrl() -> {
|
||||||
|
okHttpClient.newCallResponseBody {
|
||||||
|
if (text.endsWith("#requestWithoutUA")) {
|
||||||
|
url(text.substringBeforeLast("#requestWithoutUA"))
|
||||||
|
header(AppConst.UA_NAME, "null")
|
||||||
|
} else {
|
||||||
|
url(text)
|
||||||
|
}
|
||||||
|
}.decompressed().text("utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
text.isUri() -> text.toUri().readText(context)
|
||||||
|
else -> text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelImport() {
|
||||||
|
_importState.value = BaseImportUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleImportSelection(index: Int) {
|
||||||
|
val currentState = _importState.value as? BaseImportUiState.Success<Entity> ?: return
|
||||||
|
val newItems = currentState.items.toMutableList()
|
||||||
|
val item = newItems[index]
|
||||||
|
newItems[index] = item.copy(isSelected = !item.isSelected)
|
||||||
|
_importState.value = currentState.copy(items = newItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleImportAll(isSelected: Boolean) {
|
||||||
|
val currentState = _importState.value as? BaseImportUiState.Success<Entity> ?: return
|
||||||
|
val newItems = currentState.items.map { it.copy(isSelected = isSelected) }
|
||||||
|
_importState.value = currentState.copy(items = newItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package io.legado.app.data.dao
|
package io.legado.app.data.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
import io.legado.app.data.entities.TxtTocRule
|
import io.legado.app.data.entities.TxtTocRule
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -10,6 +15,12 @@ interface TxtTocRuleDao {
|
|||||||
@Query("select * from txtTocRules order by serialNumber")
|
@Query("select * from txtTocRules order by serialNumber")
|
||||||
fun observeAll(): Flow<List<TxtTocRule>>
|
fun observeAll(): Flow<List<TxtTocRule>>
|
||||||
|
|
||||||
|
@Query("select * from txtTocRules where name LIKE '%' || :key || '%' order by serialNumber")
|
||||||
|
fun flowSearch(key: String): Flow<List<TxtTocRule>>
|
||||||
|
|
||||||
|
@Query("select * from txtTocRules where id in (:ids)")
|
||||||
|
fun getByIds(ids: Set<Long>): List<TxtTocRule>
|
||||||
|
|
||||||
@get:Query("select * from txtTocRules order by serialNumber")
|
@get:Query("select * from txtTocRules order by serialNumber")
|
||||||
val all: List<TxtTocRule>
|
val all: List<TxtTocRule>
|
||||||
|
|
||||||
|
|||||||
@@ -22,16 +22,22 @@ class DictRuleRepository {
|
|||||||
return dao.all
|
return dao.all
|
||||||
}
|
}
|
||||||
|
|
||||||
fun insert(vararg rule: DictRule) {
|
suspend fun insert(vararg rule: DictRule) {
|
||||||
dao.insert(*rule)
|
withContext(Dispatchers.IO) {
|
||||||
|
dao.insert(*rule)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(vararg rule: DictRule) {
|
suspend fun delete(vararg rule: DictRule) {
|
||||||
dao.delete(*rule)
|
withContext(Dispatchers.IO) {
|
||||||
|
dao.delete(*rule)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(vararg rule: DictRule) {
|
suspend fun update(vararg rule: DictRule) {
|
||||||
dao.update(*rule)
|
withContext(Dispatchers.IO) {
|
||||||
|
dao.update(*rule)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findById(id: String): DictRule? = withContext(Dispatchers.IO) {
|
suspend fun findById(id: String): DictRule? = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ package io.legado.app.data.repository
|
|||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import io.legado.app.data.appDb
|
import io.legado.app.data.appDb
|
||||||
import io.legado.app.data.entities.ReplaceRule
|
import io.legado.app.data.entities.ReplaceRule
|
||||||
import io.legado.app.ui.replace.ReplaceRuleItemUi
|
|
||||||
import io.legado.app.utils.splitNotBlank
|
import io.legado.app.utils.splitNotBlank
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.collections.map
|
|
||||||
|
|
||||||
class ReplaceRuleRepository {
|
class ReplaceRuleRepository {
|
||||||
|
|
||||||
@@ -39,6 +37,12 @@ class ReplaceRuleRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun insert(vararg rule: ReplaceRule) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
appDb.replaceRuleDao.insert(*rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun delete(rule: ReplaceRule) {
|
suspend fun delete(rule: ReplaceRule) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
appDb.replaceRuleDao.delete(rule)
|
appDb.replaceRuleDao.delete(rule)
|
||||||
@@ -80,24 +84,6 @@ class ReplaceRuleRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun enableSelection(rules: List<ReplaceRule>) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val array = Array(rules.size) {
|
|
||||||
rules[it].copy(isEnabled = true)
|
|
||||||
}
|
|
||||||
appDb.replaceRuleDao.update(*array)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun disableSelection(rules: List<ReplaceRule>) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val array = Array(rules.size) {
|
|
||||||
rules[it].copy(isEnabled = false)
|
|
||||||
}
|
|
||||||
appDb.replaceRuleDao.update(*array)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addGroup(group: String) {
|
suspend fun addGroup(group: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val sources = appDb.replaceRuleDao.noGroup
|
val sources = appDb.replaceRuleDao.noGroup
|
||||||
@@ -205,14 +191,13 @@ class ReplaceRuleRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun moveOrder(currentRules: List<ReplaceRuleItemUi>, isDesc: Boolean = false) {
|
suspend fun moveOrder(currentRules: List<ReplaceRule>, isDesc: Boolean = false) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val size = currentRules.size
|
val size = currentRules.size
|
||||||
val updatedRules = currentRules.mapIndexed { index, itemUi ->
|
val updatedRules = currentRules.mapIndexed { index, rule ->
|
||||||
val order = if (isDesc) size - index else index + 1
|
val order = if (isDesc) size - index else index + 1
|
||||||
itemUi.rule.copy(order = order)
|
rule.copy(order = order)
|
||||||
}
|
}
|
||||||
|
|
||||||
appDb.replaceRuleDao.update(*updatedRules.toTypedArray())
|
appDb.replaceRuleDao.update(*updatedRules.toTypedArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package io.legado.app.data.repository
|
||||||
|
|
||||||
|
import io.legado.app.data.appDb
|
||||||
|
import io.legado.app.data.entities.TxtTocRule
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class TxtTocRuleRepository {
|
||||||
|
private val dao = appDb.txtTocRuleDao
|
||||||
|
|
||||||
|
fun flowAll(): Flow<List<TxtTocRule>> = dao.observeAll()
|
||||||
|
|
||||||
|
fun flowSearch(key: String): Flow<List<TxtTocRule>> = dao.flowSearch(key)
|
||||||
|
|
||||||
|
suspend fun insert(vararg rules: TxtTocRule) = withContext(Dispatchers.IO) {
|
||||||
|
dao.insert(*rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun update(vararg rules: TxtTocRule) = withContext(Dispatchers.IO) {
|
||||||
|
dao.update(*rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun delete(vararg rules: TxtTocRule) = withContext(Dispatchers.IO) {
|
||||||
|
dao.delete(*rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteByIds(ids: Collection<Long>) = withContext(Dispatchers.IO) {
|
||||||
|
val rules = dao.getByIds(ids.toSet())
|
||||||
|
dao.delete(*rules.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun enableByIds(ids: Collection<Long>, enable: Boolean) = withContext(Dispatchers.IO) {
|
||||||
|
val rules = dao.getByIds(ids.toSet())
|
||||||
|
val updated = rules.map { it.copy(enable = enable) }
|
||||||
|
dao.update(*updated.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveOrder(rules: List<TxtTocRule>) = withContext(Dispatchers.IO) {
|
||||||
|
rules.forEachIndexed { index, rule ->
|
||||||
|
rule.serialNumber = index + 1
|
||||||
|
}
|
||||||
|
dao.update(*rules.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,15 @@ import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
|
|||||||
import io.legado.app.ui.book.explore.ExploreShowViewModel
|
import io.legado.app.ui.book.explore.ExploreShowViewModel
|
||||||
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
|
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
|
||||||
import io.legado.app.ui.book.searchContent.SearchContentViewModel
|
import io.legado.app.ui.book.searchContent.SearchContentViewModel
|
||||||
|
import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
|
||||||
import io.legado.app.ui.dict.rule.DictRuleViewModel
|
import io.legado.app.ui.dict.rule.DictRuleViewModel
|
||||||
import io.legado.app.ui.main.my.MyViewModel
|
import io.legado.app.ui.main.my.MyViewModel
|
||||||
import io.legado.app.ui.replace.ReplaceEditRoute
|
import io.legado.app.ui.replace.ReplaceEditRoute
|
||||||
import io.legado.app.ui.replace.ReplaceRuleViewModel
|
import io.legado.app.ui.replace.ReplaceRuleViewModel
|
||||||
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
|
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
|
||||||
import org.koin.android.ext.koin.androidApplication
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val appModule = module {
|
val appModule = module {
|
||||||
@@ -27,15 +29,21 @@ val appModule = module {
|
|||||||
single { get<AppDatabase>().bookDao }
|
single { get<AppDatabase>().bookDao }
|
||||||
single { get<AppDatabase>().bookChapterDao }
|
single { get<AppDatabase>().bookChapterDao }
|
||||||
|
|
||||||
single { ReadRecordRepository(get()) }
|
singleOf(::ReadRecordRepository)
|
||||||
|
singleOf(::BookRepository)
|
||||||
single { BookRepository(get(), get()) }
|
singleOf(::SearchContentRepository)
|
||||||
|
|
||||||
single<UploadRepository> { DirectLinkUploadRepository() }
|
single<UploadRepository> { DirectLinkUploadRepository() }
|
||||||
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
||||||
single { SearchContentRepository() }
|
|
||||||
|
|
||||||
viewModel { ReadRecordViewModel(get(), get()) }
|
viewModelOf(::DictRuleViewModel)
|
||||||
|
viewModelOf(::ReadRecordViewModel)
|
||||||
|
viewModelOf(::ExploreShowViewModel)
|
||||||
|
viewModelOf(::MyViewModel)
|
||||||
|
viewModelOf(::ReplaceRuleViewModel)
|
||||||
|
viewModelOf(::AllBookmarkViewModel)
|
||||||
|
viewModelOf(::TxtTocRuleViewModel)
|
||||||
|
|
||||||
viewModel { (route: ReplaceEditRoute) ->
|
viewModel { (route: ReplaceEditRoute) ->
|
||||||
ReplaceEditViewModel(
|
ReplaceEditViewModel(
|
||||||
app = get(),
|
app = get(),
|
||||||
@@ -43,11 +51,6 @@ val appModule = module {
|
|||||||
savedStateHandle = get()
|
savedStateHandle = get()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModel { ReplaceRuleViewModel(androidApplication()) }
|
|
||||||
viewModel { DictRuleViewModel(androidApplication()) }
|
|
||||||
viewModel { ExploreShowViewModel(get()) }
|
|
||||||
viewModel { SearchContentViewModel(get(), get()) }
|
|
||||||
viewModel { MyViewModel(get()) }
|
|
||||||
|
|
||||||
viewModel { AllBookmarkViewModel(androidApplication(), get()) }
|
viewModelOf(::SearchContentViewModel)
|
||||||
}
|
}
|
||||||
@@ -33,11 +33,11 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
import io.legado.app.constant.AppConst.appInfo
|
import io.legado.app.constant.AppConst.appInfo
|
||||||
import io.legado.app.ui.widget.GlassBackIconButton
|
|
||||||
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
||||||
import io.legado.app.ui.widget.components.SettingItem
|
import io.legado.app.ui.widget.components.SettingItem
|
||||||
import io.legado.app.ui.widget.components.SplicedColumnGroup
|
import io.legado.app.ui.widget.components.SplicedColumnGroup
|
||||||
import io.legado.app.ui.widget.components.TextCard
|
import io.legado.app.ui.widget.components.button.SmallTopBarButton
|
||||||
|
import io.legado.app.ui.widget.components.card.TextCard
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -61,7 +61,7 @@ fun AboutScreen(
|
|||||||
GlassMediumFlexibleTopAppBar(
|
GlassMediumFlexibleTopAppBar(
|
||||||
title = { Text(stringResource(R.string.about)) },
|
title = { Text(stringResource(R.string.about)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
GlassBackIconButton(onClick = onBack)
|
SmallTopBarButton(onClick = onBack)
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior
|
scrollBehavior = scrollBehavior
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,12 +91,12 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|||||||
import io.legado.app.data.entities.SearchBook
|
import io.legado.app.data.entities.SearchBook
|
||||||
import io.legado.app.data.entities.rule.ExploreKind
|
import io.legado.app.data.entities.rule.ExploreKind
|
||||||
import io.legado.app.model.BookShelfState
|
import io.legado.app.model.BookShelfState
|
||||||
import io.legado.app.ui.widget.components.AnimatedTextButton
|
|
||||||
import io.legado.app.ui.widget.components.AnimatedTextLine
|
import io.legado.app.ui.widget.components.AnimatedTextLine
|
||||||
import io.legado.app.ui.widget.components.Cover
|
import io.legado.app.ui.widget.components.Cover
|
||||||
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.TextCard
|
import io.legado.app.ui.widget.components.button.AnimatedTextButton
|
||||||
|
import io.legado.app.ui.widget.components.card.TextCard
|
||||||
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
|
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
|
|||||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||||
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.filled.FolderOpen
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
import androidx.compose.material.icons.filled.ImageSearch
|
import androidx.compose.material.icons.filled.ImageSearch
|
||||||
import androidx.compose.material.icons.filled.Replay
|
import androidx.compose.material.icons.filled.Replay
|
||||||
@@ -28,10 +27,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
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
|
||||||
@@ -54,6 +50,8 @@ import io.legado.app.R
|
|||||||
import io.legado.app.ui.book.changecover.ChangeCoverDialog
|
import io.legado.app.ui.book.changecover.ChangeCoverDialog
|
||||||
import io.legado.app.ui.widget.components.Cover
|
import io.legado.app.ui.widget.components.Cover
|
||||||
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
||||||
|
import io.legado.app.ui.widget.components.button.SmallTopBarButton
|
||||||
|
import io.legado.app.ui.widget.components.button.TopBarButtonVariant
|
||||||
import io.legado.app.utils.SelectImageContract
|
import io.legado.app.utils.SelectImageContract
|
||||||
import io.legado.app.utils.launch
|
import io.legado.app.utils.launch
|
||||||
import io.legado.app.utils.showDialogFragment
|
import io.legado.app.utils.showDialogFragment
|
||||||
@@ -74,22 +72,16 @@ fun BookInfoEditScreen(
|
|||||||
GlassMediumFlexibleTopAppBar(
|
GlassMediumFlexibleTopAppBar(
|
||||||
title = { Text(text = stringResource(id = R.string.book_info_edit)) },
|
title = { Text(text = stringResource(id = R.string.book_info_edit)) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
SmallTopBarButton(
|
||||||
Icon(
|
onClick = onBack,
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
style = TopBarButtonVariant.Outlined
|
||||||
contentDescription = null
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
FilledTonalIconButton(
|
SmallTopBarButton(
|
||||||
onClick = { viewModel.save(onSave) },
|
onClick = { viewModel.save(onSave) },
|
||||||
shapes = IconButtonDefaults.shapes()) {
|
imageVector = Icons.Default.Save
|
||||||
Icon(
|
)
|
||||||
imageVector = Icons.Default.Save,
|
|
||||||
contentDescription = stringResource(id = R.string.action_save)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior
|
scrollBehavior = scrollBehavior
|
||||||
)
|
)
|
||||||
@@ -213,8 +205,7 @@ fun BookInfoEditContent(
|
|||||||
value = uiState.intro ?: "",
|
value = uiState.intro ?: "",
|
||||||
onValueChange = { viewModel.onIntroChange(it) },
|
onValueChange = { viewModel.onIntroChange(it) },
|
||||||
label = { Text("简介") },
|
label = { Text("简介") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()
|
||||||
maxLines = 5
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ import io.legado.app.ui.widget.components.AnimatedText
|
|||||||
import io.legado.app.ui.widget.components.EmptyMessageView
|
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.TextCard
|
|
||||||
import io.legado.app.ui.widget.components.button.AnimatedActionButton
|
import io.legado.app.ui.widget.components.button.AnimatedActionButton
|
||||||
|
import io.legado.app.ui.widget.components.card.TextCard
|
||||||
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package io.legado.app.ui.book.toc.rule
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.base.BaseRuleEvent
|
||||||
|
import io.legado.app.data.entities.TxtTocRule
|
||||||
|
import io.legado.app.ui.widget.components.ActionItem
|
||||||
|
import io.legado.app.ui.widget.components.DraggableSelectionHandler
|
||||||
|
import io.legado.app.ui.widget.components.button.SmallIconButton
|
||||||
|
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
|
||||||
|
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
|
||||||
|
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
|
||||||
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
|
import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
|
||||||
|
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
|
||||||
|
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
||||||
|
import io.legado.app.ui.widget.components.rules.RuleEditFields
|
||||||
|
import io.legado.app.ui.widget.components.rules.RuleEditSheet
|
||||||
|
import io.legado.app.ui.widget.components.rules.RuleListScaffold
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun TxtRuleScreen(
|
||||||
|
viewModel: TxtTocRuleViewModel = koinViewModel(),
|
||||||
|
onBackClick: () -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val rules = uiState.items
|
||||||
|
val selectedIds = uiState.selectedIds
|
||||||
|
val inSelectionMode = selectedIds.isNotEmpty()
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
|
||||||
|
var showEditSheet by remember { mutableStateOf(false) }
|
||||||
|
var editingRule by remember { mutableStateOf<TxtTocRule?>(null) }
|
||||||
|
var showDeleteRuleDialog by remember { mutableStateOf<TxtTocRule?>(null) }
|
||||||
|
var showUrlInput by remember { mutableStateOf(false) }
|
||||||
|
var showFilePickerSheet by remember { mutableStateOf(false) }
|
||||||
|
var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
|
||||||
|
|
||||||
|
val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
|
||||||
|
viewModel.moveItemInList(from.index, to.index)
|
||||||
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
val clipboardManager = LocalClipboard.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val importState by viewModel.importState.collectAsStateWithLifecycle()
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is BaseRuleEvent.ShowSnackbar -> {
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = event.message,
|
||||||
|
actionLabel = event.actionLabel,
|
||||||
|
withDismissAction = true
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed && event.url != null) {
|
||||||
|
clipboardManager.setClipEntry(
|
||||||
|
ClipEntry(
|
||||||
|
ClipData.newPlainText(
|
||||||
|
"url",
|
||||||
|
event.url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val importDoc = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
onResult = { uri ->
|
||||||
|
uri?.let {
|
||||||
|
context.contentResolver.openInputStream(it)?.use { stream ->
|
||||||
|
val text = stream.reader().readText()
|
||||||
|
viewModel.importSource(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val exportDoc = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("application/json"),
|
||||||
|
onResult = { uri ->
|
||||||
|
uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showUrlInput) {
|
||||||
|
SourceInputDialog(
|
||||||
|
title = stringResource(R.string.import_on_line),
|
||||||
|
onDismissRequest = { showUrlInput = false },
|
||||||
|
onConfirm = {
|
||||||
|
showUrlInput = false
|
||||||
|
viewModel.importSource(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFilePickerSheet) {
|
||||||
|
FilePickerSheet(
|
||||||
|
sheetState = sheetState,
|
||||||
|
onDismissRequest = { showFilePickerSheet = false },
|
||||||
|
mode = filePickerMode,
|
||||||
|
onSelectSysDir = {
|
||||||
|
showFilePickerSheet = false
|
||||||
|
exportDoc.launch("exportDictRule.json")
|
||||||
|
},
|
||||||
|
onSelectSysFile = {},
|
||||||
|
onUpload = {
|
||||||
|
showFilePickerSheet = false
|
||||||
|
viewModel.uploadSelectedRules(selectedIds, rules)
|
||||||
|
},
|
||||||
|
allowExtensions = arrayOf("json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
(importState as? BaseImportUiState.Success<TxtTocRule>)?.let { state ->
|
||||||
|
BatchImportDialog(
|
||||||
|
title = "导入词典规则",
|
||||||
|
importState = state,
|
||||||
|
onDismissRequest = { viewModel.cancelImport() },
|
||||||
|
onToggleItem = { viewModel.toggleImportSelection(it) },
|
||||||
|
onToggleAll = { viewModel.toggleImportAll(it) },
|
||||||
|
onConfirm = { viewModel.saveImportedRules() },
|
||||||
|
itemContent = { rule, _ ->
|
||||||
|
Column {
|
||||||
|
Text(rule.name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(rule.rule, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(reorderableState.isAnyItemDragging) {
|
||||||
|
if (!reorderableState.isAnyItemDragging) {
|
||||||
|
viewModel.saveSortOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteRuleDialog?.let { rule ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteRuleDialog = null },
|
||||||
|
title = { Text(stringResource(R.string.delete)) },
|
||||||
|
text = { Text(stringResource(R.string.del_msg)) },
|
||||||
|
confirmButton = {
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
viewModel.delete(rule); showDeleteRuleDialog = null
|
||||||
|
}) { Text(stringResource(R.string.ok)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteRuleDialog = null }) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.cancel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showEditSheet) {
|
||||||
|
RuleEditSheet(
|
||||||
|
rule = editingRule,
|
||||||
|
title = stringResource(R.string.dict_rule),
|
||||||
|
label1 = stringResource(R.string.url_rule),
|
||||||
|
label2 = stringResource(R.string.show_rule),
|
||||||
|
onDismissRequest = {
|
||||||
|
showEditSheet = false
|
||||||
|
editingRule = null
|
||||||
|
},
|
||||||
|
onSave = { updatedRule ->
|
||||||
|
viewModel.update(updatedRule)
|
||||||
|
showEditSheet = false
|
||||||
|
editingRule = null
|
||||||
|
},
|
||||||
|
onCopy = { viewModel.copyRule(it) },
|
||||||
|
onPaste = { viewModel.pasteRule() },
|
||||||
|
toFields = { r ->
|
||||||
|
RuleEditFields(
|
||||||
|
name = r?.name ?: "",
|
||||||
|
rule1 = r?.rule ?: "",
|
||||||
|
rule2 = r?.example ?: ""
|
||||||
|
)
|
||||||
|
},
|
||||||
|
fromFields = { fields, old ->
|
||||||
|
old?.copy(
|
||||||
|
name = fields.name,
|
||||||
|
rule = fields.rule1,
|
||||||
|
example = fields.rule2
|
||||||
|
) ?: TxtTocRule(
|
||||||
|
name = fields.name,
|
||||||
|
rule = fields.rule1,
|
||||||
|
example = fields.rule2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RuleListScaffold(
|
||||||
|
title = "字典规则",
|
||||||
|
state = uiState,
|
||||||
|
onBackClick = { onBackClick() },
|
||||||
|
onSearchToggle = { active ->
|
||||||
|
viewModel.setSearchMode(active)
|
||||||
|
},
|
||||||
|
onSearchQueryChange = { viewModel.setSearchKey(it) },
|
||||||
|
searchPlaceholder = stringResource(R.string.replace_purify_search),
|
||||||
|
onClearSelection = { viewModel.setSelection(emptySet()) },
|
||||||
|
onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
|
||||||
|
onSelectInvert = {
|
||||||
|
val allIds = rules.map { it.id }.toSet()
|
||||||
|
viewModel.setSelection(allIds - selectedIds)
|
||||||
|
},
|
||||||
|
selectionSecondaryActions = listOf(
|
||||||
|
ActionItem(text = stringResource(R.string.enable), onClick = {
|
||||||
|
viewModel.enableSelectionByIds(selectedIds)
|
||||||
|
viewModel.setSelection(emptySet())
|
||||||
|
}),
|
||||||
|
ActionItem(text = stringResource(R.string.disable_selection), onClick = {
|
||||||
|
viewModel.disableSelectionByIds(selectedIds)
|
||||||
|
viewModel.setSelection(emptySet())
|
||||||
|
}),
|
||||||
|
ActionItem(
|
||||||
|
text = stringResource(R.string.export),
|
||||||
|
onClick = { showFilePickerSheet = true })
|
||||||
|
),
|
||||||
|
onDeleteSelected = { ids ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
viewModel.delSelectionByIds(ids as Set<Long>)
|
||||||
|
viewModel.setSelection(emptySet())
|
||||||
|
},
|
||||||
|
onAddClick = {
|
||||||
|
editingRule = null
|
||||||
|
showEditSheet = true
|
||||||
|
},
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
dropDownMenuContent = { dismiss ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("在线导入") },
|
||||||
|
onClick = { dismiss(); showUrlInput = true })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("本地导入") },
|
||||||
|
onClick = {
|
||||||
|
dismiss(); importDoc.launch(
|
||||||
|
arrayOf(
|
||||||
|
"text/plain",
|
||||||
|
"application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)) {
|
||||||
|
FastScrollLazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(top = 8.dp, bottom = 120.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(rules, key = { it.id }) { item ->
|
||||||
|
ReorderableSelectionItem(
|
||||||
|
state = reorderableState,
|
||||||
|
key = item.id,
|
||||||
|
title = item.name,
|
||||||
|
subtitle = item.example,
|
||||||
|
isEnabled = item.isEnabled,
|
||||||
|
isSelected = selectedIds.contains(item.id),
|
||||||
|
inSelectionMode = inSelectionMode,
|
||||||
|
onToggleSelection = { viewModel.toggleSelection(item.id) },
|
||||||
|
onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enable = enabled)) },
|
||||||
|
onClickEdit = { editingRule = item.rule; showEditSheet = true },
|
||||||
|
trailingAction = {
|
||||||
|
SmallIconButton(
|
||||||
|
onClick = { showDeleteRuleDialog = item.rule },
|
||||||
|
icon = Icons.Default.Delete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inSelectionMode) {
|
||||||
|
DraggableSelectionHandler(
|
||||||
|
listState = listState,
|
||||||
|
items = rules,
|
||||||
|
selectedIds = selectedIds,
|
||||||
|
onSelectionChange = { viewModel.setSelection(it) },
|
||||||
|
idProvider = { it.id },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(60.dp)
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,262 +1,17 @@
|
|||||||
package io.legado.app.ui.book.toc.rule
|
package io.legado.app.ui.book.toc.rule
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.appcompat.widget.PopupMenu
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
|
||||||
import io.legado.app.R
|
|
||||||
import io.legado.app.base.VMBaseActivity
|
|
||||||
import io.legado.app.constant.AppLog
|
|
||||||
import io.legado.app.data.appDb
|
|
||||||
import io.legado.app.data.entities.TxtTocRule
|
|
||||||
import io.legado.app.databinding.ActivityTxtTocRuleBinding
|
|
||||||
import io.legado.app.databinding.DialogEditTextBinding
|
|
||||||
import io.legado.app.help.DirectLinkUpload
|
|
||||||
import io.legado.app.lib.dialogs.alert
|
|
||||||
//import io.legado.app.lib.theme.primaryColor
|
//import io.legado.app.lib.theme.primaryColor
|
||||||
import io.legado.app.ui.association.ImportTxtTocRuleDialog
|
import androidx.compose.runtime.Composable
|
||||||
import io.legado.app.ui.file.HandleFileContract
|
import io.legado.app.base.BaseComposeActivity
|
||||||
import io.legado.app.ui.qrcode.QrCodeResult
|
import io.legado.app.ui.theme.AppTheme
|
||||||
import io.legado.app.ui.widget.SelectActionBar
|
|
||||||
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
|
|
||||||
import io.legado.app.ui.widget.recycler.ItemTouchCallback
|
|
||||||
import io.legado.app.ui.widget.recycler.VerticalDivider
|
|
||||||
import io.legado.app.utils.ACache
|
|
||||||
import io.legado.app.utils.GSON
|
|
||||||
import io.legado.app.utils.isAbsUrl
|
|
||||||
import io.legado.app.utils.launch
|
|
||||||
import io.legado.app.utils.readText
|
|
||||||
import io.legado.app.utils.sendToClip
|
|
||||||
import io.legado.app.utils.showDialogFragment
|
|
||||||
import io.legado.app.utils.showHelp
|
|
||||||
import io.legado.app.utils.splitNotBlank
|
|
||||||
import io.legado.app.utils.toastOnUi
|
|
||||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.conflate
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class TxtTocRuleActivity : VMBaseActivity<ActivityTxtTocRuleBinding, TxtTocRuleViewModel>(),
|
class TxtTocRuleActivity : BaseComposeActivity() {
|
||||||
TxtTocRuleAdapter.CallBack,
|
|
||||||
SelectActionBar.CallBack,
|
|
||||||
TxtTocRuleEditDialog.Callback,
|
|
||||||
PopupMenu.OnMenuItemClickListener {
|
|
||||||
|
|
||||||
override val viewModel: TxtTocRuleViewModel by viewModels()
|
@Composable
|
||||||
override val binding: ActivityTxtTocRuleBinding by viewBinding(ActivityTxtTocRuleBinding::inflate)
|
override fun Content() {
|
||||||
private val adapter: TxtTocRuleAdapter by lazy {
|
AppTheme {
|
||||||
TxtTocRuleAdapter(this, this)
|
TxtRuleScreen(onBackClick = { finish() })
|
||||||
}
|
|
||||||
private val importTocRuleKey = "tocRuleUrl"
|
|
||||||
private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
|
|
||||||
it ?: return@registerForActivityResult
|
|
||||||
showDialogFragment(ImportTxtTocRuleDialog(it))
|
|
||||||
}
|
|
||||||
private val importDoc = registerForActivityResult(HandleFileContract()) {
|
|
||||||
kotlin.runCatching {
|
|
||||||
it.uri?.readText(this)?.let {
|
|
||||||
showDialogFragment(ImportTxtTocRuleDialog(it))
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
toastOnUi("readTextError:${it.localizedMessage}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val exportResult = registerForActivityResult(HandleFileContract()) {
|
|
||||||
it.uri?.let { uri ->
|
|
||||||
alert(R.string.export_success) {
|
|
||||||
if (uri.toString().isAbsUrl()) {
|
|
||||||
setMessage(DirectLinkUpload.getSummary())
|
|
||||||
}
|
|
||||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
|
||||||
editView.hint = getString(R.string.path)
|
|
||||||
editView.setText(uri.toString())
|
|
||||||
}
|
|
||||||
customView { alertBinding.root }
|
|
||||||
okButton {
|
|
||||||
sendToClip(uri.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
initView()
|
|
||||||
initBottomActionBar()
|
|
||||||
initData()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initView() = binding.run {
|
|
||||||
//recyclerView.setEdgeEffectColor(primaryColor)
|
|
||||||
recyclerView.addItemDecoration(VerticalDivider(this@TxtTocRuleActivity))
|
|
||||||
recyclerView.adapter = adapter
|
|
||||||
// When this page is opened, it is in selection mode
|
|
||||||
val dragSelectTouchHelper =
|
|
||||||
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
|
|
||||||
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
|
|
||||||
dragSelectTouchHelper.activeSlideSelect()
|
|
||||||
// Note: need judge selection first, so add ItemTouchHelper after it.
|
|
||||||
val itemTouchCallback = ItemTouchCallback(adapter)
|
|
||||||
itemTouchCallback.isCanDrag = true
|
|
||||||
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initBottomActionBar() {
|
|
||||||
binding.selectActionBar.setMainActionText(R.string.delete)
|
|
||||||
binding.selectActionBar.inflateMenu(R.menu.txt_toc_rule_sel)
|
|
||||||
binding.selectActionBar.setOnMenuItemClickListener(this)
|
|
||||||
binding.selectActionBar.setCallBack(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initData() {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
appDb.txtTocRuleDao.observeAll().catch {
|
|
||||||
AppLog.put("TXT目录规则界面获取数据失败\n${it.localizedMessage}", it)
|
|
||||||
}.flowOn(IO).conflate().collect { tocRules ->
|
|
||||||
adapter.setItems(tocRules, adapter.diffItemCallBack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.txt_toc_rule, menu)
|
|
||||||
return super.onCompatCreateOptionsMenu(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.menu_add -> showDialogFragment(TxtTocRuleEditDialog())
|
|
||||||
R.id.menu_import_local -> importDoc.launch {
|
|
||||||
mode = HandleFileContract.FILE
|
|
||||||
allowExtensions = arrayOf("txt", "json")
|
|
||||||
}
|
|
||||||
R.id.menu_import_onLine -> showImportDialog()
|
|
||||||
R.id.menu_import_qr -> qrCodeResult.launch()
|
|
||||||
R.id.menu_import_default -> viewModel.importDefault()
|
|
||||||
R.id.menu_help -> showHelp("txtTocRuleHelp")
|
|
||||||
|
|
||||||
}
|
|
||||||
return super.onCompatOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun del(source: TxtTocRule) {
|
|
||||||
alert(R.string.draw) {
|
|
||||||
setMessage(getString(R.string.sure_del) + "\n" + source.name)
|
|
||||||
noButton()
|
|
||||||
yesButton {
|
|
||||||
viewModel.del(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun edit(source: TxtTocRule) {
|
|
||||||
showDialogFragment(TxtTocRuleEditDialog(source.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClickSelectBarMainAction() {
|
|
||||||
delSourceDialog()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun revertSelection() {
|
|
||||||
adapter.revertSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun selectAll(selectAll: Boolean) {
|
|
||||||
if (selectAll) {
|
|
||||||
adapter.selectAll()
|
|
||||||
} else {
|
|
||||||
adapter.revertSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun saveTxtTocRule(txtTocRule: TxtTocRule) {
|
|
||||||
viewModel.save(txtTocRule)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun update(vararg source: TxtTocRule) {
|
|
||||||
viewModel.update(*source)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toTop(source: TxtTocRule) {
|
|
||||||
viewModel.toTop(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toBottom(source: TxtTocRule) {
|
|
||||||
viewModel.toBottom(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun upOrder() {
|
|
||||||
viewModel.upOrder()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun upCountView() {
|
|
||||||
binding.selectActionBar
|
|
||||||
.upCountView(adapter.selection.size, adapter.itemCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun delSourceDialog() {
|
|
||||||
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
|
|
||||||
yesButton { viewModel.del(*adapter.selection.toTypedArray()) }
|
|
||||||
noButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
private fun showImportDialog() {
|
|
||||||
val aCache = ACache.get(cacheDir = false)
|
|
||||||
val defaultUrl = "https://gitee.com/fisher52/YueDuJson/raw/master/myTxtChapterRule.json"
|
|
||||||
val cacheUrls: MutableList<String> = aCache
|
|
||||||
.getAsString(importTocRuleKey)
|
|
||||||
?.splitNotBlank(",")
|
|
||||||
?.toMutableList()
|
|
||||||
?: mutableListOf()
|
|
||||||
if (!cacheUrls.contains(defaultUrl)) {
|
|
||||||
cacheUrls.add(0, defaultUrl)
|
|
||||||
}
|
|
||||||
alert(titleResource = R.string.import_on_line) {
|
|
||||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
|
||||||
editView.hint = "url"
|
|
||||||
editView.setFilterValues(cacheUrls)
|
|
||||||
editView.delCallBack = {
|
|
||||||
cacheUrls.remove(it)
|
|
||||||
aCache.put(importTocRuleKey, cacheUrls.joinToString(","))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
customView { alertBinding.root }
|
|
||||||
okButton {
|
|
||||||
val text = alertBinding.editView.text?.toString()
|
|
||||||
text?.let {
|
|
||||||
if (it.isAbsUrl() && !cacheUrls.contains(it)) {
|
|
||||||
cacheUrls.add(0, it)
|
|
||||||
aCache.put(importTocRuleKey, cacheUrls.joinToString(","))
|
|
||||||
}
|
|
||||||
showDialogFragment(ImportTxtTocRuleDialog(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cancelButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
R.id.menu_enable_selection -> viewModel.enableSelection(*adapter.selection.toTypedArray())
|
|
||||||
R.id.menu_disable_selection -> viewModel.disableSelection(*adapter.selection.toTypedArray())
|
|
||||||
R.id.menu_export_selection -> exportResult.launch {
|
|
||||||
mode = HandleFileContract.EXPORT
|
|
||||||
fileData = HandleFileContract.FileData(
|
|
||||||
"exportTxtTocRule.json",
|
|
||||||
GSON.toJson(adapter.selection).toByteArray(),
|
|
||||||
"application/json"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
package io.legado.app.ui.book.toc.rule
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.PopupMenu
|
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import io.legado.app.R
|
|
||||||
import io.legado.app.base.adapter.ItemViewHolder
|
|
||||||
import io.legado.app.base.adapter.RecyclerAdapter
|
|
||||||
import io.legado.app.data.entities.TxtTocRule
|
|
||||||
import io.legado.app.databinding.ItemTxtTocRuleBinding
|
|
||||||
//import io.legado.app.lib.theme.backgroundColor
|
|
||||||
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
|
|
||||||
import io.legado.app.ui.widget.recycler.ItemTouchCallback
|
|
||||||
|
|
||||||
class TxtTocRuleAdapter(context: Context, private val callBack: CallBack) :
|
|
||||||
RecyclerAdapter<TxtTocRule, ItemTxtTocRuleBinding>(context),
|
|
||||||
ItemTouchCallback.Callback {
|
|
||||||
|
|
||||||
private val selected = linkedSetOf<TxtTocRule>()
|
|
||||||
|
|
||||||
val selection: List<TxtTocRule>
|
|
||||||
get() = getItems().filter {
|
|
||||||
selected.contains(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val diffItemCallBack = object : DiffUtil.ItemCallback<TxtTocRule>() {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(oldItem: TxtTocRule, newItem: TxtTocRule): Boolean {
|
|
||||||
return oldItem.id == newItem.id
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: TxtTocRule, newItem: TxtTocRule): Boolean {
|
|
||||||
if (oldItem.name != newItem.name) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (oldItem.enable != newItem.enable) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (oldItem.example != newItem.example) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(oldItem: TxtTocRule, newItem: TxtTocRule): Any? {
|
|
||||||
val payload = Bundle()
|
|
||||||
if (oldItem.name != newItem.name) {
|
|
||||||
payload.putBoolean("upName", true)
|
|
||||||
}
|
|
||||||
if (oldItem.enable != newItem.enable) {
|
|
||||||
payload.putBoolean("enabled", newItem.enable)
|
|
||||||
}
|
|
||||||
if (oldItem.example != newItem.example) {
|
|
||||||
payload.putBoolean("upExample", true)
|
|
||||||
}
|
|
||||||
if (payload.isEmpty) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getViewBinding(parent: ViewGroup): ItemTxtTocRuleBinding {
|
|
||||||
return ItemTxtTocRuleBinding.inflate(inflater, parent, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun convert(
|
|
||||||
holder: ItemViewHolder,
|
|
||||||
binding: ItemTxtTocRuleBinding,
|
|
||||||
item: TxtTocRule,
|
|
||||||
payloads: MutableList<Any>
|
|
||||||
) {
|
|
||||||
binding.run {
|
|
||||||
if (payloads.isEmpty()) {
|
|
||||||
//root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f))
|
|
||||||
cbSource.text = item.name
|
|
||||||
swtEnabled.isChecked = item.enable
|
|
||||||
cbSource.isChecked = selected.contains(item)
|
|
||||||
titleExample.text = item.example
|
|
||||||
} else {
|
|
||||||
for (i in payloads.indices) {
|
|
||||||
val bundle = payloads[i] as Bundle
|
|
||||||
bundle.keySet().forEach {
|
|
||||||
when (it) {
|
|
||||||
"selected" -> cbSource.isChecked = selected.contains(item)
|
|
||||||
"upName" -> cbSource.text = item.name
|
|
||||||
"upExample" -> titleExample.text = item.example
|
|
||||||
"enabled" -> swtEnabled.isChecked = item.enable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun registerListener(holder: ItemViewHolder, binding: ItemTxtTocRuleBinding) {
|
|
||||||
binding.cbSource.setOnCheckedChangeListener { buttonView, isChecked ->
|
|
||||||
getItem(holder.layoutPosition)?.let {
|
|
||||||
if (buttonView.isPressed) {
|
|
||||||
if (isChecked) {
|
|
||||||
selected.add(it)
|
|
||||||
} else {
|
|
||||||
selected.remove(it)
|
|
||||||
}
|
|
||||||
callBack.upCountView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.swtEnabled.setOnCheckedChangeListener { buttonView, isChecked ->
|
|
||||||
getItem(holder.layoutPosition)?.let {
|
|
||||||
if (buttonView.isPressed) {
|
|
||||||
it.enable = isChecked
|
|
||||||
callBack.update(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.ivEdit.setOnClickListener {
|
|
||||||
getItem(holder.layoutPosition)?.let {
|
|
||||||
callBack.edit(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.ivMenuMore.setOnClickListener {
|
|
||||||
showMenu(it, holder.layoutPosition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCurrentListChanged() {
|
|
||||||
callBack.upCountView()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showMenu(view: View, position: Int) {
|
|
||||||
val source = getItem(position) ?: return
|
|
||||||
val popupMenu = PopupMenu(context, view)
|
|
||||||
popupMenu.inflate(R.menu.txt_toc_rule_item)
|
|
||||||
popupMenu.setOnMenuItemClickListener { menuItem ->
|
|
||||||
when (menuItem.itemId) {
|
|
||||||
R.id.menu_top -> callBack.toTop(source)
|
|
||||||
R.id.menu_bottom -> callBack.toBottom(source)
|
|
||||||
R.id.menu_del -> {
|
|
||||||
callBack.del(source)
|
|
||||||
selected.remove(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
popupMenu.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectAll() {
|
|
||||||
getItems().forEach {
|
|
||||||
selected.add(it)
|
|
||||||
}
|
|
||||||
notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
|
|
||||||
callBack.upCountView()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun revertSelection() {
|
|
||||||
getItems().forEach {
|
|
||||||
if (selected.contains(it)) {
|
|
||||||
selected.remove(it)
|
|
||||||
} else {
|
|
||||||
selected.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
|
|
||||||
callBack.upCountView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
|
|
||||||
val srcItem = getItem(srcPosition)
|
|
||||||
val targetItem = getItem(targetPosition)
|
|
||||||
if (srcItem != null && targetItem != null) {
|
|
||||||
if (srcItem.serialNumber == targetItem.serialNumber) {
|
|
||||||
callBack.upOrder()
|
|
||||||
} else {
|
|
||||||
val srcOrder = srcItem.serialNumber
|
|
||||||
srcItem.serialNumber = targetItem.serialNumber
|
|
||||||
targetItem.serialNumber = srcOrder
|
|
||||||
movedItems.add(srcItem)
|
|
||||||
movedItems.add(targetItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
swapItem(srcPosition, targetPosition)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private val movedItems = hashSetOf<TxtTocRule>()
|
|
||||||
|
|
||||||
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
|
||||||
if (movedItems.isNotEmpty()) {
|
|
||||||
callBack.update(*movedItems.toTypedArray())
|
|
||||||
movedItems.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val dragSelectCallback: DragSelectTouchHelper.Callback =
|
|
||||||
object : DragSelectTouchHelper.AdvanceCallback<TxtTocRule>(Mode.ToggleAndReverse) {
|
|
||||||
override fun currentSelectedId(): MutableSet<TxtTocRule> {
|
|
||||||
return selected
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemId(position: Int): TxtTocRule {
|
|
||||||
return getItem(position)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
|
|
||||||
getItem(position)?.let {
|
|
||||||
if (isSelected) {
|
|
||||||
selected.add(it)
|
|
||||||
} else {
|
|
||||||
selected.remove(it)
|
|
||||||
}
|
|
||||||
notifyItemChanged(position, bundleOf(Pair("selected", null)))
|
|
||||||
callBack.upCountView()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CallBack {
|
|
||||||
fun del(source: TxtTocRule)
|
|
||||||
fun edit(source: TxtTocRule)
|
|
||||||
fun update(vararg source: TxtTocRule)
|
|
||||||
fun toTop(source: TxtTocRule)
|
|
||||||
fun toBottom(source: TxtTocRule)
|
|
||||||
fun upOrder()
|
|
||||||
fun upCountView()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -149,14 +149,13 @@ class TxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_toc_regex),
|
|||||||
}
|
}
|
||||||
R.id.menu_import_onLine -> showImportDialog()
|
R.id.menu_import_onLine -> showImportDialog()
|
||||||
R.id.menu_import_qr -> qrCodeResult.launch()
|
R.id.menu_import_qr -> qrCodeResult.launch()
|
||||||
R.id.menu_import_default -> viewModel.importDefault()
|
|
||||||
R.id.menu_help -> showHelp("txtTocRuleHelp")
|
R.id.menu_help -> showHelp("txtTocRuleHelp")
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun saveTxtTocRule(txtTocRule: TxtTocRule) {
|
override fun saveTxtTocRule(txtTocRule: TxtTocRule) {
|
||||||
viewModel.save(txtTocRule)
|
viewModel.update(txtTocRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
@SuppressLint("InflateParams")
|
||||||
@@ -294,7 +293,7 @@ class TxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_toc_regex),
|
|||||||
setMessage(getString(R.string.sure_del) + "\n" + item.name)
|
setMessage(getString(R.string.sure_del) + "\n" + item.name)
|
||||||
noButton()
|
noButton()
|
||||||
yesButton {
|
yesButton {
|
||||||
viewModel.del(item)
|
viewModel.delete(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,151 @@
|
|||||||
package io.legado.app.ui.book.toc.rule
|
package io.legado.app.ui.book.toc.rule
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import io.legado.app.base.BaseViewModel
|
import androidx.compose.runtime.Immutable
|
||||||
import io.legado.app.data.appDb
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.legado.app.base.BaseRuleViewModel
|
||||||
import io.legado.app.data.entities.TxtTocRule
|
import io.legado.app.data.entities.TxtTocRule
|
||||||
import io.legado.app.help.DefaultData
|
import io.legado.app.data.repository.TxtTocRuleRepository
|
||||||
|
import io.legado.app.data.repository.UploadRepository
|
||||||
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
|
import io.legado.app.ui.widget.components.rules.InteractionState
|
||||||
|
import io.legado.app.ui.widget.components.rules.RuleActionState
|
||||||
|
import io.legado.app.ui.widget.components.rules.SelectableItem
|
||||||
|
import io.legado.app.utils.GSON
|
||||||
|
import io.legado.app.utils.fromJsonArray
|
||||||
|
import io.legado.app.utils.fromJsonObject
|
||||||
|
import io.legado.app.utils.getClipText
|
||||||
|
import io.legado.app.utils.isJsonArray
|
||||||
|
import io.legado.app.utils.isJsonObject
|
||||||
|
import io.legado.app.utils.sendToClip
|
||||||
|
import io.legado.app.utils.toastOnUi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class TxtTocRuleViewModel(app: Application) : BaseViewModel(app) {
|
@Immutable
|
||||||
|
data class TxtTocRuleItemUi(
|
||||||
fun save(txtTocRule: TxtTocRule) {
|
override val id: Long,
|
||||||
execute {
|
val name: String,
|
||||||
appDb.txtTocRuleDao.insert(txtTocRule)
|
val isEnabled: Boolean,
|
||||||
}
|
val rule: TxtTocRule,
|
||||||
}
|
val example: String = ""
|
||||||
|
) : SelectableItem<Long>
|
||||||
fun del(vararg txtTocRule: TxtTocRule) {
|
|
||||||
execute {
|
|
||||||
appDb.txtTocRuleDao.delete(*txtTocRule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(vararg txtTocRule: TxtTocRule) {
|
|
||||||
execute {
|
|
||||||
appDb.txtTocRuleDao.update(*txtTocRule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importDefault() {
|
|
||||||
execute {
|
|
||||||
DefaultData.importDefaultTocRules()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toTop(vararg rules: TxtTocRule) {
|
|
||||||
execute {
|
|
||||||
val minOrder = appDb.txtTocRuleDao.minOrder - 1
|
|
||||||
rules.forEachIndexed { index, source ->
|
|
||||||
source.serialNumber = minOrder - index
|
|
||||||
}
|
|
||||||
appDb.txtTocRuleDao.update(*rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toBottom(vararg sources: TxtTocRule) {
|
|
||||||
execute {
|
|
||||||
val maxOrder = appDb.txtTocRuleDao.maxOrder + 1
|
|
||||||
sources.forEachIndexed { index, source ->
|
|
||||||
source.serialNumber = maxOrder + index
|
|
||||||
}
|
|
||||||
appDb.txtTocRuleDao.update(*sources)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun upOrder() {
|
|
||||||
execute {
|
|
||||||
val sources = appDb.txtTocRuleDao.all
|
|
||||||
for ((index: Int, source: TxtTocRule) in sources.withIndex()) {
|
|
||||||
source.serialNumber = index + 1
|
|
||||||
}
|
|
||||||
appDb.txtTocRuleDao.update(*sources.toTypedArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enableSelection(vararg txtTocRule: TxtTocRule) {
|
|
||||||
execute {
|
|
||||||
val array = txtTocRule.map { it.copy(enable = true) }.toTypedArray()
|
|
||||||
appDb.txtTocRuleDao.insert(*array)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disableSelection(vararg txtTocRule: TxtTocRule) {
|
|
||||||
execute {
|
|
||||||
val array = txtTocRule.map { it.copy(enable = false) }.toTypedArray()
|
|
||||||
appDb.txtTocRuleDao.insert(*array)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
data class TxtTocRuleUiState(
|
||||||
|
override val items: List<TxtTocRuleItemUi> = emptyList(),
|
||||||
|
override val selectedIds: Set<Long> = emptySet(),
|
||||||
|
override val searchKey: String = "",
|
||||||
|
val interaction: InteractionState = InteractionState()
|
||||||
|
) : RuleActionState<TxtTocRuleItemUi> {
|
||||||
|
override val isSearch: Boolean get() = interaction.isSearchMode
|
||||||
|
override val isUploading: Boolean get() = interaction.isUploading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TxtTocRuleViewModel(
|
||||||
|
application: Application,
|
||||||
|
uploadRepository: UploadRepository
|
||||||
|
) : BaseRuleViewModel<TxtTocRuleItemUi, TxtTocRule, Long, TxtTocRuleUiState>(
|
||||||
|
application,
|
||||||
|
TxtTocRuleUiState(interaction = InteractionState(isLoading = true)),
|
||||||
|
uploadRepository
|
||||||
|
) {
|
||||||
|
private val repository = TxtTocRuleRepository()
|
||||||
|
|
||||||
|
override val rawDataFlow: Flow<List<TxtTocRule>> = repository.flowAll()
|
||||||
|
|
||||||
|
override fun TxtTocRule.toUiItem() =
|
||||||
|
TxtTocRuleItemUi(id, name, enable, this, example = example ?: "")
|
||||||
|
|
||||||
|
override fun filterData(data: List<TxtTocRule>, key: String): List<TxtTocRule> {
|
||||||
|
val filtered = if (key.isEmpty()) data
|
||||||
|
else data.filter { it.name.contains(key, ignoreCase = true) }
|
||||||
|
return filtered.sortedBy { it.serialNumber }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun composeUiState(
|
||||||
|
items: List<TxtTocRuleItemUi>,
|
||||||
|
selectedIds: Set<Long>,
|
||||||
|
isSearch: Boolean,
|
||||||
|
isUploading: Boolean,
|
||||||
|
importState: BaseImportUiState<TxtTocRule>
|
||||||
|
): TxtTocRuleUiState {
|
||||||
|
return TxtTocRuleUiState(
|
||||||
|
items = items,
|
||||||
|
selectedIds = selectedIds,
|
||||||
|
searchKey = _searchKey.value,
|
||||||
|
interaction = InteractionState(
|
||||||
|
isSearchMode = isSearch,
|
||||||
|
isUploading = isUploading || (importState is BaseImportUiState.Loading),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSortOrder() {
|
||||||
|
val currentLocal = _localItems.value ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveOrder(currentLocal.map { it.rule })
|
||||||
|
_localItems.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(vararg rules: TxtTocRule) = viewModelScope.launch { repository.update(*rules) }
|
||||||
|
fun insert(vararg rules: TxtTocRule) = viewModelScope.launch { repository.insert(*rules) }
|
||||||
|
fun delete(vararg rules: TxtTocRule) = viewModelScope.launch { repository.delete(*rules) }
|
||||||
|
|
||||||
|
fun enableSelectionByIds(ids: Set<Long>) =
|
||||||
|
viewModelScope.launch { repository.enableByIds(ids, true) }
|
||||||
|
|
||||||
|
fun disableSelectionByIds(ids: Set<Long>) =
|
||||||
|
viewModelScope.launch { repository.enableByIds(ids, false) }
|
||||||
|
|
||||||
|
fun delSelectionByIds(ids: Set<Long>) = viewModelScope.launch { repository.deleteByIds(ids) }
|
||||||
|
|
||||||
|
override suspend fun generateJson(entities: List<TxtTocRule>): String = GSON.toJson(entities)
|
||||||
|
|
||||||
|
override fun parseImportRules(text: String): List<TxtTocRule> {
|
||||||
|
return when {
|
||||||
|
text.isJsonArray() -> GSON.fromJsonArray<TxtTocRule>(text).getOrThrow()
|
||||||
|
text.isJsonObject() -> listOf(GSON.fromJsonObject<TxtTocRule>(text).getOrThrow())
|
||||||
|
else -> throw Exception("格式不正确")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasChanged(newRule: TxtTocRule, oldRule: TxtTocRule): Boolean {
|
||||||
|
return newRule.name != oldRule.name || newRule.rule != oldRule.rule || newRule.enable != oldRule.enable
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findOldRule(newRule: TxtTocRule): TxtTocRule? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun ruleItemToEntity(item: TxtTocRuleItemUi): TxtTocRule = item.rule
|
||||||
|
|
||||||
|
override fun saveImportedRules() {
|
||||||
|
val state = _importState.value as? BaseImportUiState.Success<TxtTocRule> ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val rulesToSave = state.items.filter { it.isSelected }.map { it.data }
|
||||||
|
repository.insert(*rulesToSave.toTypedArray())
|
||||||
|
_importState.value = BaseImportUiState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copyRule(rule: TxtTocRule) {
|
||||||
|
context.sendToClip(GSON.toJson(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pasteRule(): TxtTocRule? {
|
||||||
|
val text = context.getClipText()
|
||||||
|
if (text.isNullOrBlank()) {
|
||||||
|
context.toastOnUi("剪贴板没有内容")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
GSON.fromJsonObject<TxtTocRule>(text).getOrThrow()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
context.toastOnUi("格式不对")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +1,12 @@
|
|||||||
package io.legado.app.ui.dict.rule
|
package io.legado.app.ui.dict.rule
|
||||||
|
|
||||||
//import io.legado.app.lib.theme.primaryColor
|
//import io.legado.app.lib.theme.primaryColor
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import io.legado.app.R
|
|
||||||
import io.legado.app.base.BaseComposeActivity
|
import io.legado.app.base.BaseComposeActivity
|
||||||
import io.legado.app.databinding.DialogEditTextBinding
|
|
||||||
import io.legado.app.help.DirectLinkUpload
|
|
||||||
import io.legado.app.lib.dialogs.alert
|
|
||||||
import io.legado.app.ui.association.ImportDictRuleDialog
|
|
||||||
import io.legado.app.ui.file.HandleFileContract
|
|
||||||
import io.legado.app.ui.theme.AppTheme
|
import io.legado.app.ui.theme.AppTheme
|
||||||
import io.legado.app.utils.ACache
|
|
||||||
import io.legado.app.utils.isAbsUrl
|
|
||||||
import io.legado.app.utils.readText
|
|
||||||
import io.legado.app.utils.sendToClip
|
|
||||||
import io.legado.app.utils.showDialogFragment
|
|
||||||
import io.legado.app.utils.splitNotBlank
|
|
||||||
import io.legado.app.utils.toastOnUi
|
|
||||||
|
|
||||||
class DictRuleActivity : BaseComposeActivity() {
|
class DictRuleActivity : BaseComposeActivity() {
|
||||||
|
|
||||||
private val importRecordKey = "dictRuleUrls"
|
|
||||||
|
|
||||||
private val importDoc = registerForActivityResult(HandleFileContract()) {
|
|
||||||
kotlin.runCatching {
|
|
||||||
it.uri?.readText(this)?.let {
|
|
||||||
showDialogFragment(
|
|
||||||
ImportDictRuleDialog(it)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
toastOnUi("readTextError:${it.localizedMessage}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private val exportResult = registerForActivityResult(HandleFileContract()) {
|
|
||||||
it.uri?.let { uri ->
|
|
||||||
alert(R.string.export_success) {
|
|
||||||
if (uri.toString().isAbsUrl()) {
|
|
||||||
setMessage(DirectLinkUpload.getSummary())
|
|
||||||
}
|
|
||||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
|
||||||
editView.hint = getString(R.string.path)
|
|
||||||
editView.setText(uri.toString())
|
|
||||||
}
|
|
||||||
customView { alertBinding.root }
|
|
||||||
okButton {
|
|
||||||
sendToClip(uri.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
//observeDictRuleData()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content() {
|
override fun Content() {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
@@ -66,36 +14,4 @@ class DictRuleActivity : BaseComposeActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
private fun showImportDialog() {
|
|
||||||
val aCache = ACache.get(cacheDir = false)
|
|
||||||
val cacheUrls: MutableList<String> = aCache
|
|
||||||
.getAsString(importRecordKey)
|
|
||||||
?.splitNotBlank(",")
|
|
||||||
?.toMutableList() ?: mutableListOf()
|
|
||||||
alert(titleResource = R.string.import_on_line) {
|
|
||||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
|
||||||
editView.hint = "url"
|
|
||||||
editView.setFilterValues(cacheUrls)
|
|
||||||
editView.delCallBack = {
|
|
||||||
cacheUrls.remove(it)
|
|
||||||
aCache.put(importRecordKey, cacheUrls.joinToString(","))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
customView { alertBinding.root }
|
|
||||||
okButton {
|
|
||||||
val text = alertBinding.editView.text?.toString()
|
|
||||||
text?.let {
|
|
||||||
if (it.isAbsUrl() && !cacheUrls.contains(it)) {
|
|
||||||
cacheUrls.add(0, it)
|
|
||||||
aCache.put(importRecordKey, cacheUrls.joinToString(","))
|
|
||||||
}
|
|
||||||
showDialogFragment(
|
|
||||||
ImportDictRuleDialog(it)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cancelButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,96 +3,63 @@ package io.legado.app.ui.dict.rule
|
|||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
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.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.FloatingToolbarDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.PlainTooltip
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarResult
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TooltipAnchorPosition
|
|
||||||
import androidx.compose.material3.TooltipBox
|
|
||||||
import androidx.compose.material3.TooltipDefaults
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.animateFloatingActionButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.rememberTooltipState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
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.graphics.Color
|
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.google.gson.Gson
|
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
|
import io.legado.app.base.BaseRuleEvent
|
||||||
import io.legado.app.data.entities.DictRule
|
import io.legado.app.data.entities.DictRule
|
||||||
import io.legado.app.data.repository.UploadRepository
|
|
||||||
import io.legado.app.ui.widget.components.ActionItem
|
import io.legado.app.ui.widget.components.ActionItem
|
||||||
import io.legado.app.ui.widget.components.AnimatedText
|
|
||||||
import io.legado.app.ui.widget.components.DraggableSelectionHandler
|
import io.legado.app.ui.widget.components.DraggableSelectionHandler
|
||||||
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
import io.legado.app.ui.widget.components.button.SmallIconButton
|
||||||
import io.legado.app.ui.widget.components.ReorderableSelectionItem
|
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
|
||||||
import io.legado.app.ui.widget.components.SearchBarSection
|
|
||||||
import io.legado.app.ui.widget.components.SelectionBottomBar
|
|
||||||
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
|
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
|
||||||
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
|
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
|
||||||
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
|
import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
|
||||||
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
|
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
|
||||||
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
||||||
import io.legado.app.utils.GSON
|
import io.legado.app.ui.widget.components.rules.RuleEditFields
|
||||||
import kotlinx.coroutines.launch
|
import io.legado.app.ui.widget.components.rules.RuleEditSheet
|
||||||
|
import io.legado.app.ui.widget.components.rules.RuleListScaffold
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import org.koin.compose.koinInject
|
|
||||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@@ -102,23 +69,22 @@ fun DictRuleScreen(
|
|||||||
onBackClick: () -> Unit
|
onBackClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val uploadRepository: UploadRepository = koinInject()
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val rules = uiState.items
|
val rules = uiState.items
|
||||||
val listState = rememberLazyListState()
|
val selectedIds = uiState.selectedIds
|
||||||
var isSearch by remember { mutableStateOf(false) }
|
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
|
||||||
val selectedIds by viewModel.selectedIds.collectAsState()
|
|
||||||
val inSelectionMode = selectedIds.isNotEmpty()
|
val inSelectionMode = selectedIds.isNotEmpty()
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
var showDeleteSelectedDialog by remember { mutableStateOf(false) }
|
|
||||||
var showEditSheet by remember { mutableStateOf(false) }
|
var showEditSheet by remember { mutableStateOf(false) }
|
||||||
var editingRule by remember { mutableStateOf<DictRule?>(null) }
|
var editingRule by remember { mutableStateOf<DictRule?>(null) }
|
||||||
var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(null) }
|
var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(null) }
|
||||||
|
var showUrlInput by remember { mutableStateOf(false) }
|
||||||
|
var showFilePickerSheet by remember { mutableStateOf(false) }
|
||||||
|
var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
|
||||||
|
|
||||||
val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
|
val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
|
||||||
viewModel.moveItemInList(from.index, to.index)
|
viewModel.moveItemInList(from.index, to.index)
|
||||||
@@ -127,13 +93,33 @@ fun DictRuleScreen(
|
|||||||
|
|
||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showUrlInput by remember { mutableStateOf(false) }
|
|
||||||
var showFilePickerSheet by remember { mutableStateOf(false) }
|
|
||||||
var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
|
|
||||||
var isUploading by remember { mutableStateOf(false) }
|
|
||||||
val importState by viewModel.importState.collectAsStateWithLifecycle()
|
val importState by viewModel.importState.collectAsStateWithLifecycle()
|
||||||
val sheetState = rememberModalBottomSheetState()
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is BaseRuleEvent.ShowSnackbar -> {
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = event.message,
|
||||||
|
actionLabel = event.actionLabel,
|
||||||
|
withDismissAction = true
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed && event.url != null) {
|
||||||
|
clipboardManager.setClipEntry(
|
||||||
|
ClipEntry(
|
||||||
|
ClipData.newPlainText(
|
||||||
|
"url",
|
||||||
|
event.url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val importDoc = rememberLauncherForActivityResult(
|
val importDoc = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
onResult = { uri ->
|
onResult = { uri ->
|
||||||
@@ -149,18 +135,7 @@ fun DictRuleScreen(
|
|||||||
val exportDoc = rememberLauncherForActivityResult(
|
val exportDoc = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument("application/json"),
|
contract = ActivityResultContracts.CreateDocument("application/json"),
|
||||||
onResult = { uri ->
|
onResult = { uri ->
|
||||||
uri?.let { it ->
|
uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
|
||||||
scope.launch {
|
|
||||||
val rulesToExport = rules
|
|
||||||
.filter { selectedIds.contains(it.name) }
|
|
||||||
.map { it.rule }
|
|
||||||
|
|
||||||
val json = Gson().toJson(rulesToExport)
|
|
||||||
context.contentResolver.openOutputStream(it)?.use { stream ->
|
|
||||||
stream.writer().write(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -187,39 +162,7 @@ fun DictRuleScreen(
|
|||||||
onSelectSysFile = {},
|
onSelectSysFile = {},
|
||||||
onUpload = {
|
onUpload = {
|
||||||
showFilePickerSheet = false
|
showFilePickerSheet = false
|
||||||
scope.launch {
|
viewModel.uploadSelectedRules(selectedIds, rules)
|
||||||
val selectedRules = viewModel.getSelectedRules()
|
|
||||||
val json = GSON.toJson(selectedRules)
|
|
||||||
|
|
||||||
isUploading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
runCatching {
|
|
||||||
uploadRepository.upload(
|
|
||||||
fileName = "exportDictRule.json",
|
|
||||||
file = json,
|
|
||||||
contentType = "application/json"
|
|
||||||
)
|
|
||||||
}.onSuccess { url ->
|
|
||||||
isUploading = false
|
|
||||||
val result = snackbarHostState.showSnackbar(
|
|
||||||
message = "上传成功: $url",
|
|
||||||
actionLabel = "复制链接",
|
|
||||||
withDismissAction = true
|
|
||||||
)
|
|
||||||
if (result == SnackbarResult.ActionPerformed) {
|
|
||||||
clipboardManager.setClipEntry(
|
|
||||||
ClipEntry(ClipData.newPlainText("export url", url))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
isUploading = false
|
|
||||||
snackbarHostState.showSnackbar("上传失败: ${e.localizedMessage}")
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isUploading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
allowExtensions = arrayOf("json")
|
allowExtensions = arrayOf("json")
|
||||||
)
|
)
|
||||||
@@ -248,220 +191,143 @@ fun DictRuleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDeleteRuleDialog != null) {
|
showDeleteRuleDialog?.let { rule ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteRuleDialog = null },
|
onDismissRequest = { showDeleteRuleDialog = null },
|
||||||
title = { Text(stringResource(R.string.delete)) },
|
title = { Text(stringResource(R.string.delete)) },
|
||||||
text = { Text(stringResource(R.string.sure_del) + showDeleteRuleDialog!!.name) },
|
text = { Text(stringResource(R.string.del_msg)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
OutlinedButton(
|
OutlinedButton(onClick = {
|
||||||
onClick = {
|
viewModel.delete(rule); showDeleteRuleDialog = null
|
||||||
viewModel.delete(showDeleteRuleDialog!!)
|
}) { Text(stringResource(R.string.ok)) }
|
||||||
showDeleteRuleDialog = null
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error,
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.ok))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showDeleteRuleDialog = null }) {
|
TextButton(onClick = { showDeleteRuleDialog = null }) {
|
||||||
Text(stringResource(R.string.cancel))
|
Text(
|
||||||
}
|
stringResource(R.string.cancel)
|
||||||
}
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDeleteSelectedDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showDeleteSelectedDialog = false },
|
|
||||||
title = { Text(stringResource(R.string.delete)) },
|
|
||||||
text = { Text(stringResource(R.string.del_msg)) },
|
|
||||||
confirmButton = {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
viewModel.delSelectionByIds(selectedIds)
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
showDeleteSelectedDialog = false
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error,
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDeleteSelectedDialog = false }) {
|
|
||||||
Text(stringResource(R.string.cancel))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showEditSheet) {
|
if (showEditSheet) {
|
||||||
DictRuleEditSheet(
|
RuleEditSheet(
|
||||||
rule = editingRule,
|
rule = editingRule,
|
||||||
onDismissRequest = { showEditSheet = false },
|
title = stringResource(R.string.dict_rule),
|
||||||
onSave = {
|
label1 = stringResource(R.string.url_rule),
|
||||||
if (editingRule == null) {
|
label2 = stringResource(R.string.show_rule),
|
||||||
viewModel.insert(it)
|
onDismissRequest = {
|
||||||
} else {
|
|
||||||
viewModel.update(it)
|
|
||||||
}
|
|
||||||
showEditSheet = false
|
showEditSheet = false
|
||||||
|
editingRule = null
|
||||||
|
},
|
||||||
|
onSave = { updatedRule ->
|
||||||
|
viewModel.update(updatedRule)
|
||||||
|
showEditSheet = false
|
||||||
|
editingRule = null
|
||||||
},
|
},
|
||||||
onCopy = { viewModel.copyRule(it) },
|
onCopy = { viewModel.copyRule(it) },
|
||||||
onPaste = { viewModel.pasteRule() }
|
onPaste = { viewModel.pasteRule() },
|
||||||
|
toFields = { r ->
|
||||||
|
RuleEditFields(
|
||||||
|
name = r?.name ?: "",
|
||||||
|
rule1 = r?.urlRule ?: "",
|
||||||
|
extra = r?.showRule ?: ""
|
||||||
|
)
|
||||||
|
},
|
||||||
|
fromFields = { fields, old ->
|
||||||
|
old?.copy(
|
||||||
|
name = fields.name,
|
||||||
|
urlRule = fields.rule1,
|
||||||
|
showRule = fields.extra
|
||||||
|
) ?: DictRule(
|
||||||
|
name = fields.name,
|
||||||
|
urlRule = fields.rule1,
|
||||||
|
showRule = fields.extra
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
RuleListScaffold(
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
title = "字典规则",
|
||||||
topBar = {
|
state = uiState,
|
||||||
Column {
|
onBackClick = { onBackClick() },
|
||||||
GlassMediumFlexibleTopAppBar(
|
onSearchToggle = { active ->
|
||||||
title = {
|
viewModel.setSearchMode(active)
|
||||||
val titleText = remember(isUploading, inSelectionMode, selectedIds, rules) {
|
|
||||||
when {
|
|
||||||
isUploading -> "正在上传..."
|
|
||||||
inSelectionMode -> "已选择 ${selectedIds.size}/${rules.size}"
|
|
||||||
else -> "字典规则"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AnimatedText(
|
|
||||||
text = titleText
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
if (inSelectionMode) {
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
} else {
|
|
||||||
onBackClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (inSelectionMode) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = if (inSelectionMode) "Cancel" else "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (!inSelectionMode) {
|
|
||||||
IconButton(onClick = { isSearch = !isSearch }) {
|
|
||||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
|
||||||
}
|
|
||||||
IconButton(onClick = { showMenu = !showMenu }) {
|
|
||||||
Icon(Icons.Default.MoreVert, contentDescription = "More")
|
|
||||||
}
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showMenu,
|
|
||||||
onDismissRequest = { showMenu = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("在线导入") },
|
|
||||||
onClick = {
|
|
||||||
showMenu = false
|
|
||||||
showUrlInput = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("本地导入") },
|
|
||||||
onClick = {
|
|
||||||
importDoc.launch(
|
|
||||||
arrayOf(
|
|
||||||
"text/plain",
|
|
||||||
"application/json"
|
|
||||||
)
|
|
||||||
); showMenu = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
AnimatedVisibility(visible = isSearch && !inSelectionMode) {
|
|
||||||
SearchBarSection(
|
|
||||||
query = uiState.searchKey ?: "",
|
|
||||||
onQueryChange = {
|
|
||||||
viewModel.setSearchKey(it)
|
|
||||||
},
|
|
||||||
placeholder = stringResource(id = R.string.search)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
onSearchQueryChange = { viewModel.setSearchKey(it) },
|
||||||
TooltipBox(
|
searchPlaceholder = stringResource(R.string.replace_purify_search),
|
||||||
positionProvider =
|
onClearSelection = { viewModel.setSelection(emptySet()) },
|
||||||
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
|
||||||
tooltip = { PlainTooltip { Text("Localized description") } },
|
onSelectInvert = {
|
||||||
state = rememberTooltipState(),
|
val allIds = rules.map { it.id }.toSet()
|
||||||
) {
|
viewModel.setSelection(allIds - selectedIds)
|
||||||
FloatingActionButton(
|
},
|
||||||
modifier = Modifier.animateFloatingActionButton(
|
selectionSecondaryActions = listOf(
|
||||||
visible = !inSelectionMode,
|
ActionItem(text = stringResource(R.string.enable), onClick = {
|
||||||
alignment = Alignment.BottomEnd,
|
viewModel.enableSelectionByIds(selectedIds)
|
||||||
),
|
viewModel.setSelection(emptySet())
|
||||||
onClick = {
|
}),
|
||||||
editingRule = null
|
ActionItem(text = stringResource(R.string.disable_selection), onClick = {
|
||||||
showEditSheet = true
|
viewModel.disableSelectionByIds(selectedIds)
|
||||||
}
|
viewModel.setSelection(emptySet())
|
||||||
) {
|
}),
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add Rule")
|
ActionItem(
|
||||||
}
|
text = stringResource(R.string.export),
|
||||||
}
|
onClick = { showFilePickerSheet = true })
|
||||||
|
),
|
||||||
|
onDeleteSelected = { ids ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
viewModel.delSelectionByIds(ids as Set<String>)
|
||||||
|
viewModel.setSelection(emptySet())
|
||||||
|
},
|
||||||
|
onAddClick = {
|
||||||
|
editingRule = null
|
||||||
|
showEditSheet = true
|
||||||
|
},
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
dropDownMenuContent = { dismiss ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("在线导入") },
|
||||||
|
onClick = { dismiss(); showUrlInput = true })
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("本地导入") },
|
||||||
|
onClick = {
|
||||||
|
dismiss(); importDoc.launch(
|
||||||
|
arrayOf(
|
||||||
|
"text/plain",
|
||||||
|
"application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Box(
|
Box(modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxSize()
|
||||||
.fillMaxSize()
|
.padding(paddingValues)) {
|
||||||
.padding(paddingValues)
|
|
||||||
) {
|
|
||||||
FastScrollLazyColumn(
|
FastScrollLazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
state = listState,
|
state = listState,
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(top = 8.dp, bottom = 120.dp),
|
||||||
top = 8.dp,
|
|
||||||
bottom = 120.dp
|
|
||||||
),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(rules, key = { it.name }) { item ->
|
items(rules, key = { it.id }) { item ->
|
||||||
ReorderableSelectionItem(
|
ReorderableSelectionItem(
|
||||||
state = reorderableState,
|
state = reorderableState,
|
||||||
key = item.name,
|
key = item.id,
|
||||||
title = item.name,
|
title = item.id,
|
||||||
isEnabled = item.isEnabled,
|
isEnabled = item.isEnabled,
|
||||||
isSelected = selectedIds.contains(item.name),
|
isSelected = selectedIds.contains(item.id),
|
||||||
inSelectionMode = inSelectionMode,
|
inSelectionMode = inSelectionMode,
|
||||||
onToggleSelection = {
|
onToggleSelection = { viewModel.toggleSelection(item.id) },
|
||||||
viewModel.toggleSelection(item.name)
|
onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enabled = enabled)) },
|
||||||
},
|
onClickEdit = { editingRule = item.rule; showEditSheet = true },
|
||||||
onEnabledChange = { enabled ->
|
|
||||||
viewModel.update(item.rule.copy(enabled = enabled))
|
|
||||||
},
|
|
||||||
onClickEdit = {
|
|
||||||
editingRule = item.rule
|
|
||||||
showEditSheet = true
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
trailingAction = {
|
trailingAction = {
|
||||||
IconButton(
|
SmallIconButton(
|
||||||
onClick = {
|
onClick = { showDeleteRuleDialog = item.rule },
|
||||||
showDeleteRuleDialog = item.rule
|
icon = Icons.Default.Delete
|
||||||
}
|
)
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -471,60 +337,14 @@ fun DictRuleScreen(
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
items = rules,
|
items = rules,
|
||||||
selectedIds = selectedIds,
|
selectedIds = selectedIds,
|
||||||
onSelectionChange = viewModel::setSelection,
|
onSelectionChange = { viewModel.setSelection(it) },
|
||||||
idProvider = { it.name },
|
idProvider = { it.id },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(60.dp)
|
.width(60.dp)
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
|
||||||
visible = inSelectionMode,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.offset(y = -FloatingToolbarDefaults.ScreenOffset)
|
|
||||||
.zIndex(1f),
|
|
||||||
enter = slideInVertically { it } + fadeIn(),
|
|
||||||
exit = slideOutVertically { it } + fadeOut()
|
|
||||||
) {
|
|
||||||
SelectionBottomBar(
|
|
||||||
onSelectAll = {
|
|
||||||
viewModel.setSelection(rules.map { it.name }.toSet())
|
|
||||||
},
|
|
||||||
onSelectInvert = {
|
|
||||||
val allIds = rules.map { it.name }.toSet()
|
|
||||||
viewModel.setSelection(allIds - selectedIds)
|
|
||||||
},
|
|
||||||
primaryAction = ActionItem(
|
|
||||||
text = stringResource(R.string.delete),
|
|
||||||
icon = { Icon(Icons.Default.Delete, null) },
|
|
||||||
onClick = { showDeleteSelectedDialog = true }
|
|
||||||
),
|
|
||||||
secondaryActions = listOf(
|
|
||||||
ActionItem(
|
|
||||||
text = stringResource(R.string.enable),
|
|
||||||
onClick = {
|
|
||||||
viewModel.enableSelectionByIds(selectedIds)
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
}
|
|
||||||
),
|
|
||||||
ActionItem(
|
|
||||||
text = stringResource(R.string.disable_selection),
|
|
||||||
onClick = {
|
|
||||||
viewModel.disableSelectionByIds(selectedIds)
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
}
|
|
||||||
),
|
|
||||||
ActionItem(
|
|
||||||
text = stringResource(R.string.export),
|
|
||||||
onClick = { showFilePickerSheet = true }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,245 +2,91 @@ package io.legado.app.ui.dict.rule
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.base.BaseViewModel
|
import io.legado.app.base.BaseRuleViewModel
|
||||||
import io.legado.app.constant.AppConst
|
|
||||||
import io.legado.app.data.entities.DictRule
|
import io.legado.app.data.entities.DictRule
|
||||||
import io.legado.app.data.repository.DictRuleRepository
|
import io.legado.app.data.repository.DictRuleRepository
|
||||||
import io.legado.app.help.http.decompressed
|
import io.legado.app.data.repository.UploadRepository
|
||||||
import io.legado.app.help.http.newCallResponseBody
|
|
||||||
import io.legado.app.help.http.okHttpClient
|
|
||||||
import io.legado.app.help.http.text
|
|
||||||
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
|
import io.legado.app.ui.widget.components.rules.InteractionState
|
||||||
import io.legado.app.ui.widget.components.importComponents.ImportStatus
|
import io.legado.app.ui.widget.components.rules.RuleActionState
|
||||||
|
import io.legado.app.ui.widget.components.rules.SelectableItem
|
||||||
import io.legado.app.utils.GSON
|
import io.legado.app.utils.GSON
|
||||||
import io.legado.app.utils.fromJsonArray
|
import io.legado.app.utils.fromJsonArray
|
||||||
import io.legado.app.utils.fromJsonObject
|
import io.legado.app.utils.fromJsonObject
|
||||||
import io.legado.app.utils.getClipText
|
import io.legado.app.utils.getClipText
|
||||||
import io.legado.app.utils.isAbsUrl
|
|
||||||
import io.legado.app.utils.isJsonArray
|
import io.legado.app.utils.isJsonArray
|
||||||
import io.legado.app.utils.isJsonObject
|
import io.legado.app.utils.isJsonObject
|
||||||
import io.legado.app.utils.isUri
|
|
||||||
import io.legado.app.utils.readText
|
|
||||||
import io.legado.app.utils.sendToClip
|
import io.legado.app.utils.sendToClip
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
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.flowOn
|
|
||||||
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
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class DictRuleItemUi(
|
data class DictRuleItemUi(
|
||||||
val name: String,
|
override val id: String,
|
||||||
val urlRule: String,
|
val urlRule: String,
|
||||||
val showRule: String,
|
val showRule: String,
|
||||||
val isEnabled: Boolean,
|
val isEnabled: Boolean,
|
||||||
val rule: DictRule
|
val rule: DictRule
|
||||||
)
|
) : SelectableItem<String>
|
||||||
|
|
||||||
data class DictRuleUiState(
|
data class DictRuleUiState(
|
||||||
val searchKey: String? = null,
|
override val items: List<DictRuleItemUi> = emptyList(),
|
||||||
val items: List<DictRuleItemUi> = emptyList(),
|
override val selectedIds: Set<String> = emptySet(),
|
||||||
val dictRule: List<DictRule> = emptyList(),
|
override val searchKey: String = "",
|
||||||
val selectedIds: Set<String> = emptySet(),
|
val interaction: InteractionState = InteractionState()
|
||||||
val isLoading: Boolean = false
|
) : RuleActionState<DictRuleItemUi> {
|
||||||
)
|
override val isSearch: Boolean get() = interaction.isSearchMode
|
||||||
|
override val isUploading: Boolean get() = interaction.isUploading
|
||||||
class DictRuleViewModel(application: Application) : BaseViewModel(application) {
|
}
|
||||||
|
|
||||||
|
class DictRuleViewModel(
|
||||||
|
application: Application,
|
||||||
|
uploadRepository: UploadRepository
|
||||||
|
) : BaseRuleViewModel<DictRuleItemUi, DictRule, String, DictRuleUiState>(
|
||||||
|
application,
|
||||||
|
DictRuleUiState(interaction = InteractionState(isLoading = true)),
|
||||||
|
uploadRepository
|
||||||
|
) {
|
||||||
private val repository = DictRuleRepository()
|
private val repository = DictRuleRepository()
|
||||||
private val _searchKey = MutableStateFlow<String?>(null)
|
|
||||||
private val _uiRules = MutableStateFlow<List<DictRuleItemUi>>(emptyList())
|
|
||||||
|
|
||||||
private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
|
override val rawDataFlow: Flow<List<DictRule>> = repository.flowAll()
|
||||||
val selectedIds: StateFlow<Set<String>> = _selectedIds
|
|
||||||
private val _importState = MutableStateFlow<BaseImportUiState<DictRule>>(BaseImportUiState.Idle)
|
override fun filterData(data: List<DictRule>, key: String): List<DictRule> {
|
||||||
val importState = _importState.asStateFlow()
|
val filtered = if (key.isEmpty()) data
|
||||||
fun toggleSelection(id: String) {
|
else data.filter { it.name.contains(key, ignoreCase = true) }
|
||||||
_selectedIds.update {
|
return filtered.sortedBy { it.sortNumber }
|
||||||
if (it.contains(id)) it - id else it + id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
override fun composeUiState(
|
||||||
private val rulesFlow = _searchKey.flatMapLatest { searchKey ->
|
items: List<DictRuleItemUi>,
|
||||||
val baseFlow = if (searchKey.isNullOrEmpty()) {
|
selectedIds: Set<String>,
|
||||||
repository.flowAll()
|
isSearch: Boolean,
|
||||||
} else {
|
isUploading: Boolean,
|
||||||
repository.flowSearch("%$searchKey%")
|
importState: BaseImportUiState<DictRule>
|
||||||
}
|
): DictRuleUiState {
|
||||||
|
return DictRuleUiState(
|
||||||
baseFlow.map { rules ->
|
items = items,
|
||||||
rules.sortedBy { it.sortNumber }
|
|
||||||
}
|
|
||||||
}.flowOn(Dispatchers.Default)
|
|
||||||
|
|
||||||
private val ruleUiFlow: Flow<List<DictRuleItemUi>> =
|
|
||||||
rulesFlow.map { rules ->
|
|
||||||
rules.map { rule ->
|
|
||||||
DictRuleItemUi(
|
|
||||||
name = rule.name,
|
|
||||||
urlRule = rule.urlRule,
|
|
||||||
showRule = rule.showRule,
|
|
||||||
isEnabled = rule.enabled,
|
|
||||||
rule = rule
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val uiState: StateFlow<DictRuleUiState> = combine(
|
|
||||||
_searchKey,
|
|
||||||
_uiRules,
|
|
||||||
_selectedIds
|
|
||||||
) { searchKey, rules, selectedIds ->
|
|
||||||
DictRuleUiState(
|
|
||||||
searchKey = searchKey,
|
|
||||||
items = rules,
|
|
||||||
selectedIds = selectedIds,
|
selectedIds = selectedIds,
|
||||||
isLoading = false
|
searchKey = _searchKey.value,
|
||||||
|
interaction = InteractionState(
|
||||||
|
isSearchMode = isSearch,
|
||||||
|
isUploading = isUploading || (importState is BaseImportUiState.Loading),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
|
||||||
initialValue = DictRuleUiState(isLoading = true)
|
|
||||||
)
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
ruleUiFlow.collect { rules ->
|
|
||||||
_uiRules.value = rules
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSearchKey(key: String?) {
|
override fun DictRule.toUiItem() = DictRuleItemUi(name, urlRule, showRule, enabled, this)
|
||||||
_searchKey.value = key
|
override fun ruleItemToEntity(item: DictRuleItemUi): DictRule = item.rule
|
||||||
}
|
|
||||||
|
|
||||||
fun setSelection(ids: Set<String>) {
|
override suspend fun generateJson(entities: List<DictRule>): String = GSON.toJson(entities)
|
||||||
_selectedIds.value = ids
|
|
||||||
}
|
|
||||||
|
|
||||||
|
override fun parseImportRules(text: String): List<DictRule> {
|
||||||
fun enableSelectionByIds(ids: Set<String>) {
|
|
||||||
execute {
|
|
||||||
repository.enableByIds(ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disableSelectionByIds(ids: Set<String>) {
|
|
||||||
execute {
|
|
||||||
repository.disableByIds(ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delSelectionByIds(ids: Set<String>) {
|
|
||||||
execute {
|
|
||||||
repository.deleteByIds(ids)
|
|
||||||
_selectedIds.update { it - ids }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(vararg rule: DictRule) {
|
|
||||||
execute {
|
|
||||||
repository.update(*rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun insert(vararg rule: DictRule) {
|
|
||||||
execute {
|
|
||||||
repository.insert(*rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun moveItemInList(fromIndex: Int, toIndex: Int) {
|
|
||||||
_uiRules.update { currentList ->
|
|
||||||
val list = currentList.toMutableList()
|
|
||||||
val item = list.removeAt(fromIndex)
|
|
||||||
list.add(toIndex, item)
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveSortOrder() {
|
|
||||||
val currentRules = _uiRules.value
|
|
||||||
execute {
|
|
||||||
repository.moveOrder(currentRules.map { it.rule })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delete(vararg dictRule: DictRule) {
|
|
||||||
execute {
|
|
||||||
repository.delete(*dictRule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importSource(text: String) {
|
|
||||||
_importState.value = BaseImportUiState.Loading
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
val jsonText = resolveSource(text.trim())
|
|
||||||
val rules = parseDictRules(jsonText)
|
|
||||||
val wrappers = rules.map { newRule ->
|
|
||||||
val oldRule = repository.findById(newRule.name)
|
|
||||||
|
|
||||||
val status = when {
|
|
||||||
oldRule == null -> ImportStatus.New
|
|
||||||
hasChanged(newRule, oldRule) -> ImportStatus.Update
|
|
||||||
else -> ImportStatus.Existing
|
|
||||||
}
|
|
||||||
|
|
||||||
ImportItemWrapper(
|
|
||||||
data = newRule,
|
|
||||||
oldData = oldRule,
|
|
||||||
status = status,
|
|
||||||
isSelected = status != ImportStatus.Existing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
_importState.value = BaseImportUiState.Success(
|
|
||||||
source = text,
|
|
||||||
items = wrappers
|
|
||||||
)
|
|
||||||
}.onFailure {
|
|
||||||
it.printStackTrace()
|
|
||||||
_importState.value = BaseImportUiState.Error(it.localizedMessage ?: "Unknown Error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun resolveSource(text: String): String {
|
|
||||||
return when {
|
|
||||||
text.isAbsUrl() -> {
|
|
||||||
okHttpClient.newCallResponseBody {
|
|
||||||
if (text.endsWith("#requestWithoutUA")) {
|
|
||||||
url(text.substringBeforeLast("#requestWithoutUA"))
|
|
||||||
header(AppConst.UA_NAME, "null")
|
|
||||||
} else {
|
|
||||||
url(text)
|
|
||||||
}
|
|
||||||
}.decompressed().text("utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
text.isUri() -> text.toUri().readText(context)
|
|
||||||
else -> text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseDictRules(text: String): List<DictRule> {
|
|
||||||
return when {
|
return when {
|
||||||
text.isJsonArray() -> GSON.fromJsonArray<DictRule>(text).getOrThrow()
|
text.isJsonArray() -> GSON.fromJsonArray<DictRule>(text).getOrThrow()
|
||||||
text.isJsonObject() -> listOf(GSON.fromJsonObject<DictRule>(text).getOrThrow())
|
text.isJsonObject() -> listOf(GSON.fromJsonObject<DictRule>(text).getOrThrow())
|
||||||
@@ -248,50 +94,57 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasChanged(newRule: DictRule, oldRule: DictRule): Boolean {
|
override fun hasChanged(newRule: DictRule, oldRule: DictRule): Boolean {
|
||||||
return newRule.name != oldRule.name
|
return newRule.name != oldRule.name
|
||||||
|| newRule.urlRule != oldRule.urlRule
|
|| newRule.urlRule != oldRule.urlRule
|
||||||
|| newRule.showRule != oldRule.showRule
|
|| newRule.showRule != oldRule.showRule
|
||||||
|
|| newRule.enabled != oldRule.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleImportSelection(index: Int) {
|
override suspend fun findOldRule(newRule: DictRule): DictRule? {
|
||||||
val currentState = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
|
return repository.findById(newRule.name)
|
||||||
val newItems = currentState.items.toMutableList()
|
|
||||||
val item = newItems[index]
|
|
||||||
newItems[index] = item.copy(isSelected = !item.isSelected)
|
|
||||||
_importState.value = currentState.copy(items = newItems)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleImportAll(isSelected: Boolean) {
|
override fun saveImportedRules() {
|
||||||
val currentState = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
|
|
||||||
val newItems = currentState.items.map { it.copy(isSelected = isSelected) }
|
|
||||||
_importState.value = currentState.copy(items = newItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveImportedRules() {
|
|
||||||
val state = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
|
val state = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val rulesToSave = state.items
|
val rulesToSave = state.items
|
||||||
.filter { it.isSelected }
|
.filter { it.isSelected }
|
||||||
.map { it.data }
|
.map { it.data }
|
||||||
|
|
||||||
repository.insert(*rulesToSave.toTypedArray())
|
repository.insert(*rulesToSave.toTypedArray())
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
_importState.value = BaseImportUiState.Idle
|
_importState.value = BaseImportUiState.Idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelImport() {
|
fun saveSortOrder() {
|
||||||
_importState.value = BaseImportUiState.Idle
|
val currentLocal = _localItems.value ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.moveOrder(currentLocal.map { it.rule })
|
||||||
|
_localItems.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSelectedRules(): List<DictRule> {
|
fun enableSelectionByIds(ids: Set<String>) {
|
||||||
val selectedIds = _selectedIds.value
|
viewModelScope.launch { repository.enableByIds(ids) }
|
||||||
return repository.getByNames(selectedIds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun disableSelectionByIds(ids: Set<String>) {
|
||||||
|
viewModelScope.launch { repository.disableByIds(ids) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delSelectionByIds(ids: Set<String>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteByIds(ids)
|
||||||
|
_selectedIds.update { it - ids }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(vararg rule: DictRule) = viewModelScope.launch { repository.update(*rule) }
|
||||||
|
fun insert(vararg rule: DictRule) = viewModelScope.launch { repository.insert(*rule) }
|
||||||
|
fun delete(vararg dictRule: DictRule) = viewModelScope.launch { repository.delete(*dictRule) }
|
||||||
|
|
||||||
fun copyRule(dictRule: DictRule) {
|
fun copyRule(dictRule: DictRule) {
|
||||||
context.sendToClip(GSON.toJson(dictRule))
|
context.sendToClip(GSON.toJson(dictRule))
|
||||||
}
|
}
|
||||||
@@ -309,5 +162,4 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
@@ -29,7 +29,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
import io.legado.app.ui.widget.components.GlassCard
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
|
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|||||||
@@ -3,13 +3,8 @@ package io.legado.app.ui.replace
|
|||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.AnimatedVisibilityScope
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -17,37 +12,24 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
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.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
import androidx.compose.material3.LoadingIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.PlainTooltip
|
import androidx.compose.material3.PlainTooltip
|
||||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarResult
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
@@ -56,13 +38,11 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TooltipAnchorPosition
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
import androidx.compose.material3.TooltipBox
|
import androidx.compose.material3.TooltipBox
|
||||||
import androidx.compose.material3.TooltipDefaults
|
import androidx.compose.material3.TooltipDefaults
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.animateFloatingActionButton
|
import androidx.compose.material3.animateFloatingActionButton
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.rememberTooltipState
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -71,9 +51,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
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.graphics.Color
|
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -82,29 +60,22 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.google.gson.Gson
|
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
|
import io.legado.app.base.BaseRuleEvent
|
||||||
import io.legado.app.data.entities.ReplaceRule
|
import io.legado.app.data.entities.ReplaceRule
|
||||||
import io.legado.app.data.repository.UploadRepository
|
|
||||||
import io.legado.app.ui.widget.components.ActionItem
|
import io.legado.app.ui.widget.components.ActionItem
|
||||||
import io.legado.app.ui.widget.components.AnimatedText
|
|
||||||
import io.legado.app.ui.widget.components.DraggableSelectionHandler
|
import io.legado.app.ui.widget.components.DraggableSelectionHandler
|
||||||
import io.legado.app.ui.widget.components.EmptyMessageView
|
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
|
||||||
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
|
||||||
import io.legado.app.ui.widget.components.ReorderableSelectionItem
|
|
||||||
import io.legado.app.ui.widget.components.SearchBarSection
|
|
||||||
import io.legado.app.ui.widget.components.SelectionBottomBar
|
|
||||||
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
|
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
|
||||||
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
|
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
|
||||||
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
|
import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
|
||||||
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
|
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
|
||||||
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
||||||
|
import io.legado.app.ui.widget.components.rules.RuleListScaffold
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import org.koin.compose.koinInject
|
|
||||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
|
||||||
@@ -119,42 +90,40 @@ fun ReplaceRuleScreen(
|
|||||||
onNavigateToEdit: (ReplaceEditRoute) -> Unit,
|
onNavigateToEdit: (ReplaceEditRoute) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val uploadRepository: UploadRepository = koinInject()
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val rules = uiState.rules
|
val rules = uiState.items
|
||||||
val groups = uiState.groups
|
val groups = uiState.groups
|
||||||
|
val selectedIds = uiState.selectedIds
|
||||||
|
val inSelectionMode = selectedIds.isNotEmpty()
|
||||||
|
|
||||||
var isSearch by remember { mutableStateOf(false) }
|
val listState = rememberLazyListState()
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
val clipboardManager = LocalClipboard.current
|
||||||
var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showDeleteSelectedDialog by remember { mutableStateOf(false) }
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState()
|
|
||||||
var showGroupManageSheet by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
|
||||||
|
|
||||||
val selectedRuleIds by viewModel.selectedRuleIds.collectAsState()
|
|
||||||
val inSelectionMode = selectedRuleIds.isNotEmpty()
|
|
||||||
|
|
||||||
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
|
||||||
val tabItems = listOf(stringResource(R.string.all)) + groups
|
|
||||||
|
|
||||||
val importState by viewModel.importState.collectAsStateWithLifecycle()
|
|
||||||
var showUrlInput by remember { mutableStateOf(false) }
|
var showUrlInput by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var showFilePickerSheet by remember { mutableStateOf(false) }
|
var showFilePickerSheet by remember { mutableStateOf(false) }
|
||||||
var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
|
var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
|
||||||
var isUploading by remember { mutableStateOf(false) }
|
var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
|
||||||
val clipboardManager = LocalClipboard.current
|
var showGroupManageSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val importState by viewModel.importState.collectAsStateWithLifecycle()
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
|
||||||
|
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
||||||
|
val tabItems = remember(groups) { listOf("全部") + groups }
|
||||||
|
val filteredRules = remember(uiState.items, selectedTabIndex, tabItems) {
|
||||||
|
if (selectedTabIndex == 0) {
|
||||||
|
uiState.items
|
||||||
|
} else {
|
||||||
|
val targetGroup = tabItems[selectedTabIndex]
|
||||||
|
uiState.items.filter { it.group == targetGroup }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
|
||||||
val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
|
val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
|
||||||
viewModel.moveItemInList(from.index, to.index)
|
viewModel.moveItemInList(from.index, to.index)
|
||||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
|
||||||
@@ -179,18 +148,7 @@ fun ReplaceRuleScreen(
|
|||||||
val exportDoc = rememberLauncherForActivityResult(
|
val exportDoc = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument("application/json"),
|
contract = ActivityResultContracts.CreateDocument("application/json"),
|
||||||
onResult = { uri ->
|
onResult = { uri ->
|
||||||
uri?.let { it ->
|
uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
|
||||||
scope.launch {
|
|
||||||
val rulesToExport = rules
|
|
||||||
.filter { selectedRuleIds.contains(it.id) }
|
|
||||||
.map { it.rule }
|
|
||||||
|
|
||||||
val json = Gson().toJson(rulesToExport)
|
|
||||||
context.contentResolver.openOutputStream(it)?.use { stream ->
|
|
||||||
stream.writer().write(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -217,41 +175,7 @@ fun ReplaceRuleScreen(
|
|||||||
onSelectSysFile = {},
|
onSelectSysFile = {},
|
||||||
onUpload = {
|
onUpload = {
|
||||||
showFilePickerSheet = false
|
showFilePickerSheet = false
|
||||||
scope.launch {
|
viewModel.uploadSelectedRules(selectedIds, rules)
|
||||||
val rulesToExport = rules
|
|
||||||
.filter { selectedRuleIds.contains(it.id) }
|
|
||||||
.map { it.rule }
|
|
||||||
val json = Gson().toJson(rulesToExport)
|
|
||||||
|
|
||||||
isUploading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
runCatching {
|
|
||||||
uploadRepository.upload(
|
|
||||||
fileName = "exportReplaceRule.json",
|
|
||||||
file = json,
|
|
||||||
contentType = "application/json"
|
|
||||||
)
|
|
||||||
}.onSuccess { url ->
|
|
||||||
isUploading = false
|
|
||||||
val result = snackbarHostState.showSnackbar(
|
|
||||||
message = "上传成功: $url",
|
|
||||||
actionLabel = "复制链接",
|
|
||||||
withDismissAction = true
|
|
||||||
)
|
|
||||||
if (result == SnackbarResult.ActionPerformed) {
|
|
||||||
clipboardManager.setClipEntry(
|
|
||||||
ClipEntry(ClipData.newPlainText("export url", url))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
isUploading = false
|
|
||||||
snackbarHostState.showSnackbar("上传失败: ${e.localizedMessage}")
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isUploading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
allowExtensions = arrayOf("json")
|
allowExtensions = arrayOf("json")
|
||||||
)
|
)
|
||||||
@@ -297,10 +221,32 @@ fun ReplaceRuleScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(groups) {
|
LaunchedEffect(groups) {
|
||||||
val maxIndex = groups.size
|
if (selectedTabIndex > groups.size) {
|
||||||
if (selectedTabIndex > maxIndex) {
|
|
||||||
selectedTabIndex = 0
|
selectedTabIndex = 0
|
||||||
viewModel.setSearchKey("")
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is BaseRuleEvent.ShowSnackbar -> {
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = event.message,
|
||||||
|
actionLabel = event.actionLabel,
|
||||||
|
withDismissAction = true
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed && event.url != null) {
|
||||||
|
clipboardManager.setClipEntry(
|
||||||
|
ClipEntry(
|
||||||
|
ClipData.newPlainText(
|
||||||
|
"url",
|
||||||
|
event.url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,204 +259,97 @@ fun ReplaceRuleScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDeleteRuleDialog != null) {
|
showDeleteRuleDialog?.let { rule ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteRuleDialog = null },
|
onDismissRequest = { showDeleteRuleDialog = null },
|
||||||
title = { Text(stringResource(R.string.delete)) },
|
title = { Text(stringResource(R.string.delete)) },
|
||||||
text = { Text(stringResource(R.string.sure_del) + showDeleteRuleDialog!!.name) },
|
text = { Text(stringResource(R.string.del_msg)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
OutlinedButton(
|
OutlinedButton(onClick = {
|
||||||
onClick = {
|
viewModel.delete(rule); showDeleteRuleDialog = null
|
||||||
viewModel.delete(showDeleteRuleDialog!!)
|
}) { Text(stringResource(R.string.ok)) }
|
||||||
showDeleteRuleDialog = null
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error,
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.ok))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showDeleteRuleDialog = null }) {
|
TextButton(onClick = { showDeleteRuleDialog = null }) {
|
||||||
Text(stringResource(R.string.cancel))
|
Text(
|
||||||
}
|
stringResource(R.string.cancel)
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDeleteSelectedDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showDeleteSelectedDialog = false },
|
|
||||||
title = { Text(stringResource(R.string.delete)) },
|
|
||||||
text = { Text(stringResource(R.string.del_msg)) },
|
|
||||||
confirmButton = {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
viewModel.delSelectionByIds(selectedRuleIds)
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
showDeleteSelectedDialog = false
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error,
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDeleteSelectedDialog = false }) {
|
|
||||||
Text(stringResource(R.string.cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
|
||||||
Column {
|
|
||||||
GlassMediumFlexibleTopAppBar(
|
|
||||||
title = {
|
|
||||||
val titleText = remember(isUploading, inSelectionMode, selectedRuleIds, rules) {
|
|
||||||
when {
|
|
||||||
isUploading -> "正在上传..."
|
|
||||||
inSelectionMode -> "已选择 ${rules.count { it.id in selectedRuleIds }}/${rules.size}"
|
|
||||||
else -> "替换规则"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AnimatedText(
|
|
||||||
text = titleText
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
if (inSelectionMode) {
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
} else {
|
|
||||||
onBackClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (inSelectionMode) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = if (inSelectionMode) "Cancel" else "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (!inSelectionMode) {
|
|
||||||
IconButton(onClick = { isSearch = !isSearch }) {
|
|
||||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
|
||||||
}
|
|
||||||
IconButton(onClick = { showMenu = !showMenu }) {
|
|
||||||
Icon(Icons.Default.MoreVert, contentDescription = "More")
|
|
||||||
}
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showMenu,
|
|
||||||
onDismissRequest = { showMenu = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("在线导入") },
|
|
||||||
onClick = {
|
|
||||||
showMenu = false
|
|
||||||
showUrlInput = true // 触发输入框
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("本地导入") },
|
|
||||||
onClick = { importDoc.launch(arrayOf("text/plain", "application/json")); showMenu = false }
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("分组管理") },
|
|
||||||
onClick = { showGroupManageSheet = true; showMenu = false }
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("帮助") },
|
|
||||||
onClick = { /*TODO*/ showMenu = false }
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("旧的在前") },
|
|
||||||
onClick = { viewModel.setSortMode("asc"); showMenu = false }
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("新的在前") },
|
|
||||||
onClick = { viewModel.setSortMode("desc"); showMenu = false }
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("名称升序") },
|
|
||||||
onClick = {
|
|
||||||
viewModel.setSortMode("name_asc")
|
|
||||||
showMenu = false
|
|
||||||
scope.launch {
|
|
||||||
snackbarHostState.showSnackbar("非时间排序模式下将禁用拖动")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("名称降序") },
|
|
||||||
onClick = {
|
|
||||||
viewModel.setSortMode("name_desc")
|
|
||||||
showMenu = false
|
|
||||||
scope.launch {
|
|
||||||
snackbarHostState.showSnackbar("非时间排序模式下将禁用拖动")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
AnimatedVisibility(visible = isSearch && !inSelectionMode) {
|
|
||||||
SearchBarSection(
|
|
||||||
query = uiState.searchKey ?: "",
|
|
||||||
onQueryChange = {
|
|
||||||
viewModel.setSearchKey(it)
|
|
||||||
selectedTabIndex = 0
|
|
||||||
},
|
|
||||||
placeholder = stringResource(id = R.string.replace_purify_search)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val allString = stringResource(R.string.all)
|
RuleListScaffold(
|
||||||
val tabItems = remember(groups, allString) { listOf(allString) + groups }
|
title = "替换规则",
|
||||||
AnimatedVisibility(visible = groups.isNotEmpty()) {
|
state = uiState,
|
||||||
PrimaryScrollableTabRow(
|
onBackClick = { onBackClick() },
|
||||||
selectedTabIndex = selectedTabIndex.coerceAtMost(tabItems.size - 1)
|
onSearchToggle = { viewModel.setSearchMode(!uiState.isSearch) },
|
||||||
.coerceAtLeast(0),
|
onSearchQueryChange = { viewModel.setSearchKey(it) },
|
||||||
edgePadding = 0.dp,
|
searchPlaceholder = stringResource(R.string.replace_purify_search),
|
||||||
divider = {},
|
onClearSelection = { viewModel.setSelection(emptySet()) },
|
||||||
) {
|
onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
|
||||||
tabItems.forEachIndexed { index, title ->
|
onSelectInvert = { viewModel.setSelection(rules.map { it.id }.toSet() - selectedIds) },
|
||||||
Tab(
|
selectionSecondaryActions = listOf(
|
||||||
selected = selectedTabIndex == index,
|
ActionItem(
|
||||||
onClick = {
|
text = stringResource(R.string.enable),
|
||||||
selectedTabIndex = index
|
onClick = {
|
||||||
val group = tabItems.getOrNull(index)
|
viewModel.enableSelectionByIds(selectedIds)
|
||||||
if (group == allString) {
|
viewModel.setSelection(emptySet())
|
||||||
viewModel.setSearchKey("")
|
}
|
||||||
} else if (group != null) {
|
),
|
||||||
viewModel.setSearchKey("group:$group")
|
ActionItem(
|
||||||
}
|
text = stringResource(R.string.disable_selection),
|
||||||
},
|
onClick = {
|
||||||
modifier = Modifier.wrapContentWidth(),
|
viewModel.disableSelectionByIds(selectedIds)
|
||||||
text = {
|
viewModel.setSelection(emptySet())
|
||||||
Text(
|
}
|
||||||
text = title,
|
),
|
||||||
maxLines = 1,
|
ActionItem(
|
||||||
overflow = TextOverflow.Ellipsis,
|
text = stringResource(R.string.to_top),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
onClick = {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
viewModel.topSelectByIds(selectedIds)
|
||||||
)
|
viewModel.setSelection(emptySet())
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
}
|
ActionItem(
|
||||||
|
text = stringResource(R.string.to_bottom),
|
||||||
|
onClick = {
|
||||||
|
viewModel.bottomSelectByIds(selectedIds)
|
||||||
|
viewModel.setSelection(emptySet())
|
||||||
|
}
|
||||||
|
),
|
||||||
|
ActionItem(
|
||||||
|
text = stringResource(R.string.export),
|
||||||
|
onClick = { showFilePickerSheet = true }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onDeleteSelected = { ids ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
viewModel.delSelectionByIds(ids as Set<Long>)
|
||||||
|
viewModel.setSelection(emptySet())
|
||||||
|
},
|
||||||
|
stickySubContent = {
|
||||||
|
if (tabItems.size > 1) {
|
||||||
|
PrimaryScrollableTabRow(
|
||||||
|
selectedTabIndex = selectedTabIndex.coerceAtMost(tabItems.size - 1)
|
||||||
|
.coerceAtLeast(0),
|
||||||
|
edgePadding = 0.dp,
|
||||||
|
divider = {},
|
||||||
|
) {
|
||||||
|
tabItems.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTabIndex == index,
|
||||||
|
onClick = { selectedTabIndex = index },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,6 +381,57 @@ fun ReplaceRuleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
dropDownMenuContent = { dismiss ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("在线导入") },
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
showUrlInput = true // 触发输入框
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("本地导入") },
|
||||||
|
onClick = { importDoc.launch(arrayOf("text/plain", "application/json")); dismiss() }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("分组管理") },
|
||||||
|
onClick = { showGroupManageSheet = true; dismiss() }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("帮助") },
|
||||||
|
onClick = { /*TODO*/ dismiss() }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("旧的在前") },
|
||||||
|
onClick = { viewModel.setSortMode("asc"); dismiss() }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("新的在前") },
|
||||||
|
onClick = { viewModel.setSortMode("desc"); dismiss() }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("名称升序") },
|
||||||
|
onClick = {
|
||||||
|
viewModel.setSortMode("name_asc")
|
||||||
|
dismiss()
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar("非时间排序模式下将禁用拖动")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("名称降序") },
|
||||||
|
onClick = {
|
||||||
|
viewModel.setSortMode("name_desc")
|
||||||
|
dismiss()
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar("非时间排序模式下将禁用拖动")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
Box(
|
||||||
@@ -549,144 +439,77 @@ fun ReplaceRuleScreen(
|
|||||||
.padding(padding)
|
.padding(padding)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
if (rules.isEmpty()) {
|
FastScrollLazyColumn(
|
||||||
EmptyMessageView(
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
message = "没有替换规则!"
|
.fillMaxSize(),
|
||||||
)
|
contentPadding = PaddingValues(
|
||||||
} else {
|
top = 8.dp,
|
||||||
FastScrollLazyColumn(
|
bottom = 120.dp
|
||||||
state = listState,
|
),
|
||||||
modifier = Modifier
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
.fillMaxSize(),
|
) {
|
||||||
contentPadding = PaddingValues(
|
items(filteredRules, key = { it.id }) { ui ->
|
||||||
top = 8.dp,
|
with(sharedTransitionScope) {
|
||||||
bottom = 120.dp
|
ReorderableSelectionItem(
|
||||||
),
|
state = reorderableState,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
key = ui.id,
|
||||||
) {
|
title = ui.name,
|
||||||
items(rules, key = { it.id }) { ui ->
|
isEnabled = ui.isEnabled,
|
||||||
with(sharedTransitionScope) {
|
isSelected = selectedIds.contains(ui.id),
|
||||||
ReorderableSelectionItem(
|
inSelectionMode = inSelectionMode,
|
||||||
state = reorderableState,
|
canReorder = canReorder,
|
||||||
key = ui.id,
|
onToggleSelection = {
|
||||||
title = ui.name,
|
viewModel.toggleSelection(ui.id)
|
||||||
isEnabled = ui.isEnabled,
|
},
|
||||||
isSelected = selectedRuleIds.contains(ui.id),
|
onEnabledChange = { enabled ->
|
||||||
inSelectionMode = inSelectionMode,
|
viewModel.update(ui.rule.copy(isEnabled = enabled))
|
||||||
canReorder = canReorder,
|
},
|
||||||
onToggleSelection = {
|
onClickEdit = {
|
||||||
viewModel.toggleSelection(ui.id)
|
onNavigateToEdit(
|
||||||
},
|
ReplaceEditRoute(
|
||||||
onEnabledChange = { enabled ->
|
id = ui.id,
|
||||||
viewModel.update(ui.rule.copy(isEnabled = enabled))
|
pattern = ui.rule.pattern
|
||||||
},
|
|
||||||
onClickEdit = {
|
|
||||||
onNavigateToEdit(
|
|
||||||
ReplaceEditRoute(
|
|
||||||
id = ui.id,
|
|
||||||
pattern = ui.rule.pattern
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
},
|
)
|
||||||
modifier = Modifier
|
},
|
||||||
.padding(horizontal = 12.dp)
|
modifier = Modifier
|
||||||
.sharedBounds(
|
.sharedBounds(
|
||||||
sharedContentState = rememberSharedContentState(key = "rule_${ui.id}"),
|
sharedContentState = rememberSharedContentState(key = "rule_${ui.id}"),
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
//clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(12.dp))
|
//clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(12.dp))
|
||||||
),
|
),
|
||||||
dropdownContent = { dismiss ->
|
dropdownContent = { dismiss ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("移至顶部") },
|
text = { Text("移至顶部") },
|
||||||
onClick = { viewModel.toTop(ui.rule); dismiss() }
|
onClick = { viewModel.toTop(ui.rule); dismiss() }
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("移至底部") },
|
text = { Text("移至底部") },
|
||||||
onClick = { viewModel.toBottom(ui.rule); dismiss() }
|
onClick = { viewModel.toBottom(ui.rule); dismiss() }
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("删除") },
|
text = { Text("删除") },
|
||||||
onClick = { showDeleteRuleDialog = ui.rule; dismiss() }
|
onClick = { showDeleteRuleDialog = ui.rule; dismiss() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (inSelectionMode) {
|
}
|
||||||
DraggableSelectionHandler(
|
if (inSelectionMode) {
|
||||||
listState = listState,
|
DraggableSelectionHandler(
|
||||||
items = rules,
|
listState = listState,
|
||||||
selectedIds = selectedRuleIds,
|
items = rules,
|
||||||
onSelectionChange = viewModel::setSelection,
|
selectedIds = selectedIds,
|
||||||
idProvider = { it.id },
|
onSelectionChange = { viewModel.setSelection(it) },
|
||||||
modifier = Modifier
|
idProvider = { it.id },
|
||||||
.fillMaxHeight()
|
modifier = Modifier
|
||||||
.width(60.dp)
|
.fillMaxHeight()
|
||||||
.align(Alignment.TopStart)
|
.width(60.dp)
|
||||||
)
|
.align(Alignment.TopStart)
|
||||||
}
|
)
|
||||||
AnimatedVisibility(
|
|
||||||
visible = inSelectionMode,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.offset(y = -ScreenOffset)
|
|
||||||
.zIndex(1f),
|
|
||||||
enter = slideInVertically { it } + fadeIn(),
|
|
||||||
exit = slideOutVertically { it } + fadeOut()
|
|
||||||
) {
|
|
||||||
SelectionBottomBar(
|
|
||||||
onSelectAll = {
|
|
||||||
viewModel.setSelection(rules.map { it.id }.toSet())
|
|
||||||
},
|
|
||||||
onSelectInvert = {
|
|
||||||
val allIds = rules.map { it.id }.toSet()
|
|
||||||
viewModel.setSelection(allIds - selectedRuleIds)
|
|
||||||
},
|
|
||||||
primaryAction = ActionItem(
|
|
||||||
text = stringResource(R.string.delete),
|
|
||||||
icon = { Icon(Icons.Default.Delete, null) },
|
|
||||||
onClick = { showDeleteSelectedDialog = true }
|
|
||||||
),
|
|
||||||
secondaryActions = listOf(
|
|
||||||
ActionItem(
|
|
||||||
text = stringResource(R.string.enable),
|
|
||||||
onClick = {
|
|
||||||
viewModel.enableSelectionByIds(selectedRuleIds)
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
}
|
|
||||||
),
|
|
||||||
ActionItem(
|
|
||||||
text = stringResource(R.string.disable_selection),
|
|
||||||
onClick = {
|
|
||||||
viewModel.disableSelectionByIds(selectedRuleIds)
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
}
|
|
||||||
),
|
|
||||||
ActionItem(
|
|
||||||
text = stringResource(R.string.to_top),
|
|
||||||
onClick = {
|
|
||||||
viewModel.topSelectByIds(selectedRuleIds)
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
}
|
|
||||||
),
|
|
||||||
ActionItem(
|
|
||||||
text = stringResource(R.string.to_bottom),
|
|
||||||
onClick = {
|
|
||||||
viewModel.bottomSelectByIds(selectedRuleIds)
|
|
||||||
viewModel.setSelection(emptySet())
|
|
||||||
}
|
|
||||||
),
|
|
||||||
ActionItem(
|
|
||||||
text = stringResource(R.string.export),
|
|
||||||
onClick = { showFilePickerSheet = true }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,330 +2,117 @@ 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.core.net.toUri
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.R
|
import io.legado.app.base.BaseRuleViewModel
|
||||||
import io.legado.app.base.BaseViewModel
|
|
||||||
import io.legado.app.constant.AppConst
|
|
||||||
import io.legado.app.constant.AppPattern
|
import io.legado.app.constant.AppPattern
|
||||||
import io.legado.app.constant.PreferKey
|
import io.legado.app.constant.PreferKey
|
||||||
import io.legado.app.data.appDb
|
import io.legado.app.data.appDb
|
||||||
import io.legado.app.data.entities.ReplaceRule
|
import io.legado.app.data.entities.ReplaceRule
|
||||||
import io.legado.app.data.repository.ReplaceRuleRepository
|
import io.legado.app.data.repository.ReplaceRuleRepository
|
||||||
|
import io.legado.app.data.repository.UploadRepository
|
||||||
import io.legado.app.help.ReplaceAnalyzer
|
import io.legado.app.help.ReplaceAnalyzer
|
||||||
import io.legado.app.help.http.decompressed
|
|
||||||
import io.legado.app.help.http.newCallResponseBody
|
|
||||||
import io.legado.app.help.http.okHttpClient
|
|
||||||
import io.legado.app.help.http.text
|
|
||||||
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
|
import io.legado.app.ui.widget.components.rules.InteractionState
|
||||||
import io.legado.app.ui.widget.components.importComponents.ImportStatus
|
import io.legado.app.ui.widget.components.rules.RuleActionState
|
||||||
|
import io.legado.app.ui.widget.components.rules.SelectableItem
|
||||||
|
import io.legado.app.utils.GSON
|
||||||
import io.legado.app.utils.getPrefString
|
import io.legado.app.utils.getPrefString
|
||||||
import io.legado.app.utils.isAbsUrl
|
|
||||||
import io.legado.app.utils.isJsonArray
|
import io.legado.app.utils.isJsonArray
|
||||||
import io.legado.app.utils.isJsonObject
|
import io.legado.app.utils.isJsonObject
|
||||||
import io.legado.app.utils.isUri
|
|
||||||
import io.legado.app.utils.putPrefString
|
import io.legado.app.utils.putPrefString
|
||||||
import io.legado.app.utils.readText
|
|
||||||
import io.legado.app.utils.splitNotBlank
|
import io.legado.app.utils.splitNotBlank
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.flowOn
|
|
||||||
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
|
||||||
import splitties.init.appCtx
|
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class ReplaceRuleItemUi(
|
data class ReplaceRuleItemUi(
|
||||||
val id: Long,
|
override val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val isEnabled: Boolean,
|
val isEnabled: Boolean,
|
||||||
val group: String?,
|
val group: String?,
|
||||||
val rule: ReplaceRule
|
val rule: ReplaceRule
|
||||||
)
|
) : SelectableItem<Long>
|
||||||
|
|
||||||
data class ReplaceRuleUiState(
|
data class ReplaceRuleUiState(
|
||||||
|
override val items: List<ReplaceRuleItemUi> = emptyList(),
|
||||||
|
override val selectedIds: Set<Long> = emptySet(),
|
||||||
|
override val searchKey: String = "",
|
||||||
val sortMode: String = "desc",
|
val sortMode: String = "desc",
|
||||||
val searchKey: String? = null,
|
|
||||||
val groups: List<String> = emptyList(),
|
val groups: List<String> = emptyList(),
|
||||||
val rules: List<ReplaceRuleItemUi> = emptyList(),
|
val interaction: InteractionState = InteractionState()
|
||||||
val isLoading: Boolean = false
|
) : RuleActionState<ReplaceRuleItemUi> {
|
||||||
)
|
override val isSearch: Boolean get() = interaction.isSearchMode
|
||||||
|
override val isUploading: Boolean get() = interaction.isUploading
|
||||||
/**
|
}
|
||||||
* 替换规则数据修改
|
|
||||||
* 修改数据要copy,直接修改会导致界面不刷新
|
|
||||||
*/
|
|
||||||
class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) {
|
|
||||||
|
|
||||||
|
class ReplaceRuleViewModel(
|
||||||
|
application: Application,
|
||||||
|
uploadRepository: UploadRepository
|
||||||
|
) : BaseRuleViewModel<ReplaceRuleItemUi, ReplaceRule, Long, ReplaceRuleUiState>(
|
||||||
|
application,
|
||||||
|
ReplaceRuleUiState(interaction = InteractionState(isLoading = true)),
|
||||||
|
uploadRepository
|
||||||
|
) {
|
||||||
private val repository = ReplaceRuleRepository()
|
private val repository = ReplaceRuleRepository()
|
||||||
private val _sortMode = MutableStateFlow(context.getPrefString(PreferKey.replaceSortMode, "desc") ?: "desc")
|
private val _sortMode = MutableStateFlow(context.getPrefString(PreferKey.replaceSortMode, "desc") ?: "desc")
|
||||||
private val _searchKey = MutableStateFlow<String?>(null)
|
private val _group = MutableStateFlow<String?>(null)
|
||||||
private val _uiRules = MutableStateFlow<List<ReplaceRuleItemUi>>(emptyList())
|
val group = _group.asStateFlow()
|
||||||
private val _selectedRuleIds = MutableStateFlow<Set<Long>>(emptySet())
|
|
||||||
val selectedRuleIds: StateFlow<Set<Long>> = _selectedRuleIds
|
|
||||||
private val _importState = MutableStateFlow<BaseImportUiState<ReplaceRule>>(BaseImportUiState.Idle)
|
|
||||||
val importState: StateFlow<BaseImportUiState<ReplaceRule>> = _importState.asStateFlow()
|
|
||||||
|
|
||||||
fun toggleSelection(id: Long) {
|
|
||||||
_selectedRuleIds.update {
|
|
||||||
if (it.contains(id)) it - id else it + id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private val rulesFlow = combine(_searchKey, _sortMode) { search, sort ->
|
override val rawDataFlow: Flow<List<ReplaceRule>> =
|
||||||
Pair(search, sort)
|
combine(_group, _sortMode) { group, sortMode ->
|
||||||
}.flatMapLatest { (searchKey, sortMode) ->
|
group to sortMode
|
||||||
// 先获取基础数据
|
}.flatMapLatest { (group, sortMode) ->
|
||||||
val baseFlow = when {
|
val baseFlow = when (group) {
|
||||||
searchKey.isNullOrEmpty() -> repository.flowAll()
|
null -> repository.flowAll()
|
||||||
searchKey == appCtx.getString(R.string.no_group) -> repository.flowNoGroup()
|
"未分组" -> repository.flowNoGroup()
|
||||||
searchKey.startsWith("group:") -> {
|
else -> repository.flowGroupSearch(group)
|
||||||
val key = searchKey.substringAfter("group:")
|
|
||||||
repository.flowGroupSearch("%$key%")
|
|
||||||
}
|
}
|
||||||
else -> repository.flowSearch("%$searchKey%")
|
|
||||||
}
|
|
||||||
|
|
||||||
baseFlow
|
baseFlow.map { rules ->
|
||||||
.map { rules ->
|
sortRules(rules, sortMode)
|
||||||
val comparator = when (sortMode) {
|
|
||||||
"asc" -> compareBy<ReplaceRule> { it.order.toLong() }
|
|
||||||
"desc" -> compareByDescending<ReplaceRule> { it.order.toLong() }
|
|
||||||
"name_asc" -> compareBy<ReplaceRule> { it.name.lowercase() }
|
|
||||||
"name_desc" -> compareByDescending<ReplaceRule> { it.name.lowercase() }
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (comparator != null) rules.sortedWith(comparator) else rules
|
|
||||||
}
|
|
||||||
}.flowOn(Dispatchers.Default)
|
|
||||||
|
|
||||||
private val ruleUiFlow: Flow<List<ReplaceRuleItemUi>> =
|
|
||||||
rulesFlow.map { rules ->
|
|
||||||
rules.map { rule ->
|
|
||||||
ReplaceRuleItemUi(
|
|
||||||
id = rule.id,
|
|
||||||
name = rule.name,
|
|
||||||
isEnabled = rule.isEnabled,
|
|
||||||
group = rule.group,
|
|
||||||
rule = rule
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<ReplaceRuleUiState> = combine(
|
override fun filterData(data: List<ReplaceRule>, key: String): List<ReplaceRule> =
|
||||||
_sortMode,
|
if (key.isEmpty()) data
|
||||||
_searchKey,
|
else data.filter { it.name.contains(key, ignoreCase = true) }
|
||||||
repository.flowGroups(),
|
|
||||||
_uiRules
|
|
||||||
) { sortMode, searchKey, groups, rules ->
|
override fun composeUiState(
|
||||||
ReplaceRuleUiState(
|
items: List<ReplaceRuleItemUi>,
|
||||||
sortMode = sortMode,
|
selectedIds: Set<Long>,
|
||||||
searchKey = searchKey,
|
isSearch: Boolean,
|
||||||
groups = groups,
|
isUploading: Boolean,
|
||||||
rules = rules,
|
importState: BaseImportUiState<ReplaceRule>
|
||||||
isLoading = false
|
): ReplaceRuleUiState {
|
||||||
|
return ReplaceRuleUiState(
|
||||||
|
items = items,
|
||||||
|
selectedIds = selectedIds,
|
||||||
|
searchKey = _searchKey.value,
|
||||||
|
interaction = InteractionState(
|
||||||
|
isSearchMode = isSearch,
|
||||||
|
isUploading = isUploading || (importState is BaseImportUiState.Loading),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
|
||||||
initialValue = ReplaceRuleUiState(isLoading = true)
|
|
||||||
)
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
ruleUiFlow.collect { rules ->
|
|
||||||
_uiRules.value = rules
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSortMode(mode: String) {
|
override fun ReplaceRule.toUiItem() = ReplaceRuleItemUi(id, name, isEnabled, group, this)
|
||||||
_sortMode.value = mode
|
override fun ruleItemToEntity(item: ReplaceRuleItemUi): ReplaceRule = item.rule
|
||||||
context.putPrefString(PreferKey.replaceSortMode, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSearchKey(key: String?) {
|
override suspend fun generateJson(entities: List<ReplaceRule>): String = GSON.toJson(entities)
|
||||||
_searchKey.value = key
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSelection(ids: Set<Long>) {
|
override fun parseImportRules(text: String): List<ReplaceRule> {
|
||||||
_selectedRuleIds.value = ids
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(vararg rule: ReplaceRule) {
|
|
||||||
execute {
|
|
||||||
repository.update(*rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delete(rule: ReplaceRule) {
|
|
||||||
execute {
|
|
||||||
repository.delete(rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toTop(rule: ReplaceRule) {
|
|
||||||
execute {
|
|
||||||
repository.toTop(rule, _sortMode.value == "desc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toBottom(rule: ReplaceRule) {
|
|
||||||
execute {
|
|
||||||
repository.toBottom(rule, _sortMode.value == "desc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun upOrder() {
|
|
||||||
execute {
|
|
||||||
repository.upOrder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enableSelection(rules: List<ReplaceRule>) {
|
|
||||||
execute {
|
|
||||||
repository.enableSelection(rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disableSelection(rules: List<ReplaceRule>) {
|
|
||||||
execute {
|
|
||||||
repository.disableSelection(rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enableSelectionByIds(ids: Set<Long>) {
|
|
||||||
execute {
|
|
||||||
repository.enableByIds(ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disableSelectionByIds(ids: Set<Long>) {
|
|
||||||
execute {
|
|
||||||
repository.disableByIds(ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delSelectionByIds(ids: Set<Long>) {
|
|
||||||
execute {
|
|
||||||
repository.deleteByIds(ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun topSelectByIds(ids: Set<Long>) {
|
|
||||||
execute {
|
|
||||||
repository.topByIds(ids, _sortMode.value == "desc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bottomSelectByIds(ids: Set<Long>) {
|
|
||||||
execute {
|
|
||||||
repository.bottomByIds(ids, _sortMode.value == "desc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun addGroup(group: String) {
|
|
||||||
execute {
|
|
||||||
repository.addGroup(group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun upGroup(oldGroup: String, newGroup: String?) {
|
|
||||||
execute {
|
|
||||||
repository.upGroup(oldGroup, newGroup)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delGroup(group: String) {
|
|
||||||
execute {
|
|
||||||
repository.delGroup(group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun moveItemInList(fromIndex: Int, toIndex: Int) {
|
|
||||||
_uiRules.update { currentList ->
|
|
||||||
val list = currentList.toMutableList()
|
|
||||||
val item = list.removeAt(fromIndex)
|
|
||||||
list.add(toIndex, item)
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveSortOrder() {
|
|
||||||
val currentRules = _uiRules.value
|
|
||||||
val isDesc = _sortMode.value == "desc"
|
|
||||||
execute {
|
|
||||||
repository.moveOrder(currentRules, isDesc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importSource(text: String) {
|
|
||||||
_importState.value = BaseImportUiState.Loading
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
val jsonText = resolveSource(text.trim())
|
|
||||||
val rules = parseRules(jsonText)
|
|
||||||
val wrappers = rules.map { newRule ->
|
|
||||||
val oldRule = appDb.replaceRuleDao.findById(newRule.id)
|
|
||||||
|
|
||||||
val status = when {
|
|
||||||
oldRule == null -> ImportStatus.New
|
|
||||||
hasChanged(newRule, oldRule) -> ImportStatus.Update
|
|
||||||
else -> ImportStatus.Existing
|
|
||||||
}
|
|
||||||
|
|
||||||
ImportItemWrapper(
|
|
||||||
data = newRule,
|
|
||||||
oldData = oldRule,
|
|
||||||
status = status,
|
|
||||||
isSelected = status != ImportStatus.Existing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
_importState.value = BaseImportUiState.Success(
|
|
||||||
source = text,
|
|
||||||
items = wrappers
|
|
||||||
)
|
|
||||||
}.onFailure {
|
|
||||||
it.printStackTrace()
|
|
||||||
_importState.value = BaseImportUiState.Error(it.localizedMessage ?: "Unknown Error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun resolveSource(text: String): String {
|
|
||||||
return when {
|
|
||||||
text.isAbsUrl() -> {
|
|
||||||
okHttpClient.newCallResponseBody {
|
|
||||||
if (text.endsWith("#requestWithoutUA")) {
|
|
||||||
url(text.substringBeforeLast("#requestWithoutUA"))
|
|
||||||
header(AppConst.UA_NAME, "null")
|
|
||||||
} else {
|
|
||||||
url(text)
|
|
||||||
}
|
|
||||||
}.decompressed().text("utf-8")
|
|
||||||
}
|
|
||||||
text.isUri() -> text.toUri().readText(appCtx)
|
|
||||||
else -> text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseRules(text: String): List<ReplaceRule> {
|
|
||||||
return when {
|
return when {
|
||||||
text.isJsonArray() -> ReplaceAnalyzer.jsonToReplaceRules(text).getOrThrow()
|
text.isJsonArray() -> ReplaceAnalyzer.jsonToReplaceRules(text).getOrThrow()
|
||||||
text.isJsonObject() -> listOf(ReplaceAnalyzer.jsonToReplaceRule(text).getOrThrow())
|
text.isJsonObject() -> listOf(ReplaceAnalyzer.jsonToReplaceRule(text).getOrThrow())
|
||||||
@@ -333,44 +120,19 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasChanged(newRule: ReplaceRule, oldRule: ReplaceRule): Boolean {
|
override fun hasChanged(newRule: ReplaceRule, oldRule: ReplaceRule): Boolean {
|
||||||
return newRule.pattern != oldRule.pattern
|
return newRule.pattern != oldRule.pattern
|
||||||
|| newRule.replacement != oldRule.replacement
|
|| newRule.replacement != oldRule.replacement
|
||||||
|| newRule.isRegex != oldRule.isRegex
|
|| newRule.isRegex != oldRule.isRegex
|
||||||
|| newRule.scope != oldRule.scope
|
|| newRule.scope != oldRule.scope
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelImport() {
|
override suspend fun findOldRule(newRule: ReplaceRule): ReplaceRule? {
|
||||||
_importState.value = BaseImportUiState.Idle
|
return appDb.replaceRuleDao.findById(newRule.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleImportSelection(index: Int) {
|
override fun saveImportedRules() {
|
||||||
val currentState = _importState.value as? BaseImportUiState.Success ?: return
|
val state = _importState.value as? BaseImportUiState.Success<ReplaceRule> ?: return
|
||||||
val newItems = currentState.items.toMutableList()
|
|
||||||
val item = newItems[index]
|
|
||||||
newItems[index] = item.copy(isSelected = !item.isSelected)
|
|
||||||
_importState.value = currentState.copy(items = newItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleImportAll(isSelected: Boolean) {
|
|
||||||
val currentState = _importState.value as? BaseImportUiState.Success ?: return
|
|
||||||
val newItems = currentState.items.map { it.copy(isSelected = isSelected) }
|
|
||||||
_importState.value = currentState.copy(items = newItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新分组配置
|
|
||||||
fun updateImportConfig(keepName: Boolean? = null, group: String? = null, isAdd: Boolean? = null) {
|
|
||||||
val currentState = _importState.value as? BaseImportUiState.Success ?: return
|
|
||||||
_importState.value = currentState.copy(
|
|
||||||
keepOriginalName = keepName ?: currentState.keepOriginalName,
|
|
||||||
customGroup = group ?: currentState.customGroup,
|
|
||||||
isAddGroup = isAdd ?: currentState.isAddGroup
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveImportedRules() {
|
|
||||||
val state = _importState.value as? BaseImportUiState.Success ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val rulesToSave = state.items
|
val rulesToSave = state.items
|
||||||
.filter { it.isSelected }
|
.filter { it.isSelected }
|
||||||
@@ -381,7 +143,6 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
|
|||||||
if (state.keepOriginalName && oldRule != null) {
|
if (state.keepOriginalName && oldRule != null) {
|
||||||
rule.name = oldRule.name
|
rule.name = oldRule.name
|
||||||
}
|
}
|
||||||
|
|
||||||
val targetGroup = state.customGroup?.trim()
|
val targetGroup = state.customGroup?.trim()
|
||||||
if (!targetGroup.isNullOrEmpty()) {
|
if (!targetGroup.isNullOrEmpty()) {
|
||||||
if (state.isAddGroup) {
|
if (state.isAddGroup) {
|
||||||
@@ -395,13 +156,65 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
|
|||||||
}
|
}
|
||||||
rule
|
rule
|
||||||
}
|
}
|
||||||
|
repository.update(*rulesToSave.toTypedArray())
|
||||||
appDb.replaceRuleDao.insert(*rulesToSave.toTypedArray())
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
_importState.value = BaseImportUiState.Idle
|
_importState.value = BaseImportUiState.Idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
private fun sortRules(rules: List<ReplaceRule>, mode: String): List<ReplaceRule> {
|
||||||
|
val comparator = when (mode) {
|
||||||
|
"asc" -> compareBy<ReplaceRule> { it.order.toLong() }
|
||||||
|
"desc" -> compareByDescending<ReplaceRule> { it.order.toLong() }
|
||||||
|
"name_asc" -> compareBy<ReplaceRule> { it.name.lowercase() }
|
||||||
|
"name_desc" -> compareByDescending<ReplaceRule> { it.name.lowercase() }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return if (comparator != null) rules.sortedWith(comparator) else rules
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSortMode(mode: String) {
|
||||||
|
_sortMode.value = mode
|
||||||
|
context.putPrefString(PreferKey.replaceSortMode, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSortOrder() {
|
||||||
|
val currentLocal = _localItems.value ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.moveOrder(currentLocal.map { it.rule }, _sortMode.value == "desc")
|
||||||
|
_localItems.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun update(vararg rule: ReplaceRule) = viewModelScope.launch { repository.update(*rule) }
|
||||||
|
fun delete(rule: ReplaceRule) = viewModelScope.launch { repository.delete(rule) }
|
||||||
|
fun enableSelectionByIds(ids: Set<Long>) = viewModelScope.launch { repository.enableByIds(ids) }
|
||||||
|
fun disableSelectionByIds(ids: Set<Long>) =
|
||||||
|
viewModelScope.launch { repository.disableByIds(ids) }
|
||||||
|
|
||||||
|
fun delSelectionByIds(ids: Set<Long>) = viewModelScope.launch {
|
||||||
|
repository.deleteByIds(ids)
|
||||||
|
_selectedIds.update { it - ids }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addGroup(group: String) = viewModelScope.launch { repository.addGroup(group) }
|
||||||
|
fun delGroup(group: String) = viewModelScope.launch { repository.delGroup(group) }
|
||||||
|
|
||||||
|
fun toTop(rule: ReplaceRule) =
|
||||||
|
viewModelScope.launch { repository.toTop(rule, _sortMode.value == "desc") }
|
||||||
|
|
||||||
|
fun toBottom(rule: ReplaceRule) =
|
||||||
|
viewModelScope.launch { repository.toBottom(rule, _sortMode.value == "desc") }
|
||||||
|
|
||||||
|
fun upOrder() = viewModelScope.launch { repository.upOrder() }
|
||||||
|
fun topSelectByIds(ids: Set<Long>) =
|
||||||
|
viewModelScope.launch { repository.topByIds(ids, _sortMode.value == "desc") }
|
||||||
|
|
||||||
|
fun bottomSelectByIds(ids: Set<Long>) =
|
||||||
|
viewModelScope.launch { repository.bottomByIds(ids, _sortMode.value == "desc") }
|
||||||
|
|
||||||
|
fun upGroup(oldGroup: String, newGroup: String?) =
|
||||||
|
viewModelScope.launch { repository.upGroup(oldGroup, newGroup) }
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package io.legado.app.ui.widget
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import io.legado.app.ui.theme.ThemeState
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GlassBackIconButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
imageVector: ImageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription: String? = null
|
|
||||||
) {
|
|
||||||
val themeOpacity by ThemeState.containerOpacity.collectAsState()
|
|
||||||
|
|
||||||
val baseColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
val glassColor = baseColor.copy(alpha = (themeOpacity / 100f).coerceAtLeast(0.6f))
|
|
||||||
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
.size(36.dp),
|
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
|
||||||
containerColor = glassColor,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = imageVector,
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -83,9 +84,11 @@ fun Calendar(
|
|||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer
|
color = MaterialTheme.colorScheme.surfaceContainer
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier
|
Column(
|
||||||
.padding(vertical = 12.dp)
|
modifier = Modifier
|
||||||
.animateContentSize()) {
|
.padding(vertical = 12.dp)
|
||||||
|
.animateContentSize()
|
||||||
|
) {
|
||||||
MonthNavigation(
|
MonthNavigation(
|
||||||
currentMonth = currentMonth,
|
currentMonth = currentMonth,
|
||||||
selectedDate = selectedDate,
|
selectedDate = selectedDate,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ fun SearchBarSection(
|
|||||||
query: String,
|
query: String,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
placeholder: String = "搜索书名",
|
placeholder: String = "搜索书名",
|
||||||
|
leadingIcon: @Composable (() -> Unit)? = { Icon(Icons.Default.Search, null) },
|
||||||
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
scrollState: LazyListState? = null,
|
scrollState: LazyListState? = null,
|
||||||
scope: CoroutineScope = rememberCoroutineScope(),
|
scope: CoroutineScope = rememberCoroutineScope(),
|
||||||
@@ -73,7 +74,7 @@ fun SearchBarSection(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 4.dp),
|
.padding(horizontal = 4.dp),
|
||||||
placeholder = { Text(placeholder) },
|
placeholder = { Text(placeholder) },
|
||||||
leadingIcon = { Icon(Icons.Default.Search, null) },
|
leadingIcon = leadingIcon,
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -127,6 +128,7 @@ fun SettingItem(
|
|||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
imageVector != null -> Icon(
|
imageVector != null -> Icon(
|
||||||
imageVector = imageVector,
|
imageVector = imageVector,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings Group Container by https://github.com/wxxsfxyzm/InstallerX-Revived
|
* Settings Group Container by https://github.com/wxxsfxyzm/InstallerX-Revived
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.legado.app.ui.widget.components
|
package io.legado.app.ui.widget.components.button
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package io.legado.app.ui.widget.components.button
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SmallIconButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
icon: ImageVector,
|
||||||
|
contentDescription: String? = null
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.size(
|
||||||
|
IconButtonDefaults.extraSmallContainerSize(
|
||||||
|
IconButtonDefaults.IconButtonWidthOption.Narrow
|
||||||
|
)
|
||||||
|
),
|
||||||
|
shape = IconButtonDefaults.extraSmallRoundShape,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.size(IconButtonDefaults.extraSmallIconSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package io.legado.app.ui.widget.components.button
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedIconButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.legado.app.ui.theme.ThemeState
|
||||||
|
|
||||||
|
enum class TopBarButtonVariant {
|
||||||
|
Filled, Outlined, Icon
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SmallTopBarButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
imageVector: ImageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription: String? = null,
|
||||||
|
style: TopBarButtonVariant = TopBarButtonVariant.Filled
|
||||||
|
) {
|
||||||
|
val themeOpacity by ThemeState.containerOpacity.collectAsState()
|
||||||
|
val baseColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
val glassColor = baseColor.copy(alpha = (themeOpacity / 100f).coerceAtLeast(0.6f))
|
||||||
|
|
||||||
|
val commonModifier = modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.size(36.dp)
|
||||||
|
|
||||||
|
when (style) {
|
||||||
|
TopBarButtonVariant.Filled -> {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = commonModifier,
|
||||||
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = glassColor,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
SmallIcon(imageVector, contentDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TopBarButtonVariant.Outlined -> {
|
||||||
|
OutlinedIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = commonModifier,
|
||||||
|
border = ButtonDefaults.outlinedButtonBorder()
|
||||||
|
) {
|
||||||
|
SmallIcon(imageVector, contentDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TopBarButtonVariant.Icon -> {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = commonModifier
|
||||||
|
) {
|
||||||
|
SmallIcon(imageVector, contentDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SmallIcon(imageVector: ImageVector, contentDescription: String?) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = imageVector,
|
||||||
|
transitionSpec = {
|
||||||
|
(fadeIn() + scaleIn(initialScale = 0.8f))
|
||||||
|
.togetherWith(fadeOut())
|
||||||
|
},
|
||||||
|
label = "IconTransition"
|
||||||
|
) { targetIcon ->
|
||||||
|
Icon(
|
||||||
|
imageVector = targetIcon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.legado.app.ui.widget.components
|
package io.legado.app.ui.widget.components.card
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
+80
-59
@@ -1,4 +1,4 @@
|
|||||||
package io.legado.app.ui.widget.components
|
package io.legado.app.ui.widget.components.card
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
@@ -11,19 +11,19 @@ import androidx.compose.animation.fadeIn
|
|||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkHorizontally
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.ListItemDefaults
|
import androidx.compose.material3.ListItemDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -36,6 +36,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.scale
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
@@ -43,6 +44,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
|
import io.legado.app.ui.widget.components.button.SmallIconButton
|
||||||
import sh.calvin.reorderable.ReorderableItem
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
import sh.calvin.reorderable.ReorderableLazyListState
|
import sh.calvin.reorderable.ReorderableLazyListState
|
||||||
|
|
||||||
@@ -73,78 +75,97 @@ fun SelectionItemCard(
|
|||||||
|
|
||||||
GlassCard(
|
GlassCard(
|
||||||
onClick = onToggleSelection,
|
onClick = onToggleSelection,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
color = containerColor
|
color = containerColor
|
||||||
) {
|
) {
|
||||||
ListItem(
|
Row(
|
||||||
modifier = Modifier.animateContentSize(),
|
modifier = Modifier
|
||||||
headlineContent = {
|
.fillMaxWidth()
|
||||||
Text(
|
.padding(horizontal = 4.dp)
|
||||||
text = title,
|
.animateContentSize(),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
maxLines = 1,
|
) {
|
||||||
overflow = TextOverflow.Ellipsis
|
AnimatedVisibility(
|
||||||
|
visible = inSelectionMode,
|
||||||
|
enter = fadeIn() + expandHorizontally(),
|
||||||
|
exit = fadeOut() + shrinkHorizontally()
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = null,
|
||||||
|
modifier = Modifier.padding(start = 12.dp)
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
supportingContent = subtitle?.let {
|
|
||||||
{
|
ListItem(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = title,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
},
|
supportingContent = if (!subtitle.isNullOrBlank()) {
|
||||||
leadingContent = {
|
{
|
||||||
AnimatedVisibility(
|
Text(
|
||||||
visible = inSelectionMode,
|
text = subtitle,
|
||||||
enter = fadeIn() + expandHorizontally(),
|
style = MaterialTheme.typography.bodySmall,
|
||||||
exit = fadeOut() + shrinkHorizontally()
|
maxLines = 1,
|
||||||
) {
|
overflow = TextOverflow.Ellipsis
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
onEnabledChange?.let {
|
|
||||||
Switch(
|
|
||||||
checked = isEnabled,
|
|
||||||
onCheckedChange = it
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else null,
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
|
)
|
||||||
|
|
||||||
if (onClickEdit != null) {
|
Row(
|
||||||
IconButton(onClick = onClickEdit) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
}
|
modifier = Modifier
|
||||||
}
|
.padding(end = 8.dp)
|
||||||
|
) {
|
||||||
|
onEnabledChange?.let {
|
||||||
|
Switch(
|
||||||
|
modifier = Modifier.scale(0.8f),
|
||||||
|
checked = isEnabled,
|
||||||
|
onCheckedChange = it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (trailingAction != null) {
|
if (onClickEdit != null) {
|
||||||
trailingAction()
|
SmallIconButton(
|
||||||
}
|
onClick = onClickEdit,
|
||||||
|
icon = Icons.Default.Edit,
|
||||||
|
contentDescription = "Edit"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (dropdownContent != null) {
|
if (trailingAction != null) {
|
||||||
Box {
|
trailingAction()
|
||||||
IconButton(onClick = { showMenu = true }) {
|
}
|
||||||
Icon(Icons.Default.MoreVert, contentDescription = "More")
|
|
||||||
}
|
if (dropdownContent != null) {
|
||||||
DropdownMenu(
|
Box {
|
||||||
expanded = showMenu,
|
SmallIconButton(
|
||||||
onDismissRequest = { showMenu = false }
|
onClick = { showMenu = true },
|
||||||
) {
|
icon = Icons.Default.MoreVert,
|
||||||
dropdownContent { showMenu = false }
|
contentDescription = "More"
|
||||||
}
|
)
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false }
|
||||||
|
) {
|
||||||
|
dropdownContent { showMenu = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent)
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.legado.app.ui.widget.components
|
package io.legado.app.ui.widget.components.card
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.legado.app.ui.widget.components.AnimatedText
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package io.legado.app.ui.widget.components.rules
|
||||||
|
|
||||||
|
interface RuleActionState<T> {
|
||||||
|
val items: List<T>
|
||||||
|
val selectedIds: Set<Any>
|
||||||
|
val searchKey: String
|
||||||
|
val isSearch: Boolean
|
||||||
|
val isUploading: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
data class InteractionState(
|
||||||
|
val isSearchMode: Boolean = false,
|
||||||
|
val isUploading: Boolean = false,
|
||||||
|
val isLoading: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SelectableItem<T> {
|
||||||
|
val id: T
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package io.legado.app.ui.widget.components.rules
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NoteAdd
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用编辑数据包装,用于适配不同的规则实体
|
||||||
|
*/
|
||||||
|
data class RuleEditFields(
|
||||||
|
val name: String = "",
|
||||||
|
val rule1: String = "",
|
||||||
|
val rule2: String = "",
|
||||||
|
val extra: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun <T> RuleEditSheet(
|
||||||
|
rule: T?,
|
||||||
|
title: String,
|
||||||
|
label1: String,
|
||||||
|
label2: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSave: (T) -> Unit,
|
||||||
|
onCopy: (T) -> Unit,
|
||||||
|
onPaste: () -> T?,
|
||||||
|
toFields: (T?) -> RuleEditFields,
|
||||||
|
fromFields: (RuleEditFields, T?) -> T
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
val initialFields = remember(rule) { toFields(rule) }
|
||||||
|
var name by remember(initialFields) { mutableStateOf(initialFields.name) }
|
||||||
|
var rule1 by remember(initialFields) { mutableStateOf(initialFields.rule1) }
|
||||||
|
var rule2 by remember(initialFields) { mutableStateOf(initialFields.rule2) }
|
||||||
|
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2), rule)
|
||||||
|
|
||||||
|
GlassModalBottomSheet(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onDismissRequest) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.cancel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { showMenu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = "More")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.copy_rule)) },
|
||||||
|
leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, null) },
|
||||||
|
onClick = {
|
||||||
|
onCopy(getCurrentEntity())
|
||||||
|
showMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.paste_rule)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
onPaste()?.let { pasted ->
|
||||||
|
val fields = toFields(pasted)
|
||||||
|
name = fields.name
|
||||||
|
rule1 = fields.rule1
|
||||||
|
rule2 = fields.rule2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 96.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(stringResource(R.string.name)) },
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
value = rule1,
|
||||||
|
onValueChange = { rule1 = it },
|
||||||
|
label = { Text(label1) }
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
value = rule2,
|
||||||
|
onValueChange = { rule2 = it },
|
||||||
|
label = { Text(label2) },
|
||||||
|
minLines = 3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { onSave(getCurrentEntity()) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp),
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Save, contentDescription = "Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package io.legado.app.ui.widget.components.rules
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.animateFloatingActionButton
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.ui.widget.components.ActionItem
|
||||||
|
import io.legado.app.ui.widget.components.AnimatedText
|
||||||
|
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
||||||
|
import io.legado.app.ui.widget.components.SearchBarSection
|
||||||
|
import io.legado.app.ui.widget.components.SelectionBottomBar
|
||||||
|
import io.legado.app.ui.widget.components.button.SmallTopBarButton
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun <T> RuleListScaffold(
|
||||||
|
title: String,
|
||||||
|
state: RuleActionState<T>,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
onSearchToggle: (Boolean) -> Unit,
|
||||||
|
onSearchQueryChange: (String) -> Unit,
|
||||||
|
searchPlaceholder: String,
|
||||||
|
searchLeadingIcon: ImageVector = Icons.Default.Search,
|
||||||
|
searchTrailingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
|
||||||
|
topBarActions: @Composable RowScope.() -> Unit = {},
|
||||||
|
stickySubContent: @Composable (ColumnScope.() -> Unit)? = null,
|
||||||
|
dropDownMenuContent: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {},
|
||||||
|
onClearSelection: () -> Unit,
|
||||||
|
onSelectAll: () -> Unit,
|
||||||
|
onSelectInvert: () -> Unit,
|
||||||
|
selectionSecondaryActions: List<ActionItem>,
|
||||||
|
onDeleteSelected: (Set<Any>) -> Unit,
|
||||||
|
onAddClick: (() -> Unit)? = null,
|
||||||
|
floatingActionButton: @Composable () -> Unit = {
|
||||||
|
onAddClick?.let { onClick ->
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above
|
||||||
|
),
|
||||||
|
tooltip = { PlainTooltip { Text("添加") } },
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
FloatingActionButton(
|
||||||
|
modifier = Modifier.animateFloatingActionButton(
|
||||||
|
visible = state.selectedIds.isEmpty(),
|
||||||
|
alignment = Alignment.BottomEnd,
|
||||||
|
),
|
||||||
|
onClick = onClick
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
content: @Composable (PaddingValues) -> Unit
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showDeleteConfirmDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteConfirmDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.delete)) },
|
||||||
|
text = { Text(stringResource(R.string.del_msg)) },
|
||||||
|
confirmButton = {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
onDeleteSelected(state.selectedIds)
|
||||||
|
showDeleteConfirmDialog = false
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteConfirmDialog = false }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
GlassMediumFlexibleTopAppBar(
|
||||||
|
title = {
|
||||||
|
val titleText = when {
|
||||||
|
state.isUploading -> "请稍后..."
|
||||||
|
state.selectedIds.isNotEmpty() -> "已选择 ${state.selectedIds.size}/${state.items.size}"
|
||||||
|
else -> title
|
||||||
|
}
|
||||||
|
AnimatedText(text = titleText)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
SmallTopBarButton(
|
||||||
|
onClick = {
|
||||||
|
if (state.selectedIds.isNotEmpty()) onClearSelection() else onBackClick()
|
||||||
|
},
|
||||||
|
imageVector = if (state.selectedIds.isNotEmpty()) {
|
||||||
|
Icons.Default.Close
|
||||||
|
} else {
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack
|
||||||
|
},
|
||||||
|
contentDescription = if (state.selectedIds.isNotEmpty()) "取消选择" else "返回"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!state.selectedIds.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onSearchToggle(!state.isSearch) }) {
|
||||||
|
Icon(Icons.Default.Search, null)
|
||||||
|
}
|
||||||
|
topBarActions()
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showMenu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, null)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false }) {
|
||||||
|
dropDownMenuContent { showMenu = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = state.isSearch && !state.selectedIds.isNotEmpty()) {
|
||||||
|
SearchBarSection(
|
||||||
|
query = state.searchKey,
|
||||||
|
onQueryChange = onSearchQueryChange,
|
||||||
|
placeholder = searchPlaceholder,
|
||||||
|
leadingIcon = { Icon(searchLeadingIcon, null) },
|
||||||
|
trailingIcon = searchTrailingIcon,
|
||||||
|
dropdownMenu = searchDropdownMenu
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = stickySubContent != null
|
||||||
|
) {
|
||||||
|
stickySubContent?.let { it() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
floatingActionButton = floatingActionButton,
|
||||||
|
content = { paddingValues ->
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
content(paddingValues)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state.selectedIds.isNotEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.offset(y = -ScreenOffset)
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.zIndex(1f),
|
||||||
|
enter = slideInVertically { it } + fadeIn(),
|
||||||
|
exit = slideOutVertically { it } + fadeOut()
|
||||||
|
) {
|
||||||
|
SelectionBottomBar(
|
||||||
|
onSelectAll = onSelectAll,
|
||||||
|
onSelectInvert = onSelectInvert,
|
||||||
|
primaryAction = ActionItem(
|
||||||
|
text = stringResource(R.string.delete),
|
||||||
|
icon = { Icon(Icons.Default.Delete, null) },
|
||||||
|
onClick = { showDeleteConfirmDialog = true }
|
||||||
|
),
|
||||||
|
secondaryActions = selectionSecondaryActions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user