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

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