[新增] 重写了Txt目录界面,优化了规则类的相关逻辑和界面
This commit is contained in:
@@ -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
|
||||
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -10,6 +15,12 @@ interface TxtTocRuleDao {
|
||||
@Query("select * from txtTocRules order by serialNumber")
|
||||
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")
|
||||
val all: List<TxtTocRule>
|
||||
|
||||
|
||||
@@ -22,16 +22,22 @@ class DictRuleRepository {
|
||||
return dao.all
|
||||
}
|
||||
|
||||
fun insert(vararg rule: DictRule) {
|
||||
dao.insert(*rule)
|
||||
suspend fun insert(vararg rule: DictRule) {
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.insert(*rule)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(vararg rule: DictRule) {
|
||||
dao.delete(*rule)
|
||||
suspend fun delete(vararg rule: DictRule) {
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.delete(*rule)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(vararg rule: DictRule) {
|
||||
dao.update(*rule)
|
||||
suspend fun update(vararg rule: DictRule) {
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.update(*rule)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findById(id: String): DictRule? = withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -3,13 +3,11 @@ package io.legado.app.data.repository
|
||||
import android.text.TextUtils
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.ReplaceRule
|
||||
import io.legado.app.ui.replace.ReplaceRuleItemUi
|
||||
import io.legado.app.utils.splitNotBlank
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.collections.map
|
||||
|
||||
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) {
|
||||
withContext(Dispatchers.IO) {
|
||||
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) {
|
||||
withContext(Dispatchers.IO) {
|
||||
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) {
|
||||
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
|
||||
itemUi.rule.copy(order = order)
|
||||
rule.copy(order = order)
|
||||
}
|
||||
|
||||
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.readRecord.ReadRecordViewModel
|
||||
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.main.my.MyViewModel
|
||||
import io.legado.app.ui.replace.ReplaceEditRoute
|
||||
import io.legado.app.ui.replace.ReplaceRuleViewModel
|
||||
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.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appModule = module {
|
||||
@@ -27,15 +29,21 @@ val appModule = module {
|
||||
single { get<AppDatabase>().bookDao }
|
||||
single { get<AppDatabase>().bookChapterDao }
|
||||
|
||||
single { ReadRecordRepository(get()) }
|
||||
|
||||
single { BookRepository(get(), get()) }
|
||||
singleOf(::ReadRecordRepository)
|
||||
singleOf(::BookRepository)
|
||||
singleOf(::SearchContentRepository)
|
||||
|
||||
single<UploadRepository> { DirectLinkUploadRepository() }
|
||||
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) ->
|
||||
ReplaceEditViewModel(
|
||||
app = get(),
|
||||
@@ -43,11 +51,6 @@ val appModule = module {
|
||||
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 io.legado.app.R
|
||||
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.SettingItem
|
||||
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)
|
||||
@Composable
|
||||
@@ -61,7 +61,7 @@ fun AboutScreen(
|
||||
GlassMediumFlexibleTopAppBar(
|
||||
title = { Text(stringResource(R.string.about)) },
|
||||
navigationIcon = {
|
||||
GlassBackIconButton(onClick = onBack)
|
||||
SmallTopBarButton(onClick = onBack)
|
||||
},
|
||||
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.rule.ExploreKind
|
||||
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.Cover
|
||||
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
||||
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 kotlinx.coroutines.delay
|
||||
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.verticalScroll
|
||||
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.ImageSearch
|
||||
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.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.widget.components.Cover
|
||||
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.launch
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
@@ -74,22 +72,16 @@ fun BookInfoEditScreen(
|
||||
GlassMediumFlexibleTopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.book_info_edit)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
SmallTopBarButton(
|
||||
onClick = onBack,
|
||||
style = TopBarButtonVariant.Outlined
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
FilledTonalIconButton(
|
||||
SmallTopBarButton(
|
||||
onClick = { viewModel.save(onSave) },
|
||||
shapes = IconButtonDefaults.shapes()) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Save,
|
||||
contentDescription = stringResource(id = R.string.action_save)
|
||||
)
|
||||
}
|
||||
imageVector = Icons.Default.Save
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
@@ -213,8 +205,7 @@ fun BookInfoEditContent(
|
||||
value = uiState.intro ?: "",
|
||||
onValueChange = { viewModel.onIntroChange(it) },
|
||||
label = { Text("简介") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 5
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
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.GlassMediumFlexibleTopAppBar
|
||||
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.card.TextCard
|
||||
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
||||
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
|
||||
|
||||
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.ui.association.ImportTxtTocRuleDialog
|
||||
import io.legado.app.ui.file.HandleFileContract
|
||||
import io.legado.app.ui.qrcode.QrCodeResult
|
||||
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
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.legado.app.base.BaseComposeActivity
|
||||
import io.legado.app.ui.theme.AppTheme
|
||||
|
||||
class TxtTocRuleActivity : VMBaseActivity<ActivityTxtTocRuleBinding, TxtTocRuleViewModel>(),
|
||||
TxtTocRuleAdapter.CallBack,
|
||||
SelectActionBar.CallBack,
|
||||
TxtTocRuleEditDialog.Callback,
|
||||
PopupMenu.OnMenuItemClickListener {
|
||||
class TxtTocRuleActivity : BaseComposeActivity() {
|
||||
|
||||
override val viewModel: TxtTocRuleViewModel by viewModels()
|
||||
override val binding: ActivityTxtTocRuleBinding by viewBinding(ActivityTxtTocRuleBinding::inflate)
|
||||
private val adapter: TxtTocRuleAdapter by lazy {
|
||||
TxtTocRuleAdapter(this, this)
|
||||
}
|
||||
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}")
|
||||
@Composable
|
||||
override fun Content() {
|
||||
AppTheme {
|
||||
TxtRuleScreen(onBackClick = { finish() })
|
||||
}
|
||||
}
|
||||
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_qr -> qrCodeResult.launch()
|
||||
R.id.menu_import_default -> viewModel.importDefault()
|
||||
R.id.menu_help -> showHelp("txtTocRuleHelp")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun saveTxtTocRule(txtTocRule: TxtTocRule) {
|
||||
viewModel.save(txtTocRule)
|
||||
viewModel.update(txtTocRule)
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
@@ -294,7 +293,7 @@ class TxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_toc_regex),
|
||||
setMessage(getString(R.string.sure_del) + "\n" + item.name)
|
||||
noButton()
|
||||
yesButton {
|
||||
viewModel.del(item)
|
||||
viewModel.delete(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,151 @@
|
||||
package io.legado.app.ui.book.toc.rule
|
||||
|
||||
import android.app.Application
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.data.appDb
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.base.BaseRuleViewModel
|
||||
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) {
|
||||
|
||||
fun save(txtTocRule: TxtTocRule) {
|
||||
execute {
|
||||
appDb.txtTocRuleDao.insert(txtTocRule)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@Immutable
|
||||
data class TxtTocRuleItemUi(
|
||||
override val id: Long,
|
||||
val name: String,
|
||||
val isEnabled: Boolean,
|
||||
val rule: TxtTocRule,
|
||||
val example: String = ""
|
||||
) : SelectableItem<Long>
|
||||
|
||||
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
|
||||
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.legado.app.R
|
||||
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.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() {
|
||||
|
||||
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
|
||||
override fun Content() {
|
||||
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 androidx.activity.compose.rememberLauncherForActivityResult
|
||||
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.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.offset
|
||||
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.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.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.OutlinedButton
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
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.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
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.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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
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.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.gson.Gson
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.BaseRuleEvent
|
||||
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.AnimatedText
|
||||
import io.legado.app.ui.widget.components.DraggableSelectionHandler
|
||||
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.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.utils.GSON
|
||||
import kotlinx.coroutines.launch
|
||||
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 org.koin.compose.koinInject
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@@ -102,23 +69,22 @@ fun DictRuleScreen(
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
|
||||
val uploadRepository: UploadRepository = koinInject()
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
val rules = uiState.items
|
||||
val listState = rememberLazyListState()
|
||||
var isSearch by remember { mutableStateOf(false) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val selectedIds by viewModel.selectedIds.collectAsState()
|
||||
val selectedIds = uiState.selectedIds
|
||||
val inSelectionMode = selectedIds.isNotEmpty()
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var showDeleteSelectedDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var showEditSheet by remember { mutableStateOf(false) }
|
||||
var editingRule 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 ->
|
||||
viewModel.moveItemInList(from.index, to.index)
|
||||
@@ -127,13 +93,33 @@ fun DictRuleScreen(
|
||||
|
||||
val clipboardManager = LocalClipboard.current
|
||||
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 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 ->
|
||||
@@ -149,18 +135,7 @@ fun DictRuleScreen(
|
||||
val exportDoc = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/json"),
|
||||
onResult = { uri ->
|
||||
uri?.let { it ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -187,39 +162,7 @@ fun DictRuleScreen(
|
||||
onSelectSysFile = {},
|
||||
onUpload = {
|
||||
showFilePickerSheet = false
|
||||
scope.launch {
|
||||
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
|
||||
}
|
||||
}
|
||||
viewModel.uploadSelectedRules(selectedIds, rules)
|
||||
},
|
||||
allowExtensions = arrayOf("json")
|
||||
)
|
||||
@@ -248,220 +191,143 @@ fun DictRuleScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteRuleDialog != null) {
|
||||
showDeleteRuleDialog?.let { rule ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteRuleDialog = null },
|
||||
title = { Text(stringResource(R.string.delete)) },
|
||||
text = { Text(stringResource(R.string.sure_del) + showDeleteRuleDialog!!.name) },
|
||||
text = { Text(stringResource(R.string.del_msg)) },
|
||||
confirmButton = {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.delete(showDeleteRuleDialog!!)
|
||||
showDeleteRuleDialog = null
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
viewModel.delete(rule); showDeleteRuleDialog = null
|
||||
}) { Text(stringResource(R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteRuleDialog = null }) {
|
||||
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))
|
||||
Text(
|
||||
stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showEditSheet) {
|
||||
DictRuleEditSheet(
|
||||
RuleEditSheet(
|
||||
rule = editingRule,
|
||||
onDismissRequest = { showEditSheet = false },
|
||||
onSave = {
|
||||
if (editingRule == null) {
|
||||
viewModel.insert(it)
|
||||
} else {
|
||||
viewModel.update(it)
|
||||
}
|
||||
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() }
|
||||
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(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
Column {
|
||||
GlassMediumFlexibleTopAppBar(
|
||||
title = {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
RuleListScaffold(
|
||||
title = "字典规则",
|
||||
state = uiState,
|
||||
onBackClick = { onBackClick() },
|
||||
onSearchToggle = { active ->
|
||||
viewModel.setSearchMode(active)
|
||||
},
|
||||
floatingActionButton = {
|
||||
TooltipBox(
|
||||
positionProvider =
|
||||
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
tooltip = { PlainTooltip { Text("Localized description") } },
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.animateFloatingActionButton(
|
||||
visible = !inSelectionMode,
|
||||
alignment = Alignment.BottomEnd,
|
||||
),
|
||||
onClick = {
|
||||
editingRule = null
|
||||
showEditSheet = true
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Rule")
|
||||
}
|
||||
}
|
||||
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<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 ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)) {
|
||||
FastScrollLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(
|
||||
top = 8.dp,
|
||||
bottom = 120.dp
|
||||
),
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(rules, key = { it.name }) { item ->
|
||||
items(rules, key = { it.id }) { item ->
|
||||
ReorderableSelectionItem(
|
||||
state = reorderableState,
|
||||
key = item.name,
|
||||
title = item.name,
|
||||
key = item.id,
|
||||
title = item.id,
|
||||
isEnabled = item.isEnabled,
|
||||
isSelected = selectedIds.contains(item.name),
|
||||
isSelected = selectedIds.contains(item.id),
|
||||
inSelectionMode = inSelectionMode,
|
||||
onToggleSelection = {
|
||||
viewModel.toggleSelection(item.name)
|
||||
},
|
||||
onEnabledChange = { enabled ->
|
||||
viewModel.update(item.rule.copy(enabled = enabled))
|
||||
},
|
||||
onClickEdit = {
|
||||
editingRule = item.rule
|
||||
showEditSheet = true
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
onToggleSelection = { viewModel.toggleSelection(item.id) },
|
||||
onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enabled = enabled)) },
|
||||
onClickEdit = { editingRule = item.rule; showEditSheet = true },
|
||||
trailingAction = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
showDeleteRuleDialog = item.rule
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||
}
|
||||
SmallIconButton(
|
||||
onClick = { showDeleteRuleDialog = item.rule },
|
||||
icon = Icons.Default.Delete
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -471,60 +337,14 @@ fun DictRuleScreen(
|
||||
listState = listState,
|
||||
items = rules,
|
||||
selectedIds = selectedIds,
|
||||
onSelectionChange = viewModel::setSelection,
|
||||
idProvider = { it.name },
|
||||
onSelectionChange = { viewModel.setSelection(it) },
|
||||
idProvider = { it.id },
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(60.dp)
|
||||
.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 androidx.compose.runtime.Immutable
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.AppConst
|
||||
import io.legado.app.base.BaseRuleViewModel
|
||||
import io.legado.app.data.entities.DictRule
|
||||
import io.legado.app.data.repository.DictRuleRepository
|
||||
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.data.repository.UploadRepository
|
||||
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.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.isAbsUrl
|
||||
import io.legado.app.utils.isJsonArray
|
||||
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.toastOnUi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Immutable
|
||||
data class DictRuleItemUi(
|
||||
val name: String,
|
||||
override val id: String,
|
||||
val urlRule: String,
|
||||
val showRule: String,
|
||||
val isEnabled: Boolean,
|
||||
val rule: DictRule
|
||||
)
|
||||
) : SelectableItem<String>
|
||||
|
||||
data class DictRuleUiState(
|
||||
val searchKey: String? = null,
|
||||
val items: List<DictRuleItemUi> = emptyList(),
|
||||
val dictRule: List<DictRule> = emptyList(),
|
||||
val selectedIds: Set<String> = emptySet(),
|
||||
val isLoading: Boolean = false
|
||||
)
|
||||
|
||||
class DictRuleViewModel(application: Application) : BaseViewModel(application) {
|
||||
override val items: List<DictRuleItemUi> = emptyList(),
|
||||
override val selectedIds: Set<String> = emptySet(),
|
||||
override val searchKey: String = "",
|
||||
val interaction: InteractionState = InteractionState()
|
||||
) : RuleActionState<DictRuleItemUi> {
|
||||
override val isSearch: Boolean get() = interaction.isSearchMode
|
||||
override val isUploading: Boolean get() = interaction.isUploading
|
||||
}
|
||||
|
||||
class DictRuleViewModel(
|
||||
application: Application,
|
||||
uploadRepository: UploadRepository
|
||||
) : BaseRuleViewModel<DictRuleItemUi, DictRule, String, DictRuleUiState>(
|
||||
application,
|
||||
DictRuleUiState(interaction = InteractionState(isLoading = true)),
|
||||
uploadRepository
|
||||
) {
|
||||
private val repository = DictRuleRepository()
|
||||
private val _searchKey = MutableStateFlow<String?>(null)
|
||||
private val _uiRules = MutableStateFlow<List<DictRuleItemUi>>(emptyList())
|
||||
|
||||
private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selectedIds: StateFlow<Set<String>> = _selectedIds
|
||||
private val _importState = MutableStateFlow<BaseImportUiState<DictRule>>(BaseImportUiState.Idle)
|
||||
val importState = _importState.asStateFlow()
|
||||
fun toggleSelection(id: String) {
|
||||
_selectedIds.update {
|
||||
if (it.contains(id)) it - id else it + id
|
||||
}
|
||||
override val rawDataFlow: Flow<List<DictRule>> = repository.flowAll()
|
||||
|
||||
override fun filterData(data: List<DictRule>, key: String): List<DictRule> {
|
||||
val filtered = if (key.isEmpty()) data
|
||||
else data.filter { it.name.contains(key, ignoreCase = true) }
|
||||
return filtered.sortedBy { it.sortNumber }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val rulesFlow = _searchKey.flatMapLatest { searchKey ->
|
||||
val baseFlow = if (searchKey.isNullOrEmpty()) {
|
||||
repository.flowAll()
|
||||
} else {
|
||||
repository.flowSearch("%$searchKey%")
|
||||
}
|
||||
|
||||
baseFlow.map { rules ->
|
||||
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,
|
||||
override fun composeUiState(
|
||||
items: List<DictRuleItemUi>,
|
||||
selectedIds: Set<String>,
|
||||
isSearch: Boolean,
|
||||
isUploading: Boolean,
|
||||
importState: BaseImportUiState<DictRule>
|
||||
): DictRuleUiState {
|
||||
return DictRuleUiState(
|
||||
items = items,
|
||||
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?) {
|
||||
_searchKey.value = key
|
||||
}
|
||||
override fun DictRule.toUiItem() = DictRuleItemUi(name, urlRule, showRule, enabled, this)
|
||||
override fun ruleItemToEntity(item: DictRuleItemUi): DictRule = item.rule
|
||||
|
||||
fun setSelection(ids: Set<String>) {
|
||||
_selectedIds.value = ids
|
||||
}
|
||||
override suspend fun generateJson(entities: List<DictRule>): String = GSON.toJson(entities)
|
||||
|
||||
|
||||
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> {
|
||||
override fun parseImportRules(text: String): List<DictRule> {
|
||||
return when {
|
||||
text.isJsonArray() -> GSON.fromJsonArray<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
|
||||
|| newRule.urlRule != oldRule.urlRule
|
||||
|| newRule.showRule != oldRule.showRule
|
||||
|| newRule.enabled != oldRule.enabled
|
||||
}
|
||||
|
||||
fun toggleImportSelection(index: Int) {
|
||||
val currentState = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
|
||||
val newItems = currentState.items.toMutableList()
|
||||
val item = newItems[index]
|
||||
newItems[index] = item.copy(isSelected = !item.isSelected)
|
||||
_importState.value = currentState.copy(items = newItems)
|
||||
override suspend fun findOldRule(newRule: DictRule): DictRule? {
|
||||
return repository.findById(newRule.name)
|
||||
}
|
||||
|
||||
fun toggleImportAll(isSelected: Boolean) {
|
||||
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() {
|
||||
override fun saveImportedRules() {
|
||||
val state = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val rulesToSave = state.items
|
||||
.filter { it.isSelected }
|
||||
.map { it.data }
|
||||
|
||||
repository.insert(*rulesToSave.toTypedArray())
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_importState.value = BaseImportUiState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelImport() {
|
||||
_importState.value = BaseImportUiState.Idle
|
||||
fun saveSortOrder() {
|
||||
val currentLocal = _localItems.value ?: return
|
||||
viewModelScope.launch {
|
||||
repository.moveOrder(currentLocal.map { it.rule })
|
||||
_localItems.value = null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSelectedRules(): List<DictRule> {
|
||||
val selectedIds = _selectedIds.value
|
||||
return repository.getByNames(selectedIds)
|
||||
fun enableSelectionByIds(ids: Set<String>) {
|
||||
viewModelScope.launch { repository.enableByIds(ids) }
|
||||
}
|
||||
|
||||
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) {
|
||||
context.sendToClip(GSON.toJson(dictRule))
|
||||
}
|
||||
@@ -309,5 +162,4 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
@@ -3,13 +3,8 @@ package io.legado.app.ui.replace
|
||||
import android.content.ClipData
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
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.layout.Arrangement
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.DropdownMenuItem
|
||||
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.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Tab
|
||||
@@ -56,13 +38,11 @@ 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.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -71,9 +51,7 @@ 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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.gson.Gson
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.BaseRuleEvent
|
||||
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.AnimatedText
|
||||
import io.legado.app.ui.widget.components.DraggableSelectionHandler
|
||||
import io.legado.app.ui.widget.components.EmptyMessageView
|
||||
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.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.RuleListScaffold
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
|
||||
@@ -119,42 +90,40 @@ fun ReplaceRuleScreen(
|
||||
onNavigateToEdit: (ReplaceEditRoute) -> Unit,
|
||||
) {
|
||||
|
||||
val uploadRepository: UploadRepository = koinInject()
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val rules = uiState.rules
|
||||
val rules = uiState.items
|
||||
val groups = uiState.groups
|
||||
val selectedIds = uiState.selectedIds
|
||||
val inSelectionMode = selectedIds.isNotEmpty()
|
||||
|
||||
var isSearch by remember { mutableStateOf(false) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
|
||||
var showDeleteSelectedDialog by remember { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
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 showFilePickerSheet by remember { mutableStateOf(false) }
|
||||
var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
|
||||
var isUploading by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboard.current
|
||||
var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
|
||||
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 ->
|
||||
viewModel.moveItemInList(from.index, to.index)
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
|
||||
@@ -179,18 +148,7 @@ fun ReplaceRuleScreen(
|
||||
val exportDoc = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/json"),
|
||||
onResult = { uri ->
|
||||
uri?.let { it ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -217,41 +175,7 @@ fun ReplaceRuleScreen(
|
||||
onSelectSysFile = {},
|
||||
onUpload = {
|
||||
showFilePickerSheet = false
|
||||
scope.launch {
|
||||
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
|
||||
}
|
||||
}
|
||||
viewModel.uploadSelectedRules(selectedIds, rules)
|
||||
},
|
||||
allowExtensions = arrayOf("json")
|
||||
)
|
||||
@@ -297,10 +221,32 @@ fun ReplaceRuleScreen(
|
||||
}
|
||||
|
||||
LaunchedEffect(groups) {
|
||||
val maxIndex = groups.size
|
||||
if (selectedTabIndex > maxIndex) {
|
||||
if (selectedTabIndex > groups.size) {
|
||||
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(
|
||||
onDismissRequest = { showDeleteRuleDialog = null },
|
||||
title = { Text(stringResource(R.string.delete)) },
|
||||
text = { Text(stringResource(R.string.sure_del) + showDeleteRuleDialog!!.name) },
|
||||
text = { Text(stringResource(R.string.del_msg)) },
|
||||
confirmButton = {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.delete(showDeleteRuleDialog!!)
|
||||
showDeleteRuleDialog = null
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.ok))
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
viewModel.delete(rule); showDeleteRuleDialog = null
|
||||
}) { Text(stringResource(R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteRuleDialog = null }) {
|
||||
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)
|
||||
Text(
|
||||
stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val allString = stringResource(R.string.all)
|
||||
val tabItems = remember(groups, allString) { listOf(allString) + groups }
|
||||
AnimatedVisibility(visible = groups.isNotEmpty()) {
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedTabIndex.coerceAtMost(tabItems.size - 1)
|
||||
.coerceAtLeast(0),
|
||||
edgePadding = 0.dp,
|
||||
divider = {},
|
||||
) {
|
||||
tabItems.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTabIndex == index,
|
||||
onClick = {
|
||||
selectedTabIndex = index
|
||||
val group = tabItems.getOrNull(index)
|
||||
if (group == allString) {
|
||||
viewModel.setSearchKey("")
|
||||
} else if (group != null) {
|
||||
viewModel.setSearchKey("group:$group")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
text = {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
RuleListScaffold(
|
||||
title = "替换规则",
|
||||
state = uiState,
|
||||
onBackClick = { onBackClick() },
|
||||
onSearchToggle = { viewModel.setSearchMode(!uiState.isSearch) },
|
||||
onSearchQueryChange = { viewModel.setSearchKey(it) },
|
||||
searchPlaceholder = stringResource(R.string.replace_purify_search),
|
||||
onClearSelection = { viewModel.setSelection(emptySet()) },
|
||||
onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
|
||||
onSelectInvert = { viewModel.setSelection(rules.map { it.id }.toSet() - 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.to_top),
|
||||
onClick = {
|
||||
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 ->
|
||||
Box(
|
||||
@@ -549,144 +439,77 @@ fun ReplaceRuleScreen(
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
if (rules.isEmpty()) {
|
||||
EmptyMessageView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
message = "没有替换规则!"
|
||||
)
|
||||
} else {
|
||||
FastScrollLazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
top = 8.dp,
|
||||
bottom = 120.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(rules, key = { it.id }) { ui ->
|
||||
with(sharedTransitionScope) {
|
||||
ReorderableSelectionItem(
|
||||
state = reorderableState,
|
||||
key = ui.id,
|
||||
title = ui.name,
|
||||
isEnabled = ui.isEnabled,
|
||||
isSelected = selectedRuleIds.contains(ui.id),
|
||||
inSelectionMode = inSelectionMode,
|
||||
canReorder = canReorder,
|
||||
onToggleSelection = {
|
||||
viewModel.toggleSelection(ui.id)
|
||||
},
|
||||
onEnabledChange = { enabled ->
|
||||
viewModel.update(ui.rule.copy(isEnabled = enabled))
|
||||
},
|
||||
onClickEdit = {
|
||||
onNavigateToEdit(
|
||||
ReplaceEditRoute(
|
||||
id = ui.id,
|
||||
pattern = ui.rule.pattern
|
||||
)
|
||||
FastScrollLazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
top = 8.dp,
|
||||
bottom = 120.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(filteredRules, key = { it.id }) { ui ->
|
||||
with(sharedTransitionScope) {
|
||||
ReorderableSelectionItem(
|
||||
state = reorderableState,
|
||||
key = ui.id,
|
||||
title = ui.name,
|
||||
isEnabled = ui.isEnabled,
|
||||
isSelected = selectedIds.contains(ui.id),
|
||||
inSelectionMode = inSelectionMode,
|
||||
canReorder = canReorder,
|
||||
onToggleSelection = {
|
||||
viewModel.toggleSelection(ui.id)
|
||||
},
|
||||
onEnabledChange = { enabled ->
|
||||
viewModel.update(ui.rule.copy(isEnabled = enabled))
|
||||
},
|
||||
onClickEdit = {
|
||||
onNavigateToEdit(
|
||||
ReplaceEditRoute(
|
||||
id = ui.id,
|
||||
pattern = ui.rule.pattern
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(key = "rule_${ui.id}"),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||
//clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(12.dp))
|
||||
),
|
||||
dropdownContent = { dismiss ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("移至顶部") },
|
||||
onClick = { viewModel.toTop(ui.rule); dismiss() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("移至底部") },
|
||||
onClick = { viewModel.toBottom(ui.rule); dismiss() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("删除") },
|
||||
onClick = { showDeleteRuleDialog = ui.rule; dismiss() }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(key = "rule_${ui.id}"),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||
//clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(12.dp))
|
||||
),
|
||||
dropdownContent = { dismiss ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("移至顶部") },
|
||||
onClick = { viewModel.toTop(ui.rule); dismiss() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("移至底部") },
|
||||
onClick = { viewModel.toBottom(ui.rule); dismiss() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("删除") },
|
||||
onClick = { showDeleteRuleDialog = ui.rule; dismiss() }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (inSelectionMode) {
|
||||
DraggableSelectionHandler(
|
||||
listState = listState,
|
||||
items = rules,
|
||||
selectedIds = selectedRuleIds,
|
||||
onSelectionChange = viewModel::setSelection,
|
||||
idProvider = { it.id },
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.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 }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,330 +2,117 @@ package io.legado.app.ui.replace
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.AppConst
|
||||
import io.legado.app.base.BaseRuleViewModel
|
||||
import io.legado.app.constant.AppPattern
|
||||
import io.legado.app.constant.PreferKey
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.ReplaceRule
|
||||
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.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.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.getPrefString
|
||||
import io.legado.app.utils.isAbsUrl
|
||||
import io.legado.app.utils.isJsonArray
|
||||
import io.legado.app.utils.isJsonObject
|
||||
import io.legado.app.utils.isUri
|
||||
import io.legado.app.utils.putPrefString
|
||||
import io.legado.app.utils.readText
|
||||
import io.legado.app.utils.splitNotBlank
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Immutable
|
||||
data class ReplaceRuleItemUi(
|
||||
val id: Long,
|
||||
override val id: Long,
|
||||
val name: String,
|
||||
val isEnabled: Boolean,
|
||||
val group: String?,
|
||||
val rule: ReplaceRule
|
||||
)
|
||||
) : SelectableItem<Long>
|
||||
|
||||
data class ReplaceRuleUiState(
|
||||
override val items: List<ReplaceRuleItemUi> = emptyList(),
|
||||
override val selectedIds: Set<Long> = emptySet(),
|
||||
override val searchKey: String = "",
|
||||
val sortMode: String = "desc",
|
||||
val searchKey: String? = null,
|
||||
val groups: List<String> = emptyList(),
|
||||
val rules: List<ReplaceRuleItemUi> = emptyList(),
|
||||
val isLoading: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* 替换规则数据修改
|
||||
* 修改数据要copy,直接修改会导致界面不刷新
|
||||
*/
|
||||
class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) {
|
||||
val interaction: InteractionState = InteractionState()
|
||||
) : RuleActionState<ReplaceRuleItemUi> {
|
||||
override val isSearch: Boolean get() = interaction.isSearchMode
|
||||
override val isUploading: Boolean get() = interaction.isUploading
|
||||
}
|
||||
|
||||
class ReplaceRuleViewModel(
|
||||
application: Application,
|
||||
uploadRepository: UploadRepository
|
||||
) : BaseRuleViewModel<ReplaceRuleItemUi, ReplaceRule, Long, ReplaceRuleUiState>(
|
||||
application,
|
||||
ReplaceRuleUiState(interaction = InteractionState(isLoading = true)),
|
||||
uploadRepository
|
||||
) {
|
||||
private val repository = ReplaceRuleRepository()
|
||||
private val _sortMode = MutableStateFlow(context.getPrefString(PreferKey.replaceSortMode, "desc") ?: "desc")
|
||||
private val _searchKey = MutableStateFlow<String?>(null)
|
||||
private val _uiRules = MutableStateFlow<List<ReplaceRuleItemUi>>(emptyList())
|
||||
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
|
||||
}
|
||||
}
|
||||
private val _group = MutableStateFlow<String?>(null)
|
||||
val group = _group.asStateFlow()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val rulesFlow = combine(_searchKey, _sortMode) { search, sort ->
|
||||
Pair(search, sort)
|
||||
}.flatMapLatest { (searchKey, sortMode) ->
|
||||
// 先获取基础数据
|
||||
val baseFlow = when {
|
||||
searchKey.isNullOrEmpty() -> repository.flowAll()
|
||||
searchKey == appCtx.getString(R.string.no_group) -> repository.flowNoGroup()
|
||||
searchKey.startsWith("group:") -> {
|
||||
val key = searchKey.substringAfter("group:")
|
||||
repository.flowGroupSearch("%$key%")
|
||||
override val rawDataFlow: Flow<List<ReplaceRule>> =
|
||||
combine(_group, _sortMode) { group, sortMode ->
|
||||
group to sortMode
|
||||
}.flatMapLatest { (group, sortMode) ->
|
||||
val baseFlow = when (group) {
|
||||
null -> repository.flowAll()
|
||||
"未分组" -> repository.flowNoGroup()
|
||||
else -> repository.flowGroupSearch(group)
|
||||
}
|
||||
else -> repository.flowSearch("%$searchKey%")
|
||||
}
|
||||
|
||||
baseFlow
|
||||
.map { rules ->
|
||||
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
|
||||
)
|
||||
baseFlow.map { rules ->
|
||||
sortRules(rules, sortMode)
|
||||
}
|
||||
}
|
||||
|
||||
val uiState: StateFlow<ReplaceRuleUiState> = combine(
|
||||
_sortMode,
|
||||
_searchKey,
|
||||
repository.flowGroups(),
|
||||
_uiRules
|
||||
) { sortMode, searchKey, groups, rules ->
|
||||
ReplaceRuleUiState(
|
||||
sortMode = sortMode,
|
||||
searchKey = searchKey,
|
||||
groups = groups,
|
||||
rules = rules,
|
||||
isLoading = false
|
||||
override fun filterData(data: List<ReplaceRule>, key: String): List<ReplaceRule> =
|
||||
if (key.isEmpty()) data
|
||||
else data.filter { it.name.contains(key, ignoreCase = true) }
|
||||
|
||||
|
||||
override fun composeUiState(
|
||||
items: List<ReplaceRuleItemUi>,
|
||||
selectedIds: Set<Long>,
|
||||
isSearch: Boolean,
|
||||
isUploading: Boolean,
|
||||
importState: BaseImportUiState<ReplaceRule>
|
||||
): 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) {
|
||||
_sortMode.value = mode
|
||||
context.putPrefString(PreferKey.replaceSortMode, mode)
|
||||
}
|
||||
override fun ReplaceRule.toUiItem() = ReplaceRuleItemUi(id, name, isEnabled, group, this)
|
||||
override fun ruleItemToEntity(item: ReplaceRuleItemUi): ReplaceRule = item.rule
|
||||
|
||||
fun setSearchKey(key: String?) {
|
||||
_searchKey.value = key
|
||||
}
|
||||
override suspend fun generateJson(entities: List<ReplaceRule>): String = GSON.toJson(entities)
|
||||
|
||||
fun setSelection(ids: Set<Long>) {
|
||||
_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> {
|
||||
override fun parseImportRules(text: String): List<ReplaceRule> {
|
||||
return when {
|
||||
text.isJsonArray() -> ReplaceAnalyzer.jsonToReplaceRules(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
|
||||
|| newRule.replacement != oldRule.replacement
|
||||
|| newRule.isRegex != oldRule.isRegex
|
||||
|| newRule.scope != oldRule.scope
|
||||
}
|
||||
|
||||
fun cancelImport() {
|
||||
_importState.value = BaseImportUiState.Idle
|
||||
override suspend fun findOldRule(newRule: ReplaceRule): ReplaceRule? {
|
||||
return appDb.replaceRuleDao.findById(newRule.id)
|
||||
}
|
||||
|
||||
fun toggleImportSelection(index: Int) {
|
||||
val currentState = _importState.value as? BaseImportUiState.Success ?: 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
|
||||
|
||||
override fun saveImportedRules() {
|
||||
val state = _importState.value as? BaseImportUiState.Success<ReplaceRule> ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val rulesToSave = state.items
|
||||
.filter { it.isSelected }
|
||||
@@ -381,7 +143,6 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
|
||||
if (state.keepOriginalName && oldRule != null) {
|
||||
rule.name = oldRule.name
|
||||
}
|
||||
|
||||
val targetGroup = state.customGroup?.trim()
|
||||
if (!targetGroup.isNullOrEmpty()) {
|
||||
if (state.isAddGroup) {
|
||||
@@ -395,13 +156,65 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
|
||||
}
|
||||
rule
|
||||
}
|
||||
|
||||
appDb.replaceRuleDao.insert(*rulesToSave.toTypedArray())
|
||||
|
||||
repository.update(*rulesToSave.toTypedArray())
|
||||
withContext(Dispatchers.Main) {
|
||||
_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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legado.app.ui.widget.components.card.GlassCard
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
@@ -83,9 +84,11 @@ fun Calendar(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
Column(modifier = Modifier
|
||||
.padding(vertical = 12.dp)
|
||||
.animateContentSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 12.dp)
|
||||
.animateContentSize()
|
||||
) {
|
||||
MonthNavigation(
|
||||
currentMonth = currentMonth,
|
||||
selectedDate = selectedDate,
|
||||
|
||||
@@ -45,6 +45,7 @@ fun SearchBarSection(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
placeholder: String = "搜索书名",
|
||||
leadingIcon: @Composable (() -> Unit)? = { Icon(Icons.Default.Search, null) },
|
||||
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
scrollState: LazyListState? = null,
|
||||
scope: CoroutineScope = rememberCoroutineScope(),
|
||||
@@ -73,7 +74,7 @@ fun SearchBarSection(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
placeholder = { Text(placeholder) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null) },
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = {
|
||||
Row(
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legado.app.ui.widget.components.card.GlassCard
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@@ -127,6 +128,7 @@ fun SettingItem(
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
imageVector != null -> Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = null,
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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
|
||||
|
||||
+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.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.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.animateColorAsState
|
||||
@@ -11,19 +11,19 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import io.legado.app.ui.widget.components.button.SmallIconButton
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.ReorderableLazyListState
|
||||
|
||||
@@ -73,78 +75,97 @@ fun SelectionItemCard(
|
||||
|
||||
GlassCard(
|
||||
onClick = onToggleSelection,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = containerColor
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.animateContentSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
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 = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
AnimatedVisibility(
|
||||
visible = inSelectionMode,
|
||||
enter = fadeIn() + expandHorizontally(),
|
||||
exit = fadeOut() + shrinkHorizontally()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = null
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
onEnabledChange?.let {
|
||||
Switch(
|
||||
checked = isEnabled,
|
||||
onCheckedChange = it
|
||||
},
|
||||
supportingContent = if (!subtitle.isNullOrBlank()) {
|
||||
{
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
)
|
||||
|
||||
if (onClickEdit != null) {
|
||||
IconButton(onClick = onClickEdit) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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) {
|
||||
trailingAction()
|
||||
}
|
||||
if (onClickEdit != null) {
|
||||
SmallIconButton(
|
||||
onClick = onClickEdit,
|
||||
icon = Icons.Default.Edit,
|
||||
contentDescription = "Edit"
|
||||
)
|
||||
}
|
||||
|
||||
if (dropdownContent != null) {
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "More")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
dropdownContent { showMenu = false }
|
||||
}
|
||||
if (trailingAction != null) {
|
||||
trailingAction()
|
||||
}
|
||||
|
||||
if (dropdownContent != null) {
|
||||
Box {
|
||||
SmallIconButton(
|
||||
onClick = { showMenu = true },
|
||||
icon = Icons.Default.MoreVert,
|
||||
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.Spacer
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
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.AnimatedText
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@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