diff --git a/app/src/main/java/io/legado/app/data/dao/DictRuleDao.kt b/app/src/main/java/io/legado/app/data/dao/DictRuleDao.kt index 6dfd601bf..2802f48e0 100644 --- a/app/src/main/java/io/legado/app/data/dao/DictRuleDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/DictRuleDao.kt @@ -1,10 +1,14 @@ 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.DictRule import kotlinx.coroutines.flow.Flow - @Dao interface DictRuleDao { @@ -17,9 +21,15 @@ interface DictRuleDao { @Query("select * from dictRules order by sortNumber") fun flowAll(): Flow> + @Query("select * from dictRules where name LIKE '%' || :key || '%' order by sortNumber") + fun flowSearch(key: String): Flow> + @Query("select * from dictRules where name = :name") fun getByName(name: String): DictRule? + @Query("SELECT * FROM dictRules WHERE name IN (:names)") + fun getByNames(names: Set): List + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg dictRule: DictRule) @@ -29,4 +39,10 @@ interface DictRuleDao { @Delete fun delete(vararg dictRule: DictRule) -} \ No newline at end of file + @Query("UPDATE dictRules SET enabled = :enabled WHERE name IN (:names)") + suspend fun updateEnabled(names: Set, enabled: Boolean) + + @Query("DELETE FROM dictRules WHERE name IN (:names)") + suspend fun deleteByIds(names: Set) + +} 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 new file mode 100644 index 000000000..618abcadd --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt @@ -0,0 +1,64 @@ +package io.legado.app.data.repository + +import io.legado.app.data.appDb +import io.legado.app.data.entities.DictRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +class DictRuleRepository { + + private val dao = appDb.dictRuleDao + + fun flowAll(): Flow> { + return dao.flowAll() + } + + fun flowSearch(key: String): Flow> { + return dao.flowSearch(key) + } + + fun getAll(): List { + return dao.all + } + + fun insert(vararg rule: DictRule) { + dao.insert(*rule) + } + + fun delete(vararg rule: DictRule) { + dao.delete(*rule) + } + + fun update(vararg rule: DictRule) { + dao.update(*rule) + } + + suspend fun enableByIds(names: Set) = withContext(Dispatchers.IO) { + if (names.isEmpty()) return@withContext + val rules = dao.getByNames(names) + val updated = rules.map { it.copy(enabled = true) } + dao.update(*updated.toTypedArray()) + } + + suspend fun disableByIds(names: Set) = withContext(Dispatchers.IO) { + if (names.isEmpty()) return@withContext + val rules = dao.getByNames(names) + val updated = rules.map { it.copy(enabled = false) } + dao.update(*updated.toTypedArray()) + } + + suspend fun deleteByIds(names: Set) = withContext(Dispatchers.IO) { + if (names.isEmpty()) return@withContext + val rules = dao.getByNames(names) + dao.delete(*rules.toTypedArray()) + } + + suspend fun moveOrder(rules: List) = withContext(Dispatchers.IO) { + val updatedRules = rules.mapIndexed { index, rule -> + rule.copy(sortNumber = index + 1) + } + dao.update(*updatedRules.toTypedArray()) + } + +} 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 13cba82d8..ff77cd7a2 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,62 +1,29 @@ package io.legado.app.ui.dict.rule +//import io.legado.app.lib.theme.primaryColor 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 androidx.recyclerview.widget.LinearLayoutManager +import androidx.compose.runtime.Composable 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.DictRule -import io.legado.app.databinding.ActivityDictRuleBinding +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.lib.theme.primaryColor import io.legado.app.ui.association.ImportDictRuleDialog 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.ui.theme.AppTheme 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.flowOn -import kotlinx.coroutines.launch -class DictRuleActivity : VMBaseActivity(), - PopupMenu.OnMenuItemClickListener, - SelectActionBar.CallBack, - DictRuleAdapter.CallBack { +class DictRuleActivity : BaseComposeActivity() { - override val viewModel by viewModels() - override val binding by viewBinding(ActivityDictRuleBinding::inflate) private val importRecordKey = "dictRuleUrls" - private val adapter by lazy { DictRuleAdapter(this, this) } - private val qrCodeResult = registerForActivityResult(QrCodeResult()) { - it ?: return@registerForActivityResult - showDialogFragment( - ImportDictRuleDialog(it) - ) - } + private val importDoc = registerForActivityResult(HandleFileContract()) { kotlin.runCatching { it.uri?.readText(this)?.let { @@ -88,134 +55,17 @@ class DictRuleActivity : VMBaseActivity showDialogFragment() - 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("dictRuleHelp") - } - return super.onCompatOptionsItemSelected(item) - } - - 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( - "exportDictRule.json", - GSON.toJson(adapter.selection).toByteArray(), - "application/json" - ) - } - } - return true - } - - override fun onClickSelectBarMainAction() { - viewModel.delete(*adapter.selection.toTypedArray()) - } - - override fun selectAll(selectAll: Boolean) { - if (selectAll) { - adapter.selectAll() - } else { - adapter.revertSelection() - } - } - - override fun revertSelection() { - adapter.revertSelection() - } - - override fun update(vararg rule: DictRule) { - viewModel.update(*rule) - } - - override fun delete(rule: DictRule) { - alert(R.string.draw) { - setMessage(getString(R.string.sure_del) + "\n" + rule.name) - noButton() - yesButton { - viewModel.delete(rule) - } - } - } - - override fun edit(rule: DictRule) { - showDialogFragment(DictRuleEditDialog(rule.name)) - } - - override fun upOrder() { - viewModel.upSortNumber() - } - - override fun upCountView() { - binding.selectActionBar.upCountView( - adapter.selection.size, - adapter.itemCount - ) - } - @SuppressLint("InflateParams") private fun showImportDialog() { val aCache = ACache.get(cacheDir = false) diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleAdapter.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleAdapter.kt deleted file mode 100644 index f4bbd5dfd..000000000 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleAdapter.kt +++ /dev/null @@ -1,209 +0,0 @@ -package io.legado.app.ui.dict.rule - -import android.content.Context -import android.os.Bundle -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.data.entities.DictRule -import io.legado.app.databinding.ItemDictRuleBinding -//import io.legado.app.lib.theme.backgroundColor -import io.legado.app.ui.widget.recycler.DragSelectTouchHelper -import io.legado.app.ui.widget.recycler.ItemTouchCallback - - -class DictRuleAdapter(context: Context, var callBack: CallBack) : - RecyclerAdapter(context), - ItemTouchCallback.Callback { - - private val selected = linkedSetOf() - - val selection: List - get() { - return getItems().filter { - selected.contains(it) - } - } - - val diffItemCallBack = object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: DictRule, newItem: DictRule): Boolean { - return oldItem.name == newItem.name - } - - override fun areContentsTheSame(oldItem: DictRule, newItem: DictRule): Boolean { - if (oldItem.name != newItem.name) { - return false - } - if (oldItem.enabled != newItem.enabled) { - return false - } - return true - } - - override fun getChangePayload(oldItem: DictRule, newItem: DictRule): Any? { - val payload = Bundle() - if (oldItem.name != newItem.name) { - payload.putBoolean("upName", true) - } - if (oldItem.enabled != newItem.enabled) { - payload.putBoolean("enabled", newItem.enabled) - } - if (payload.isEmpty) { - return null - } - return payload - } - } - - 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 getViewBinding(parent: ViewGroup): ItemDictRuleBinding { - return ItemDictRuleBinding.inflate(inflater, parent, false) - } - - override fun onCurrentListChanged() { - callBack.upCountView() - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemDictRuleBinding, - item: DictRule, - payloads: MutableList - ) { - binding.run { - if (payloads.isEmpty()) { - //root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f)) - cbName.text = item.name - swtEnabled.isChecked = item.enabled - cbName.isChecked = selected.contains(item) - } else { - for (i in payloads.indices) { - val bundle = payloads[i] as Bundle - bundle.keySet().forEach { - when (it) { - "selected" -> cbName.isChecked = selected.contains(item) - "upName" -> cbName.text = item.name - "enabled" -> swtEnabled.isChecked = item.enabled - } - } - } - } - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemDictRuleBinding) { - binding.apply { - swtEnabled.setOnCheckedChangeListener { buttonView, isChecked -> - if (buttonView.isPressed) { - getItem(holder.layoutPosition)?.let { - it.enabled = isChecked - callBack.update(it) - } - } - } - cbName.setOnClickListener { - getItem(holder.layoutPosition)?.let { - if (cbName.isChecked) { - selected.add(it) - } else { - selected.remove(it) - } - } - callBack.upCountView() - } - ivEdit.setOnClickListener { - getItem(holder.layoutPosition)?.let { - callBack.edit(it) - } - } - ivDelete.setOnClickListener { - getItem(holder.layoutPosition)?.let { - callBack.delete(it) - } - } - } - } - - override fun swap(srcPosition: Int, targetPosition: Int): Boolean { - val srcItem = getItem(srcPosition) - val targetItem = getItem(targetPosition) - if (srcItem != null && targetItem != null) { - if (srcItem.sortNumber == targetItem.sortNumber) { - callBack.upOrder() - } else { - val srcOrder = srcItem.sortNumber - srcItem.sortNumber = targetItem.sortNumber - targetItem.sortNumber = srcOrder - movedItems.add(srcItem) - movedItems.add(targetItem) - } - } - swapItem(srcPosition, targetPosition) - return true - } - - private val movedItems = linkedSetOf() - - 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): DictRule { - 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 update(vararg rule: DictRule) - fun delete(rule: DictRule) - fun edit(rule: DictRule) - fun upOrder() - fun upCountView() - } -} diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditDialog.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditDialog.kt deleted file mode 100644 index 45e417d16..000000000 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditDialog.kt +++ /dev/null @@ -1,122 +0,0 @@ -package io.legado.app.ui.dict.rule - -import android.app.Application -import android.os.Bundle -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.widget.Toolbar - -import androidx.fragment.app.viewModels -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.base.BaseViewModel -import io.legado.app.data.appDb -import io.legado.app.data.entities.DictRule -import io.legado.app.databinding.DialogDictRuleEditBinding -//import io.legado.app.lib.theme.primaryColor -import io.legado.app.utils.* -import io.legado.app.utils.viewbindingdelegate.viewBinding - -class DictRuleEditDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_dict_rule_edit), - Toolbar.OnMenuItemClickListener { - - val viewModel by viewModels() - val binding by viewBinding(DialogDictRuleEditBinding::bind) - - constructor(name: String) : this() { - arguments = Bundle().apply { - putString("name", name) - } - } - - override fun onStart() { - super.onStart() - setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - //binding.toolBar.setBackgroundColor(primaryColor) - binding.toolBar.inflateMenu(R.menu.dict_rule_edit) - //binding.toolBar.menu.applyTint(requireContext()) - binding.toolBar.setOnMenuItemClickListener(this) - viewModel.initData(arguments?.getString("name")) { - upRuleView(viewModel.dictRule) - } - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_save -> viewModel.save(getDictRule()) { - dismissAllowingStateLoss() - } - R.id.menu_copy_rule -> viewModel.copyRule(getDictRule()) - R.id.menu_paste_rule -> viewModel.pasteRule { - upRuleView(it) - } - } - return true - } - - private fun upRuleView(dictRule: DictRule?) { - binding.tvRuleName.setText(dictRule?.name) - binding.tvUrlRule.setText(dictRule?.urlRule) - binding.tvShowRule.setText(dictRule?.showRule) - } - - private fun getDictRule(): DictRule { - val dictRule = viewModel.dictRule?.copy() ?: DictRule() - dictRule.name = binding.tvRuleName.text.toString() - dictRule.urlRule = binding.tvUrlRule.text.toString() - dictRule.showRule = binding.tvShowRule.text.toString() - return dictRule - } - - class DictRuleEditViewModel(application: Application) : BaseViewModel(application) { - - var dictRule: DictRule? = null - - fun initData(name: String?, onFinally: () -> Unit) { - execute { - if (dictRule == null && name != null) { - dictRule = appDb.dictRuleDao.getByName(name) - } - }.onFinally { - onFinally.invoke() - } - } - - fun save(newDictRule: DictRule, onFinally: () -> Unit) { - execute { - dictRule?.let { - appDb.dictRuleDao.delete(it) - } - appDb.dictRuleDao.insert(newDictRule) - dictRule = newDictRule - }.onFinally { - onFinally.invoke() - } - } - - fun copyRule(dictRule: DictRule) { - context.sendToClip(GSON.toJson(dictRule)) - } - - fun pasteRule(success: (DictRule) -> Unit) { - val text = context.getClipText() - if (text.isNullOrBlank()) { - context.toastOnUi("剪贴板没有内容") - return - } - execute { - GSON.fromJsonObject(text).getOrThrow() - }.onSuccess { - success.invoke(it) - }.onError { - context.toastOnUi("格式不对") - } - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt new file mode 100644 index 000000000..e543a8084 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt @@ -0,0 +1,174 @@ +package io.legado.app.ui.dict.rule + +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.ModalBottomSheet +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.data.entities.DictRule +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DictRuleEditSheet( + rule: DictRule?, + onDismissRequest: () -> Unit, + onSave: (DictRule) -> Unit, + onCopy: (DictRule) -> Unit, + onPaste: () -> DictRule? +) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var name by remember(rule) { mutableStateOf(rule?.name ?: "") } + var urlRule by remember(rule) { mutableStateOf(rule?.urlRule ?: "") } + var showRule by remember(rule) { mutableStateOf(rule?.showRule ?: "") } + var showMenu by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + CenterAlignedTopAppBar( + title = { Text(stringResource(R.string.dict_rule)) }, + 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( + DictRule( + name, + urlRule, + showRule, + enabled = rule?.enabled ?: true + ) + ) + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.paste_rule)) }, + leadingIcon = { Icon(Icons.Default.ContentPaste, null) }, + onClick = { + scope.launch { + onPaste()?.let { + name = it.name + urlRule = it.urlRule + showRule = it.showRule + } + } + 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 = urlRule, + onValueChange = { urlRule = it }, + label = { Text(stringResource(R.string.url_rule)) } + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = showRule, + onValueChange = { showRule = it }, + label = { Text(stringResource(R.string.show_rule)) }, + minLines = 3 + ) + } + } + + FloatingActionButton( + onClick = { + onSave( + rule?.copy(name = name, urlRule = urlRule, showRule = showRule) + ?: DictRule(name = name, urlRule = urlRule, showRule = showRule) + ) + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Icon(Icons.Default.Save, contentDescription = "Save") + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..a5261f2e6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt @@ -0,0 +1,351 @@ +package io.legado.app.ui.dict.rule + +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.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +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.MediumFlexibleTopAppBar +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +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.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.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +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.viewmodel.compose.viewModel +import io.legado.app.R +import io.legado.app.data.entities.DictRule +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.ReorderableSelectionItem +import io.legado.app.ui.widget.components.SearchBarSection +import io.legado.app.ui.widget.components.SelectionBottomBar +import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun DictRuleScreen( + viewModel: DictRuleViewModel = viewModel(), + onBackClick: () -> Unit +) { + + val uiState by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() + var isSearch by remember { mutableStateOf(false) } + val selectedIds by viewModel.selectedIds.collectAsState() + val inSelectionMode = selectedIds.isNotEmpty() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + 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) } + val reorderableState = rememberReorderableLazyListState(listState) { from, to -> + viewModel.moveItemInList(from.index, to.index) + hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + } + + LaunchedEffect(reorderableState.isAnyItemDragging) { + if (!reorderableState.isAnyItemDragging) { + viewModel.saveSortOrder() + } + } + + if (showDeleteRuleDialog != null) { + AlertDialog( + onDismissRequest = { showDeleteRuleDialog = null }, + title = { Text(stringResource(R.string.delete)) }, + text = { Text(stringResource(R.string.sure_del) + showDeleteRuleDialog!!.name) }, + confirmButton = { + OutlinedButton( + onClick = { + viewModel.delete(showDeleteRuleDialog!!) + showDeleteRuleDialog = null + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + containerColor = Color.Transparent, + ), + ) { + 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)) + } + } + ) + } + + if (showEditSheet) { + DictRuleEditSheet( + rule = editingRule, + onDismissRequest = { showEditSheet = false }, + onSave = { + if (editingRule == null) { + viewModel.insert(it) + } else { + viewModel.update(it) + } + showEditSheet = false + }, + onCopy = { viewModel.copyRule(it) }, + onPaste = { viewModel.pasteRule() } + ) + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + Column { + MediumFlexibleTopAppBar( + title = { + val titleText = remember(inSelectionMode, selectedIds, uiState.items) { + when { + inSelectionMode -> "已选择 ${selectedIds.size}/${uiState.items.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") + } + } + }, + scrollBehavior = scrollBehavior + ) + AnimatedVisibility(visible = isSearch && !inSelectionMode) { + SearchBarSection( + query = uiState.searchKey ?: "", + onQueryChange = { + viewModel.setSearchKey(it) + }, + placeholder = stringResource(id = R.string.search) + ) + } + } + }, + floatingActionButton = { + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text("Localized description") } }, + state = rememberTooltipState(), + ) { + FloatingActionButton( + modifier = Modifier.animateFloatingActionButton( + visible = !inSelectionMode, + alignment = Alignment.BottomEnd, + ), + onClick = { + editingRule = null + showEditSheet = true + } + ) { + Icon(Icons.Default.Add, contentDescription = "Add Rule") + } + } + } + ) { paddingValues -> + 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(uiState.items, key = { it.name }) { item -> + ReorderableSelectionItem( + state = reorderableState, + key = item.name, + title = item.name, + isEnabled = item.isEnabled, + isSelected = selectedIds.contains(item.name), + 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), + trailingAction = { + IconButton( + onClick = { + showDeleteRuleDialog = item.rule + } + ) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + } + ) + } + } + if (inSelectionMode) { + DraggableSelectionHandler( + listState = listState, + items = uiState.items, + selectedIds = selectedIds, + onSelectionChange = viewModel::setSelection, + idProvider = { it.name }, + 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(uiState.items.map { it.name }.toSet()) + }, + onSelectInvert = { + val allIds = uiState.items.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()) + } + ) + ) + ) + } + + } + } +} \ 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 e3f8bdbe1..b208ae547 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 @@ -1,57 +1,184 @@ package io.legado.app.ui.dict.rule import android.app.Application +import androidx.compose.runtime.Immutable +import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel -import io.legado.app.constant.AppLog -import io.legado.app.data.appDb import io.legado.app.data.entities.DictRule +import io.legado.app.data.repository.DictRuleRepository import io.legado.app.help.DefaultData +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.getClipText +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 + +@Immutable +data class DictRuleItemUi( + val name: String, + val urlRule: String, + val showRule: String, + val isEnabled: Boolean, + val rule: DictRule +) + +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) { + private val repository = DictRuleRepository() + private val _searchKey = MutableStateFlow(null) + private val _uiRules = MutableStateFlow>(emptyList()) - fun update(vararg dictRule: DictRule) { + private val _selectedIds = MutableStateFlow>(emptySet()) + val selectedIds: StateFlow> = _selectedIds.asStateFlow() + + fun toggleSelection(id: String) { + _selectedIds.update { + if (it.contains(id)) it - id else it + id + } + } + + @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, + selectedIds = selectedIds, + isLoading = false + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = DictRuleUiState(isLoading = true) + ) + + init { + viewModelScope.launch { + ruleUiFlow.collect { rules -> + _uiRules.value = rules + } + } + } + + fun setSearchKey(key: String?) { + _searchKey.value = key + } + + fun setSelection(ids: Set) { + _selectedIds.value = ids + } + + + fun enableSelectionByIds(ids: Set) { execute { - appDb.dictRuleDao.update(*dictRule) - }.onError { - val msg = "更新字典规则出错\n${it.localizedMessage}" - AppLog.put(msg, it) - context.toastOnUi(msg) + 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 { - appDb.dictRuleDao.delete(*dictRule) - }.onError { - val msg = "删除字典规则出错\n${it.localizedMessage}" - AppLog.put(msg, it) - context.toastOnUi(msg) + repository.delete(*dictRule) } } fun upSortNumber() { execute { - val rules = appDb.dictRuleDao.all + val rules = repository.getAll() for ((index, rule) in rules.withIndex()) { rule.sortNumber = index + 1 } - appDb.dictRuleDao.insert(*rules.toTypedArray()) - } - } - - fun enableSelection(vararg dictRule: DictRule) { - execute { - val array = dictRule.map { it.copy(enabled = true) }.toTypedArray() - appDb.dictRuleDao.insert(*array) - } - } - - fun disableSelection(vararg dictRule: DictRule) { - execute { - val array = dictRule.map { it.copy(enabled = false) }.toTypedArray() - appDb.dictRuleDao.insert(*array) + repository.insert(*rules.toTypedArray()) } } @@ -61,4 +188,22 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) { } } -} \ No newline at end of file + fun copyRule(dictRule: DictRule) { + context.sendToClip(GSON.toJson(dictRule)) + } + + fun pasteRule(): DictRule? { + val text = context.getClipText() + if (text.isNullOrBlank()) { + context.toastOnUi("剪贴板没有内容") + return null + } + return try { + GSON.fromJsonObject(text).getOrThrow() + } catch (e: Exception) { + context.toastOnUi("格式不对") + null + } + } + +} 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 new file mode 100644 index 000000000..e3dda3e2a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt @@ -0,0 +1,116 @@ +package io.legado.app.ui.replace + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +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.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.widget.components.modalBottomSheet.GlobalModalBottomSheet + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupManageBottomSheet( + groups: List, + sheetState: SheetState, + onDismissRequest: () -> Unit, + viewModel: ReplaceRuleViewModel +) { + var editingGroup by remember { mutableStateOf(null) } + var updatedGroupName by remember { mutableStateOf("") } + + GlobalModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = stringResource(R.string.group_manage), + style = MaterialTheme.typography.titleMedium + ) + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(groups) { group -> + Card(modifier = Modifier.fillMaxWidth()) { + if (editingGroup == group) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + OutlinedTextField( + value = updatedGroupName, + onValueChange = { updatedGroupName = it }, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { + viewModel.upGroup(group, updatedGroupName) + editingGroup = null + }) { + Icon( + Icons.Default.Check, + contentDescription = stringResource(id = R.string.ok) + ) + } + } + } else { + ListItem( + headlineContent = { Text(group) }, + trailingContent = { + Row { + IconButton(onClick = { + editingGroup = group + updatedGroupName = group + }) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(id = R.string.edit) + ) + } + IconButton(onClick = { viewModel.delGroup(group) }) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(id = R.string.delete) + ) + } + } + } + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleAdapter.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleAdapter.kt deleted file mode 100644 index 460951298..000000000 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleAdapter.kt +++ /dev/null @@ -1,258 +0,0 @@ -package io.legado.app.ui.replace - -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.ReplaceRule -import io.legado.app.databinding.ItemReplaceRuleBinding -//import io.legado.app.lib.theme.backgroundColor -import io.legado.app.ui.widget.recycler.DragSelectTouchHelper -import io.legado.app.ui.widget.recycler.ItemTouchCallback -import io.legado.app.utils.gone -import io.legado.app.utils.themeColor -import io.legado.app.utils.visible -import splitties.views.backgroundColor - - -class ReplaceRuleAdapter(context: Context, var callBack: CallBack) : - RecyclerAdapter(context), - ItemTouchCallback.Callback { - - private val selected = linkedSetOf() - - val selection: List - get() { - return getItems().filter { - selected.contains(it) - } - } - - val diffItemCallBack = object : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: ReplaceRule, newItem: ReplaceRule): Boolean { - if (oldItem.name != newItem.name) return false - if (oldItem.group != newItem.group) return false - if (oldItem.isEnabled != newItem.isEnabled) return false - if (oldItem.order != newItem.order) return false // 添加 order 检查 - return true - } - - override fun getChangePayload(oldItem: ReplaceRule, newItem: ReplaceRule): Any? { - val payload = Bundle() - if (oldItem.name != newItem.name || oldItem.group != newItem.group) { - payload.putBoolean("upName", true) - } - if (oldItem.isEnabled != newItem.isEnabled) { - payload.putBoolean("enabled", newItem.isEnabled) - } - if (oldItem.order != newItem.order) { - payload.putBoolean("orderChanged", true) - } - if (payload.isEmpty) { - return null - } - return payload - } - } - - 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 getViewBinding(parent: ViewGroup): ItemReplaceRuleBinding { - return ItemReplaceRuleBinding.inflate(inflater, parent, false) - } - - override fun onCurrentListChanged() { - callBack.upCountView() - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemReplaceRuleBinding, - item: ReplaceRule, - payloads: MutableList - ) { - binding.run { - var needUpdatePin = payloads.isEmpty() - - if (payloads.isNotEmpty()) { - for (i in payloads.indices) { - val bundle = payloads[i] as Bundle - bundle.keySet().forEach { - when (it) { - "selected" -> cbName.isChecked = selected.contains(item) - "upName" -> cbName.text = item.getDisplayNameGroup() - "enabled" -> swtEnabled.isChecked = item.isEnabled - "orderChanged" -> needUpdatePin = true - } - } - } - } - - if (payloads.isEmpty() || needUpdatePin) { - when (item.order) { - -1 -> { - ivPin.visible() - ivPin.setImageResource(R.drawable.ic_praise_filled) - ivPin.rotation = 0f - ivPin.setColorFilter(context.themeColor(com.google.android.material.R.attr.colorSecondary)) - } - -2 -> { - ivPin.visible() - ivPin.setImageResource(R.drawable.ic_praise_filled) - ivPin.rotation = 180f - ivPin.setColorFilter(context.themeColor(com.google.android.material.R.attr.colorSecondary)) - } - else -> ivPin.gone() - } - } - - if (payloads.isEmpty()) { - cbName.text = item.getDisplayNameGroup() - swtEnabled.isChecked = item.isEnabled - cbName.isChecked = selected.contains(item) - } - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemReplaceRuleBinding) { - binding.apply { - swtEnabled.setOnCheckedChangeListener { buttonView, isChecked -> - if (buttonView.isPressed) { - getItem(holder.layoutPosition)?.let { - it.isEnabled = isChecked - callBack.update(it) - } - } - } - ivEdit.setOnClickListener { - getItem(holder.layoutPosition)?.let { - callBack.edit(it) - } - } - cbName.setOnClickListener { - getItem(holder.layoutPosition)?.let { - if (cbName.isChecked) { - selected.add(it) - } else { - selected.remove(it) - } - } - callBack.upCountView() - } - ivMenuMore.setOnClickListener { - showMenu(ivMenuMore, holder.layoutPosition) - } - } - } - - private fun showMenu(view: View, position: Int) { - val item = getItem(position) ?: return - val popupMenu = PopupMenu(context, view) - popupMenu.inflate(R.menu.replace_rule_item) - popupMenu.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.menu_top -> callBack.toTop(item) - R.id.menu_bottom -> callBack.toBottom(item) - R.id.menu_del -> { - callBack.delete(item) - selected.remove(item) - } - } - true - } - popupMenu.show() - } - - override fun swap(srcPosition: Int, targetPosition: Int): Boolean { - val srcItem = getItem(srcPosition) - val targetItem = getItem(targetPosition) - if (srcItem != null && targetItem != null) { - if (srcItem.order == targetItem.order) { - callBack.upOrder() - } else { - val srcOrder = srcItem.order - srcItem.order = targetItem.order - targetItem.order = srcOrder - movedItems.add(srcItem) - movedItems.add(targetItem) - } - } - swapItem(srcPosition, targetPosition) - return true - } - - private val movedItems = linkedSetOf() - - 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): ReplaceRule { - 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 update(vararg rule: ReplaceRule) - fun delete(rule: ReplaceRule) - fun edit(rule: ReplaceRule) - fun toTop(rule: ReplaceRule) - fun toBottom(rule: ReplaceRule) - fun upOrder() - fun upCountView() - } -} 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 f9c251f5e..c264feb68 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,52 +3,33 @@ 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.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween 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.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource 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.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth 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.LazyColumn -import androidx.compose.foundation.lazy.LazyListState 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.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit 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.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -58,21 +39,16 @@ 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.ListItem -import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumFlexibleTopAppBar import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PlainTooltip import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetState import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -91,16 +67,12 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext @@ -118,7 +90,9 @@ import io.legado.app.data.repository.UploadRepository import io.legado.app.ui.replace.edit.ReplaceEditActivity 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.ReorderableSelectionItem import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SelectionBottomBar import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet @@ -127,12 +101,9 @@ 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.modalBottomSheet.GlobalModalBottomSheet -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject -import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, @@ -148,7 +119,6 @@ fun ReplaceRuleScreen( //TODO: 期望换为Navigation val context = LocalContext.current - val haptic = LocalHapticFeedback.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() val snackbarHostState = remember { SnackbarHostState() } @@ -585,71 +555,53 @@ fun ReplaceRuleScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(rules, key = { it.id }) { ui -> - val isSelected = selectedRuleIds.contains(ui.id) - ReorderableItem( + ReorderableSelectionItem( state = reorderableState, - key = ui.id - ) { isDragging -> - - val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) - ReplaceRuleItem( - modifier = Modifier - .padding(horizontal = 12.dp) - .zIndex(if (isDragging) 1f else 0f) - .shadow( - elevation = elevation, - shape = MaterialTheme.shapes.medium, - clip = false + 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 = { + context.startActivity( + ReplaceEditActivity.startIntent( + context, + ui.id ) - .then( - if (canReorder) { - Modifier.longPressDraggableHandle( - onDragStarted = { - hapticFeedback.performHapticFeedback( - HapticFeedbackType.GestureThresholdActivate - ) - }, - onDragStopped = { - hapticFeedback.performHapticFeedback( - HapticFeedbackType.GestureEnd - ) - }, - interactionSource = remember { MutableInteractionSource() } - ) - } else { - Modifier - } - ) - .animateItem(), - name = ui.name, - isEnabled = ui.isEnabled, - isSelected = isSelected, - inSelectionMode = inSelectionMode, - onEnabledChange = { enabled -> - viewModel.update(ui.rule.copy(isEnabled = enabled)) - }, - onDelete = { showDeleteRuleDialog = ui.rule }, - onToTop = { viewModel.toTop(ui.rule) }, - onToBottom = { viewModel.toBottom(ui.rule) }, - onToggleSelection = { - viewModel.toggleSelection(ui.id) - }, - onClickEdit = { - context.startActivity( - ReplaceEditActivity.startIntent(context, ui.id) - ) - } - ) - } + ) + }, + modifier = Modifier.padding(horizontal = 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, - rules = rules, - selectedRuleIds = selectedRuleIds, + items = rules, + selectedIds = selectedRuleIds, onSelectionChange = viewModel::setSelection, - haptic = haptic, + idProvider = { it.id }, modifier = Modifier .fillMaxHeight() .width(60.dp) @@ -719,303 +671,3 @@ fun ReplaceRuleScreen( } } } - -@Composable -fun DraggableSelectionHandler( - listState: LazyListState, - rules: List, - selectedRuleIds: Set, - onSelectionChange: (Set) -> Unit, - haptic: HapticFeedback, - modifier: Modifier = Modifier -) { - val latestSelectedRuleIds by rememberUpdatedState(selectedRuleIds) - var isAddingMode by remember { mutableStateOf(true) } - var lastProcessedIndex by remember { mutableIntStateOf(-1) } - - fun findRuleAtOffset(offsetY: Float): Pair? { - val itemInfo = listState.layoutInfo.visibleItemsInfo - .firstOrNull { item -> - offsetY >= item.offset && offsetY <= item.offset + item.size - } - - return itemInfo?.let { info -> - rules.getOrNull(info.index)?.let { rule -> - info.index to rule - } - } - } - - fun applySelection(id: Long, add: Boolean) { - val current = latestSelectedRuleIds - onSelectionChange( - if (add) current + id else current - id - ) - } - - Box( - modifier = modifier.pointerInput(Unit) { - coroutineScope { - launch { - detectTapGestures( - onTap = { offset -> - val result = findRuleAtOffset(offset.y) - if (result != null) { - val (_, rule) = result - val id = rule.id - val current = latestSelectedRuleIds - onSelectionChange( - if (current.contains(id)) current - id - else current + id - ) - haptic.performHapticFeedback( - HapticFeedbackType.TextHandleMove - ) - } - } - ) - } - - // 拖拽多选 - launch { - detectDragGestures( - onDragStart = { offset -> - val result = findRuleAtOffset(offset.y) - if (result != null) { - val (index, rule) = result - lastProcessedIndex = index - - val id = rule.id - val current = latestSelectedRuleIds - isAddingMode = !current.contains(id) - applySelection(id, isAddingMode) - - haptic.performHapticFeedback( - HapticFeedbackType.LongPress - ) - } - }, - onDrag = { change, _ -> - val result = findRuleAtOffset(change.position.y) - if (result != null) { - val (index, rule) = result - if (index != lastProcessedIndex) { - lastProcessedIndex = index - applySelection(rule.id, isAddingMode) - haptic.performHapticFeedback( - HapticFeedbackType.TextHandleMove - ) - } - } - }, - onDragEnd = { - lastProcessedIndex = -1 - }, - onDragCancel = { - lastProcessedIndex = -1 - } - ) - } - } - } - ) -} - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun GroupManageBottomSheet( - groups: List, - sheetState: SheetState, - onDismissRequest: () -> Unit, - viewModel: ReplaceRuleViewModel -) { - var editingGroup by remember { mutableStateOf(null) } - var updatedGroupName by remember { mutableStateOf("") } - - GlobalModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState - ) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - modifier = Modifier.padding(bottom = 16.dp), - text = stringResource(R.string.group_manage), - style = MaterialTheme.typography.titleMedium - ) - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(groups) { group -> - Card(modifier = Modifier.fillMaxWidth()) { - if (editingGroup == group) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - OutlinedTextField( - value = updatedGroupName, - onValueChange = { updatedGroupName = it }, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = { - viewModel.upGroup(group, updatedGroupName) - editingGroup = null - }) { - Icon( - Icons.Default.Check, - contentDescription = stringResource(id = R.string.ok) - ) - } - } - } else { - ListItem( - headlineContent = { Text(group) }, - trailingContent = { - Row { - IconButton(onClick = { - editingGroup = group - updatedGroupName = group - }) { - Icon( - Icons.Default.Edit, - contentDescription = stringResource(id = R.string.edit) - ) - } - IconButton(onClick = { viewModel.delGroup(group) }) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(id = R.string.delete) - ) - } - } - } - ) - } - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ReplaceRuleItem( - name: String, - isEnabled: Boolean, - isSelected: Boolean, - inSelectionMode: Boolean, - onToggleSelection: () -> Unit, - onEnabledChange: (Boolean) -> Unit, - onDelete: () -> Unit, - onToTop: () -> Unit, - onToBottom: () -> Unit, - onClickEdit: () -> Unit, - modifier: Modifier = Modifier -) { - var showRuleMenu by remember { mutableStateOf(false) } - - val containerColor by animateColorAsState( - targetValue = if (isSelected) - MaterialTheme.colorScheme.secondaryContainer - else - MaterialTheme.colorScheme.surfaceContainerLow, - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing - ), - label = "CardColor" - ) - - Card( - onClick = { onToggleSelection() }, - modifier = modifier - .fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - colors = CardDefaults.cardColors( - containerColor = containerColor - ) - ) { - ListItem( - modifier = Modifier - .animateContentSize(), - headlineContent = { - AnimatedContent(targetState = name, label = "RuleNameAnimation") { targetName -> - Text( - text = targetName, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - }, - leadingContent = { - AnimatedContent( - targetState = inSelectionMode, - label = "LeadingCheckbox" - ) { visible -> - if (visible) { - Checkbox( - checked = isSelected, - onCheckedChange = null - ) - } else { - Spacer(modifier = Modifier.width(0.dp)) - } - } - }, - trailingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Switch( - checked = isEnabled, - onCheckedChange = onEnabledChange - ) - IconButton(onClick = onClickEdit) { - Icon(Icons.Default.Edit, contentDescription = "Edit") - } - Box { - IconButton(onClick = { showRuleMenu = true }) { - Icon(Icons.Default.MoreVert, contentDescription = "More Actions") - } - DropdownMenu( - expanded = showRuleMenu, - onDismissRequest = { showRuleMenu = false } - ) { - DropdownMenuItem( - text = { Text("移至顶部") }, - onClick = { - onToTop() - showRuleMenu = false - } - ) - DropdownMenuItem( - text = { Text("移至底部") }, - onClick = { - onToBottom() - showRuleMenu = false - } - ) - DropdownMenuItem( - text = { Text("删除") }, - onClick = { - onDelete() - showRuleMenu = false - } - ) - } - } - } - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent - ) - ) - } -} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/DraggableSelectionHandler.kt b/app/src/main/java/io/legado/app/ui/widget/components/DraggableSelectionHandler.kt new file mode 100644 index 000000000..7b47bc16c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/DraggableSelectionHandler.kt @@ -0,0 +1,98 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +@Composable +fun DraggableSelectionHandler( + listState: LazyListState, + modifier: Modifier = Modifier, + items: List, + selectedIds: Set, + onSelectionChange: (Set) -> Unit, + idProvider: (T) -> ID, + haptic: HapticFeedback = LocalHapticFeedback.current, +) { + val latestSelectedIds by rememberUpdatedState(selectedIds) + var isAddingMode by remember { mutableStateOf(true) } + var lastProcessedIndex by remember { mutableIntStateOf(-1) } + + fun findItemAtOffset(offsetY: Float): Pair? { + val itemInfo = listState.layoutInfo.visibleItemsInfo + .firstOrNull { item -> + offsetY >= item.offset && offsetY <= item.offset + item.size + } + + return itemInfo?.let { info -> + items.getOrNull(info.index)?.let { item -> + info.index to item + } + } + } + + fun applySelection(id: ID, add: Boolean) { + val current = latestSelectedIds + onSelectionChange( + if (add) current + id else current - id + ) + } + + Box( + modifier = modifier.pointerInput(Unit) { + coroutineScope { + launch { + detectTapGestures( + onTap = { offset -> + findItemAtOffset(offset.y)?.let { (_, item) -> + val id = idProvider(item) + applySelection(id, !latestSelectedIds.contains(id)) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + ) + } + + launch { + detectDragGestures( + onDragStart = { offset -> + findItemAtOffset(offset.y)?.let { (index, item) -> + lastProcessedIndex = index + val id = idProvider(item) + isAddingMode = !latestSelectedIds.contains(id) + applySelection(id, isAddingMode) + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + }, + onDrag = { change, _ -> + findItemAtOffset(change.position.y)?.let { (index, item) -> + if (index != lastProcessedIndex) { + lastProcessedIndex = index + applySelection(idProvider(item), isAddingMode) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + }, + onDragEnd = { lastProcessedIndex = -1 }, + onDragCancel = { lastProcessedIndex = -1 } + ) + } + } + } + ) +} \ No newline at end of file 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/SelectionItemCard.kt new file mode 100644 index 000000000..84338c7c1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/SelectionItemCard.kt @@ -0,0 +1,208 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +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.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.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.Card +import androidx.compose.material3.CardDefaults +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 +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +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 sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.ReorderableLazyListState + +@Composable +fun SelectionItemCard( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + isEnabled: Boolean = true, + isSelected: Boolean = false, + inSelectionMode: Boolean = false, + onToggleSelection: () -> Unit = {}, + onEnabledChange: ((Boolean) -> Unit)? = null, + onClickEdit: (() -> Unit)? = null, + trailingAction: @Composable (RowScope.() -> Unit)? = null, + dropdownContent: @Composable (ColumnScope.(onDismiss: () -> Unit) -> Unit)? = null +) { + var showMenu by remember { mutableStateOf(false) } + + val containerColor by animateColorAsState( + targetValue = if (isSelected) + MaterialTheme.colorScheme.secondaryContainer + else + MaterialTheme.colorScheme.surfaceContainerLow, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "CardColor" + ) + + Card( + onClick = onToggleSelection, + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors(containerColor = containerColor) + ) { + ListItem( + modifier = Modifier.animateContentSize(), + headlineContent = { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = subtitle?.let { + { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + 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 + ) + } + + if (onClickEdit != null) { + IconButton(onClick = onClickEdit) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + } + + if (trailingAction != null) { + trailingAction() + } + + if (dropdownContent != null) { + Box { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More") + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + dropdownContent { showMenu = false } + } + } + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyItemScope.ReorderableSelectionItem( + state: ReorderableLazyListState, + key: Any, + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + isEnabled: Boolean = true, + isSelected: Boolean = false, + inSelectionMode: Boolean = false, + canReorder: Boolean = true, + onToggleSelection: () -> Unit = {}, + onEnabledChange: ((Boolean) -> Unit)? = null, + onClickEdit: (() -> Unit)? = null, + trailingAction: @Composable (RowScope.() -> Unit)? = null, + dropdownContent: @Composable (ColumnScope.(onDismiss: () -> Unit) -> Unit)? = null +) { + val hapticFeedback = LocalHapticFeedback.current + + ReorderableItem(state, key = key) { isDragging -> + val elevation by animateDpAsState( + targetValue = if (isDragging) 8.dp else 0.dp, + label = "DragElevation" + ) + + SelectionItemCard( + title = title, + subtitle = subtitle, + isEnabled = isEnabled, + isSelected = isSelected, + inSelectionMode = inSelectionMode, + onToggleSelection = onToggleSelection, + onEnabledChange = onEnabledChange, + onClickEdit = onClickEdit, + trailingAction = trailingAction, + dropdownContent = dropdownContent, + modifier = modifier + .zIndex(if (isDragging) 1f else 0f) + .shadow(elevation, MaterialTheme.shapes.medium) + .then( + if (canReorder && !inSelectionMode) { + Modifier.longPressDraggableHandle( + onDragStarted = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + }, + onDragStopped = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd) + } + ) + } else Modifier + ) + .animateItem() + ) + } +} \ No newline at end of file