[新增] 重写了Txt目录界面,优化了规则类的相关逻辑和界面

This commit is contained in:
HapeLee committed 2026-01-28 23:20:30 +08:00
1 parent 01d0ad2e99
commit b6a1814555
35 files changed
+2018 -1977

No files matched your search

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