diff --git a/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt b/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt new file mode 100644 index 000000000..7a6b91330 --- /dev/null +++ b/app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt @@ -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, Entity, ID, S : RuleActionState>( + application: Application, + initialState: S, + private val uploadRepository: UploadRepository? = null // 设为可空,提高灵活性 +) : BaseViewModel(application) { + + protected val _searchKey = MutableStateFlow("") + protected val _selectedIds = MutableStateFlow>(emptySet()) + protected val _isSearchMode = MutableStateFlow(false) + protected val _isUploading = MutableStateFlow(false) + protected val _localItems = MutableStateFlow?>(null) + protected val _importState = MutableStateFlow>(BaseImportUiState.Idle) + val importState = _importState.asStateFlow() + protected val _eventChannel = Channel() + val events = _eventChannel.receiveAsFlow() + + abstract val rawDataFlow: Flow> + + open fun filterData(data: List, key: String): List = data + + @OptIn(FlowPreview::class) + private val itemsFlow: Flow> 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 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, + selectedIds: Set, + isSearch: Boolean, + isUploading: Boolean, + importState: BaseImportUiState + ): S + + // 抽象方法:将实体转换为UI Item + abstract fun Entity.toUiItem(): T + + // 抽象方法:生成用于导出/上传的JSON + abstract suspend fun generateJson(entities: List): String + + // 抽象方法:解析导入的文本为实体列表 + abstract fun parseImportRules(text: String): List + + // 抽象方法:判断新旧实体是否有变化 (用于导入去重) + 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) { + _selectedIds.value = ids + } + + fun exportToUri(uri: Uri, rules: List, selectedIds: Set) { + 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, rules: List) { + 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 ?: 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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt b/app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt index 32981d1d4..3d1f9f237 100644 --- a/app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt @@ -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> + @Query("select * from txtTocRules where name LIKE '%' || :key || '%' order by serialNumber") + fun flowSearch(key: String): Flow> + + @Query("select * from txtTocRules where id in (:ids)") + fun getByIds(ids: Set): List + @get:Query("select * from txtTocRules order by serialNumber") val all: List diff --git a/app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt b/app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt index eb8ea2e7f..66fa6ad0d 100644 --- a/app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt @@ -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) { diff --git a/app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt index d449415be..d43497ee7 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt @@ -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) { - withContext(Dispatchers.IO) { - val array = Array(rules.size) { - rules[it].copy(isEnabled = true) - } - appDb.replaceRuleDao.update(*array) - } - } - - suspend fun disableSelection(rules: List) { - 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, isDesc: Boolean = false) { + suspend fun moveOrder(currentRules: List, 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()) } } diff --git a/app/src/main/java/io/legado/app/data/repository/TxtTocRuleRepository.kt b/app/src/main/java/io/legado/app/data/repository/TxtTocRuleRepository.kt new file mode 100644 index 000000000..7914c0f74 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/TxtTocRuleRepository.kt @@ -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> = dao.observeAll() + + fun flowSearch(key: String): Flow> = 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) = withContext(Dispatchers.IO) { + val rules = dao.getByIds(ids.toSet()) + dao.delete(*rules.toTypedArray()) + } + + suspend fun enableByIds(ids: Collection, 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) = withContext(Dispatchers.IO) { + rules.forEachIndexed { index, rule -> + rule.serialNumber = index + 1 + } + dao.update(*rules.toTypedArray()) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 2c9a52a2c..209e1dc76 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -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().bookDao } single { get().bookChapterDao } - single { ReadRecordRepository(get()) } - - single { BookRepository(get(), get()) } + singleOf(::ReadRecordRepository) + singleOf(::BookRepository) + singleOf(::SearchContentRepository) single { DirectLinkUploadRepository() } single { 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) } diff --git a/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt b/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt index 4e2a3a47a..39d342224 100644 --- a/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt +++ b/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt @@ -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 ) diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt index 35be13bdc..54a5ddb37 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt index 8da0a44e1..8d81be832 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt @@ -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( diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt index a60fb872b..bfcdb7fe7 100644 --- a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt new file mode 100644 index 000000000..4ca0926c9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt @@ -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(null) } + var showDeleteRuleDialog by remember { mutableStateOf(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)?.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) + 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) + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt index 07adc3b77..a5cc74383 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt @@ -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(), - 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 = 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 - } } diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleAdapter.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleAdapter.kt deleted file mode 100644 index 0ede64f68..000000000 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleAdapter.kt +++ /dev/null @@ -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(context), - ItemTouchCallback.Callback { - - private val selected = linkedSetOf() - - val selection: List - get() = getItems().filter { - selected.contains(it) - } - - val diffItemCallBack = object : DiffUtil.ItemCallback() { - - 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 - ) { - 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() - - override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - if (movedItems.isNotEmpty()) { - callBack.update(*movedItems.toTypedArray()) - movedItems.clear() - } - } - - val dragSelectCallback: DragSelectTouchHelper.Callback = - object : DragSelectTouchHelper.AdvanceCallback(Mode.ToggleAndReverse) { - override fun currentSelectedId(): MutableSet { - 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() - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleDialog.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleDialog.kt index 391d8d13e..a6b765e76 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleDialog.kt @@ -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) } } } diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt index fd3597fde..cc7a2ecd3 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt @@ -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 +data class TxtTocRuleUiState( + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + val interaction: InteractionState = InteractionState() +) : RuleActionState { + override val isSearch: Boolean get() = interaction.isSearchMode + override val isUploading: Boolean get() = interaction.isUploading } + + +class TxtTocRuleViewModel( + application: Application, + uploadRepository: UploadRepository +) : BaseRuleViewModel( + application, + TxtTocRuleUiState(interaction = InteractionState(isLoading = true)), + uploadRepository +) { + private val repository = TxtTocRuleRepository() + + override val rawDataFlow: Flow> = repository.flowAll() + + override fun TxtTocRule.toUiItem() = + TxtTocRuleItemUi(id, name, enable, this, example = example ?: "") + + override fun filterData(data: List, key: String): List { + 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, + selectedIds: Set, + isSearch: Boolean, + isUploading: Boolean, + importState: BaseImportUiState + ): 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) = + viewModelScope.launch { repository.enableByIds(ids, true) } + + fun disableSelectionByIds(ids: Set) = + viewModelScope.launch { repository.enableByIds(ids, false) } + + fun delSelectionByIds(ids: Set) = viewModelScope.launch { repository.deleteByIds(ids) } + + override suspend fun generateJson(entities: List): String = GSON.toJson(entities) + + override fun parseImportRules(text: String): List { + return when { + text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() + text.isJsonObject() -> listOf(GSON.fromJsonObject(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 ?: 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(text).getOrThrow() + } catch (e: Exception) { + context.toastOnUi("格式不对") + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleActivity.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleActivity.kt index ff77cd7a2..69931665b 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleActivity.kt +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleActivity.kt @@ -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 = 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() - } - } } diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt index 7dbd657da..0f6da0659 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt @@ -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(null) } var showDeleteRuleDialog by remember { mutableStateOf(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) + 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 } - ) - ) - ) - } - } } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt index e9d13166e..c0ff7613a 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt @@ -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 data class DictRuleUiState( - val searchKey: String? = null, - val items: List = emptyList(), - val dictRule: List = emptyList(), - val selectedIds: Set = emptySet(), - val isLoading: Boolean = false -) - -class DictRuleViewModel(application: Application) : BaseViewModel(application) { + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + val interaction: InteractionState = InteractionState() +) : RuleActionState { + override val isSearch: Boolean get() = interaction.isSearchMode + override val isUploading: Boolean get() = interaction.isUploading +} +class DictRuleViewModel( + application: Application, + uploadRepository: UploadRepository +) : BaseRuleViewModel( + application, + DictRuleUiState(interaction = InteractionState(isLoading = true)), + uploadRepository +) { private val repository = DictRuleRepository() - private val _searchKey = MutableStateFlow(null) - private val _uiRules = MutableStateFlow>(emptyList()) - private val _selectedIds = MutableStateFlow>(emptySet()) - val selectedIds: StateFlow> = _selectedIds - private val _importState = MutableStateFlow>(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> = repository.flowAll() + + override fun filterData(data: List, key: String): List { + 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> = - rulesFlow.map { rules -> - rules.map { rule -> - DictRuleItemUi( - name = rule.name, - urlRule = rule.urlRule, - showRule = rule.showRule, - isEnabled = rule.enabled, - rule = rule - ) - } - } - - val uiState: StateFlow = combine( - _searchKey, - _uiRules, - _selectedIds - ) { searchKey, rules, selectedIds -> - DictRuleUiState( - searchKey = searchKey, - items = rules, + override fun composeUiState( + items: List, + selectedIds: Set, + isSearch: Boolean, + isUploading: Boolean, + importState: BaseImportUiState + ): 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) { - _selectedIds.value = ids - } + override suspend fun generateJson(entities: List): String = GSON.toJson(entities) - - fun enableSelectionByIds(ids: Set) { - execute { - repository.enableByIds(ids) - } - } - - fun disableSelectionByIds(ids: Set) { - execute { - repository.disableByIds(ids) - } - } - - fun delSelectionByIds(ids: Set) { - 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 { + override fun parseImportRules(text: String): List { return when { text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() text.isJsonObject() -> listOf(GSON.fromJsonObject(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 ?: 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 ?: 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 ?: 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 { - val selectedIds = _selectedIds.value - return repository.getByNames(selectedIds) + fun enableSelectionByIds(ids: Set) { + viewModelScope.launch { repository.enableByIds(ids) } } + fun disableSelectionByIds(ids: Set) { + viewModelScope.launch { repository.disableByIds(ids) } + } + + fun delSelectionByIds(ids: Set) { + 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 } } - -} +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt b/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt index 3d9cd580c..e33ed31c6 100644 --- a/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt +++ b/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index 17208eca5..6d050b432 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -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(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(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) + 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) + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt index d3f26d2a9..9f791a1f6 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt @@ -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 data class ReplaceRuleUiState( + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", val sortMode: String = "desc", - val searchKey: String? = null, val groups: List = emptyList(), - val rules: List = emptyList(), - val isLoading: Boolean = false -) - -/** - * 替换规则数据修改 - * 修改数据要copy,直接修改会导致界面不刷新 - */ -class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) { + val interaction: InteractionState = InteractionState() +) : RuleActionState { + override val isSearch: Boolean get() = interaction.isSearchMode + override val isUploading: Boolean get() = interaction.isUploading +} +class ReplaceRuleViewModel( + application: Application, + uploadRepository: UploadRepository +) : BaseRuleViewModel( + 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(null) - private val _uiRules = MutableStateFlow>(emptyList()) - private val _selectedRuleIds = MutableStateFlow>(emptySet()) - val selectedRuleIds: StateFlow> = _selectedRuleIds - private val _importState = MutableStateFlow>(BaseImportUiState.Idle) - val importState: StateFlow> = _importState.asStateFlow() - - fun toggleSelection(id: Long) { - _selectedRuleIds.update { - if (it.contains(id)) it - id else it + id - } - } + private val _group = MutableStateFlow(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> = + 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 { it.order.toLong() } - "desc" -> compareByDescending { it.order.toLong() } - "name_asc" -> compareBy { it.name.lowercase() } - "name_desc" -> compareByDescending { it.name.lowercase() } - else -> null - } - - if (comparator != null) rules.sortedWith(comparator) else rules - } - }.flowOn(Dispatchers.Default) - - private val ruleUiFlow: Flow> = - 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 = 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, key: String): List = + if (key.isEmpty()) data + else data.filter { it.name.contains(key, ignoreCase = true) } + + + override fun composeUiState( + items: List, + selectedIds: Set, + isSearch: Boolean, + isUploading: Boolean, + importState: BaseImportUiState + ): 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): String = GSON.toJson(entities) - fun setSelection(ids: Set) { - _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) { - execute { - repository.enableSelection(rules) - } - } - - fun disableSelection(rules: List) { - execute { - repository.disableSelection(rules) - } - } - - fun enableSelectionByIds(ids: Set) { - execute { - repository.enableByIds(ids) - } - } - - fun disableSelectionByIds(ids: Set) { - execute { - repository.disableByIds(ids) - } - } - - fun delSelectionByIds(ids: Set) { - execute { - repository.deleteByIds(ids) - } - } - - fun topSelectByIds(ids: Set) { - execute { - repository.topByIds(ids, _sortMode.value == "desc") - } - } - - fun bottomSelectByIds(ids: Set) { - 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 { + override fun parseImportRules(text: String): List { 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 ?: 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, mode: String): List { + val comparator = when (mode) { + "asc" -> compareBy { it.order.toLong() } + "desc" -> compareByDescending { it.order.toLong() } + "name_asc" -> compareBy { it.name.lowercase() } + "name_desc" -> compareByDescending { 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) = viewModelScope.launch { repository.enableByIds(ids) } + fun disableSelectionByIds(ids: Set) = + viewModelScope.launch { repository.disableByIds(ids) } + + fun delSelectionByIds(ids: Set) = 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) = + viewModelScope.launch { repository.topByIds(ids, _sortMode.value == "desc") } + + fun bottomSelectByIds(ids: Set) = + viewModelScope.launch { repository.bottomByIds(ids, _sortMode.value == "desc") } + + fun upGroup(oldGroup: String, newGroup: String?) = + viewModelScope.launch { repository.upGroup(oldGroup, newGroup) } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/GlassTonalIconButton.kt b/app/src/main/java/io/legado/app/ui/widget/GlassTonalIconButton.kt deleted file mode 100644 index 929335fa1..000000000 --- a/app/src/main/java/io/legado/app/ui/widget/GlassTonalIconButton.kt +++ /dev/null @@ -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) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/Calender.kt b/app/src/main/java/io/legado/app/ui/widget/components/Calender.kt index 7fc55ee8c..f5f4371c0 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/Calender.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/Calender.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt index b22778e98..41762ca52 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt index 436a84c52..865c3f845 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt b/app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt index 6ff81521f..bc6f5d9bc 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt similarity index 96% rename from app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt rename to app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt index d5929cd41..b2df9fc52 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt new file mode 100644 index 000000000..21e9f5c79 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt @@ -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), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTopBarButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTopBarButton.kt new file mode 100644 index 000000000..b1bddcf99 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTopBarButton.kt @@ -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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/GlassCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt similarity index 97% rename from app/src/main/java/io/legado/app/ui/widget/components/GlassCard.kt rename to app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt index e80a2d2ac..201ec3d34 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/GlassCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SelectionItemCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt similarity index 64% rename from app/src/main/java/io/legado/app/ui/widget/components/SelectionItemCard.kt rename to app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt index 870287d43..3d0b83292 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SelectionItemCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt @@ -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) - ) + } + } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/TextCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt similarity index 95% rename from app/src/main/java/io/legado/app/ui/widget/components/TextCard.kt rename to app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt index 47c67fac4..90f92e03b 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/TextCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleActionState.kt b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleActionState.kt new file mode 100644 index 000000000..913731e26 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleActionState.kt @@ -0,0 +1,19 @@ +package io.legado.app.ui.widget.components.rules + +interface RuleActionState { + val items: List + val selectedIds: Set + 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 { + val id: T +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt new file mode 100644 index 000000000..fdd530caa --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt @@ -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 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") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt new file mode 100644 index 000000000..b1289ae71 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt @@ -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 RuleListScaffold( + title: String, + state: RuleActionState, + 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, + onDeleteSelected: (Set) -> 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 + ) + } + } + } + ) +} \ No newline at end of file