[新增] 由 Compose 重写的字典规则功能

This commit is contained in:
HapeLee
2026-01-19 03:12:47 +08:00
parent f70efb584c
commit 22cfdc508c
13 changed files with 1258 additions and 1173 deletions
@@ -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<List<DictRule>>
@Query("select * from dictRules where name LIKE '%' || :key || '%' order by sortNumber")
fun flowSearch(key: String): Flow<List<DictRule>>
@Query("select * from dictRules where name = :name")
fun getByName(name: String): DictRule?
@Query("SELECT * FROM dictRules WHERE name IN (:names)")
fun getByNames(names: Set<String>): List<DictRule>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg dictRule: DictRule)
@@ -29,4 +39,10 @@ interface DictRuleDao {
@Delete
fun delete(vararg dictRule: DictRule)
}
@Query("UPDATE dictRules SET enabled = :enabled WHERE name IN (:names)")
suspend fun updateEnabled(names: Set<String>, enabled: Boolean)
@Query("DELETE FROM dictRules WHERE name IN (:names)")
suspend fun deleteByIds(names: Set<String>)
}
@@ -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<List<DictRule>> {
return dao.flowAll()
}
fun flowSearch(key: String): Flow<List<DictRule>> {
return dao.flowSearch(key)
}
fun getAll(): List<DictRule> {
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<String>) = 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<String>) = 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<String>) = withContext(Dispatchers.IO) {
if (names.isEmpty()) return@withContext
val rules = dao.getByNames(names)
dao.delete(*rules.toTypedArray())
}
suspend fun moveOrder(rules: List<DictRule>) = withContext(Dispatchers.IO) {
val updatedRules = rules.mapIndexed { index, rule ->
rule.copy(sortNumber = index + 1)
}
dao.update(*updatedRules.toTypedArray())
}
}
@@ -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<ActivityDictRuleBinding, DictRuleViewModel>(),
PopupMenu.OnMenuItemClickListener,
SelectActionBar.CallBack,
DictRuleAdapter.CallBack {
class DictRuleActivity : BaseComposeActivity() {
override val viewModel by viewModels<DictRuleViewModel>()
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<ActivityDictRuleBinding, DictRuleViewMod
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initRecyclerView()
initSelectActionView()
observeDictRuleData()
//observeDictRuleData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.dict_rule, menu)
return super.onCompatCreateOptionsMenu(menu)
}
private fun initRecyclerView() {
//binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
binding.recyclerView.addItemDecoration(VerticalDivider(this))
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
val dragSelectTouchHelper: DragSelectTouchHelper =
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
// When this page is opened, it is in selection mode
dragSelectTouchHelper.activeSlideSelect()
// Note: need judge selection first, so add ItemTouchHelper after it.
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
}
private fun initSelectActionView() {
binding.selectActionBar.setMainActionText(R.string.delete)
binding.selectActionBar.inflateMenu(R.menu.dict_rule_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
}
private fun observeDictRuleData() {
lifecycleScope.launch {
appDb.dictRuleDao.flowAll().catch {
AppLog.put("字典规则获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it, adapter.diffItemCallBack)
}
@Composable
override fun Content() {
AppTheme {
DictRuleScreen(onBackClick = { finish() })
}
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_add -> showDialogFragment<DictRuleEditDialog>()
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)
@@ -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<DictRule, ItemDictRuleBinding>(context),
ItemTouchCallback.Callback {
private val selected = linkedSetOf<DictRule>()
val selection: List<DictRule>
get() {
return getItems().filter {
selected.contains(it)
}
}
val diffItemCallBack = object : DiffUtil.ItemCallback<DictRule>() {
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<Any>
) {
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<DictRule>()
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (movedItems.isNotEmpty()) {
callBack.update(*movedItems.toTypedArray())
movedItems.clear()
}
}
val dragSelectCallback: DragSelectTouchHelper.Callback =
object : DragSelectTouchHelper.AdvanceCallback<DictRule>(Mode.ToggleAndReverse) {
override fun currentSelectedId(): MutableSet<DictRule> {
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()
}
}
@@ -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<DictRuleEditViewModel>()
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<DictRule>(text).getOrThrow()
}.onSuccess {
success.invoke(it)
}.onError {
context.toastOnUi("格式不对")
}
}
}
}
@@ -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")
}
}
}
}
@@ -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<DictRule?>(null) }
var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(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())
}
)
)
)
}
}
}
}
@@ -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<DictRuleItemUi> = emptyList(),
val dictRule: List<DictRule> = emptyList(),
val selectedIds: Set<String> = emptySet(),
val isLoading: Boolean = false
)
class DictRuleViewModel(application: Application) : BaseViewModel(application) {
private val repository = DictRuleRepository()
private val _searchKey = MutableStateFlow<String?>(null)
private val _uiRules = MutableStateFlow<List<DictRuleItemUi>>(emptyList())
fun update(vararg dictRule: DictRule) {
private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
val selectedIds: StateFlow<Set<String>> = _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<List<DictRuleItemUi>> =
rulesFlow.map { rules ->
rules.map { rule ->
DictRuleItemUi(
name = rule.name,
urlRule = rule.urlRule,
showRule = rule.showRule,
isEnabled = rule.enabled,
rule = rule
)
}
}
val uiState: StateFlow<DictRuleUiState> = combine(
_searchKey,
_uiRules,
_selectedIds
) { searchKey, rules, selectedIds ->
DictRuleUiState(
searchKey = searchKey,
items = rules,
selectedIds = selectedIds,
isLoading = false
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DictRuleUiState(isLoading = true)
)
init {
viewModelScope.launch {
ruleUiFlow.collect { rules ->
_uiRules.value = rules
}
}
}
fun setSearchKey(key: String?) {
_searchKey.value = key
}
fun setSelection(ids: Set<String>) {
_selectedIds.value = ids
}
fun enableSelectionByIds(ids: Set<String>) {
execute {
appDb.dictRuleDao.update(*dictRule)
}.onError {
val msg = "更新字典规则出错\n${it.localizedMessage}"
AppLog.put(msg, it)
context.toastOnUi(msg)
repository.enableByIds(ids)
}
}
fun disableSelectionByIds(ids: Set<String>) {
execute {
repository.disableByIds(ids)
}
}
fun delSelectionByIds(ids: Set<String>) {
execute {
repository.deleteByIds(ids)
_selectedIds.update { it - ids }
}
}
fun update(vararg rule: DictRule) {
execute {
repository.update(*rule)
}
}
fun insert(vararg rule: DictRule) {
execute {
repository.insert(*rule)
}
}
fun moveItemInList(fromIndex: Int, toIndex: Int) {
_uiRules.update { currentList ->
val list = currentList.toMutableList()
val item = list.removeAt(fromIndex)
list.add(toIndex, item)
list
}
}
fun saveSortOrder() {
val currentRules = _uiRules.value
execute {
repository.moveOrder(currentRules.map { it.rule })
}
}
fun delete(vararg dictRule: DictRule) {
execute {
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) {
}
}
}
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<DictRule>(text).getOrThrow()
} catch (e: Exception) {
context.toastOnUi("格式不对")
null
}
}
}
@@ -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<String>,
sheetState: SheetState,
onDismissRequest: () -> Unit,
viewModel: ReplaceRuleViewModel
) {
var editingGroup by remember { mutableStateOf<String?>(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)
)
}
}
}
)
}
}
}
}
}
}
}
@@ -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<ReplaceRule, ItemReplaceRuleBinding>(context),
ItemTouchCallback.Callback {
private val selected = linkedSetOf<ReplaceRule>()
val selection: List<ReplaceRule>
get() {
return getItems().filter {
selected.contains(it)
}
}
val diffItemCallBack = object : DiffUtil.ItemCallback<ReplaceRule>() {
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<Any>
) {
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<ReplaceRule>()
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (movedItems.isNotEmpty()) {
callBack.update(*movedItems.toTypedArray())
movedItems.clear()
}
}
val dragSelectCallback: DragSelectTouchHelper.Callback =
object : DragSelectTouchHelper.AdvanceCallback<ReplaceRule>(Mode.ToggleAndReverse) {
override fun currentSelectedId(): MutableSet<ReplaceRule> {
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()
}
}
@@ -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<ReplaceRuleItemUi>,
selectedRuleIds: Set<Long>,
onSelectionChange: (Set<Long>) -> 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<Int, ReplaceRuleItemUi>? {
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<String>,
sheetState: SheetState,
onDismissRequest: () -> Unit,
viewModel: ReplaceRuleViewModel
) {
var editingGroup by remember { mutableStateOf<String?>(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
)
)
}
}
@@ -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 <T, ID> DraggableSelectionHandler(
listState: LazyListState,
modifier: Modifier = Modifier,
items: List<T>,
selectedIds: Set<ID>,
onSelectionChange: (Set<ID>) -> 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<Int, T>? {
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 }
)
}
}
}
)
}
@@ -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()
)
}
}