[新增] 用 Compose 重写订阅相关的大部分界面,现在订阅可长按登录

This commit is contained in:
HapeLee
2026-03-10 00:12:27 +08:00
parent 64efff6f41
commit 12a46e8cae
21 changed files with 2154 additions and 1556 deletions
@@ -0,0 +1,31 @@
package io.legado.app.data.repository
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource
import io.legado.app.help.source.SourceHelp
import kotlinx.coroutines.flow.Flow
class RssRepository {
private val dao = appDb.rssSourceDao
fun getEnabledSources(): Flow<List<RssSource>> = dao.flowEnabled()
fun getEnabledSources(searchKey: String): Flow<List<RssSource>> = dao.flowEnabled(searchKey)
fun getEnabledSourcesByGroup(group: String): Flow<List<RssSource>> =
dao.flowEnabledByGroup(group)
fun getEnabledGroups(): Flow<List<String>> = dao.flowEnabledGroups()
suspend fun updateSources(vararg sources: RssSource) {
dao.update(*sources)
}
suspend fun deleteSources(sources: List<RssSource>) {
SourceHelp.deleteRssSources(sources)
}
fun getMinOrder(): Int = dao.minOrder
fun getMaxOrder(): Int = dao.maxOrder
}
@@ -1,86 +0,0 @@
package io.legado.app.ui.main.rss
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import com.bumptech.glide.request.RequestOptions
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.RssSource
import io.legado.app.databinding.ItemRssBinding
import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.glide.OkHttpModelLoader
import splitties.views.onLongClick
class RssAdapter(
context: Context,
private val fragment: Fragment,
private val callBack: CallBack,
private val lifecycle: Lifecycle
) : RecyclerAdapter<RssSource, ItemRssBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemRssBinding {
return ItemRssBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemRssBinding,
item: RssSource,
payloads: MutableList<Any>
) {
binding.apply {
tvName.text = item.sourceName
val options = RequestOptions()
.set(OkHttpModelLoader.sourceOriginOption, item.sourceUrl)
ImageLoader.load(fragment, lifecycle, item.sourceIcon)
.apply(options)
.centerCrop()
.placeholder(R.drawable.image_rss)
.error(R.drawable.image_rss)
.into(ivIcon)
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssBinding) {
binding.apply {
root.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let {
callBack.openRss(it)
}
}
root.onLongClick {
getItemByLayoutPosition(holder.layoutPosition)?.let {
showMenu(ivIcon, it)
}
}
}
}
private fun showMenu(view: View, rssSource: RssSource) {
val popupMenu = PopupMenu(context, view)
popupMenu.inflate(R.menu.rss_main_item)
popupMenu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_top -> callBack.toTop(rssSource)
R.id.menu_edit -> callBack.edit(rssSource)
R.id.menu_del -> callBack.del(rssSource)
R.id.menu_disable -> callBack.disable(rssSource)
}
true
}
popupMenu.show()
}
interface CallBack {
fun openRss(rssSource: RssSource)
fun toTop(rssSource: RssSource)
fun edit(rssSource: RssSource)
fun del(rssSource: RssSource)
fun disable(rssSource: RssSource)
}
}
@@ -1,26 +1,16 @@
package io.legado.app.ui.main.rss
import android.os.Build
import android.os.Bundle
import android.transition.TransitionManager
import android.view.Menu
import android.view.MenuItem
import android.view.SubMenu
import android.view.LayoutInflater
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import io.legado.app.R
import io.legado.app.base.VMBaseFragment
import io.legado.app.constant.AppLog
import io.legado.app.data.AppDatabase
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource
import io.legado.app.databinding.FragmentRssBinding
import io.legado.app.databinding.ItemRssBinding
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.main.MainFragmentInterface
import io.legado.app.ui.rss.article.RssSortActivity
import io.legado.app.ui.rss.favorites.RssFavoritesActivity
@@ -28,25 +18,15 @@ import io.legado.app.ui.rss.read.ReadRssActivity
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.rss.source.manage.RssSourceActivity
import io.legado.app.ui.rss.subscription.RuleSubActivity
import io.legado.app.utils.flowWithLifecycleAndDatabaseChange
import io.legado.app.ui.theme.AppTheme
import io.legado.app.utils.openUrl
import io.legado.app.utils.startActivity
import io.legado.app.utils.transaction
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* 订阅界面
*/
class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
MainFragmentInterface,
RssAdapter.CallBack {
MainFragmentInterface {
constructor(position: Int) : this() {
val bundle = Bundle()
@@ -56,140 +36,36 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
override val position: Int? get() = arguments?.getInt("position")
private val binding by viewBinding(FragmentRssBinding::bind)
override val viewModel by viewModels<RssViewModel>()
private val adapter by lazy {
RssAdapter(requireContext(), this, this, viewLifecycleOwner.lifecycle)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
AppTheme {
RssScreen(
viewModel = viewModel,
onOpenRss = { openRss(it) },
onEdit = { edit(it) },
onOpenStar = { startActivity<RssFavoritesActivity>() },
onOpenConfig = { startActivity<RssSourceActivity>() },
onOpenRuleSub = { startActivity<RuleSubActivity>() },
onDelete = { del(it) },
onLogin = { login(it) }
)
}
}
}
}
private val searchView: SearchView by lazy { binding.searchBar }
private var groupsFlowJob: Job? = null
private var rssFlowJob: Job? = null
private val groups = linkedSetOf<String>()
private var groupsMenu: SubMenu? = null
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
setSupportToolbar(binding.topBar)
initSearchView()
initRecyclerView()
initGroupData()
upRssFlowJob()
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
binding.appBar.fitsSystemWindows = true
// Compose handles UI
}
override fun onCompatCreateOptionsMenu(menu: Menu) {
menuInflater.inflate(R.menu.main_rss, menu)
groupsMenu = menu.findItem(R.id.menu_group)?.subMenu
upGroupsMenu()
}
override fun onCompatOptionsItemSelected(item: MenuItem) {
super.onCompatOptionsItemSelected(item)
when (item.itemId) {
R.id.menu_rss_config -> startActivity<RssSourceActivity>()
R.id.menu_rss_star -> startActivity<RssFavoritesActivity>()
R.id.menu_rss_search -> {
TransitionManager.beginDelayedTransition(binding.rootView)
binding.searchBar.visibility =
if (binding.searchBar.isVisible) View.GONE else View.VISIBLE
}
else -> if (item.groupId == R.id.menu_group_text) {
if (item.title == getString(R.string.all)) {
upRssFlowJob()
searchView.setQuery("", false)
} else {
searchView.setQuery(item.title, false)
upRssFlowJob("group:${item.title}")
}
}
}
}
override fun onPause() {
super.onPause()
searchView.clearFocus()
}
private fun upGroupsMenu() = groupsMenu?.transaction { subMenu ->
subMenu.removeGroup(R.id.menu_group_text)
subMenu.add(R.id.menu_group_text, Menu.NONE, Menu.NONE, getString(R.string.all))
groups.forEach {
subMenu.add(R.id.menu_group_text, Menu.NONE, Menu.NONE, it)
}
}
private fun initSearchView() {
searchView.queryHint = getString(R.string.search_rss_source)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
upRssFlowJob(query)
searchView.clearFocus()
return true
}
override fun onQueryTextChange(newText: String): Boolean {
upRssFlowJob(newText)
return true
}
})
}
private fun initRecyclerView() {
//binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.adapter = adapter
adapter.addHeaderView {
ItemRssBinding.inflate(layoutInflater, it, false).apply {
tvName.setText(R.string.rule_subscription)
ivIcon.setImageResource(R.drawable.image_legado)
root.setOnClickListener {
startActivity<RuleSubActivity>()
}
}
}
}
private fun initGroupData() {
groupsFlowJob?.cancel()
groupsFlowJob = viewLifecycleOwner.lifecycleScope.launch {
appDb.rssSourceDao.flowEnabledGroups().catch {
AppLog.put("订阅界面获取分组数据失败\n${it.localizedMessage}", it)
}.flowWithLifecycleAndDatabaseChange(
viewLifecycleOwner.lifecycle,
Lifecycle.State.RESUMED,
AppDatabase.RSS_SOURCE_TABLE_NAME
).conflate().collect {
groups.clear()
groups.addAll(it)
upGroupsMenu()
}
}
}
private fun upRssFlowJob(searchKey: String? = null) {
rssFlowJob?.cancel()
rssFlowJob = viewLifecycleOwner.lifecycleScope.launch {
when {
searchKey.isNullOrEmpty() -> appDb.rssSourceDao.flowEnabled()
searchKey.startsWith("group:") -> {
val key = searchKey.substringAfter("group:")
appDb.rssSourceDao.flowEnabledByGroup(key)
}
else -> appDb.rssSourceDao.flowEnabled(searchKey)
}.flowWithLifecycleAndDatabaseChange(
viewLifecycleOwner.lifecycle,
Lifecycle.State.RESUMED,
AppDatabase.RSS_SOURCE_TABLE_NAME
).catch {
AppLog.put("订阅界面更新数据出错", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
override fun openRss(rssSource: RssSource) {
private fun openRss(rssSource: RssSource) {
if (rssSource.singleUrl) {
viewModel.getSingleUrl(rssSource) { url ->
if (url.startsWith("http", true)) {
@@ -208,17 +84,20 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
}
}
override fun toTop(rssSource: RssSource) {
viewModel.topSource(rssSource)
}
override fun edit(rssSource: RssSource) {
private fun edit(rssSource: RssSource) {
startActivity<RssSourceEditActivity> {
putExtra("sourceUrl", rssSource.sourceUrl)
}
}
override fun del(rssSource: RssSource) {
private fun login(rssSource: RssSource) {
startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", rssSource.sourceUrl)
}
}
private fun del(rssSource: RssSource) {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n" + rssSource.sourceName)
noButton()
@@ -227,8 +106,4 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
}
}
}
override fun disable(rssSource: RssSource) {
viewModel.disable(rssSource)
}
}
}
@@ -0,0 +1,245 @@
package io.legado.app.ui.main.rss
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.automirrored.outlined.Label
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.Group
import androidx.compose.material.icons.filled.VerticalAlignTop
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.outlined.Subscriptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.RssSource
import io.legado.app.ui.widget.components.SourceIcon
import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.divider.PillDivider
import io.legado.app.ui.widget.components.divider.PillHeaderDivider
import io.legado.app.ui.widget.components.list.ListScaffold
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RssScreen(
viewModel: RssViewModel,
onOpenRss: (RssSource) -> Unit,
onEdit: (RssSource) -> Unit,
onOpenStar: () -> Unit,
onOpenConfig: () -> Unit,
onOpenRuleSub: () -> Unit,
onDelete: (RssSource) -> Unit,
onLogin: (RssSource) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
ListScaffold(
title = stringResource(R.string.rss),
state = uiState,
subtitle = uiState.group.ifEmpty { "全部" },
onBackClick = null,
onSearchToggle = { viewModel.toggleSearchVisible(it) },
onSearchQueryChange = { viewModel.search(it) },
searchPlaceholder = stringResource(R.string.search_rss_source),
topBarActions = {
TopBarActionButton(
onClick = onOpenRuleSub,
imageVector = Icons.Outlined.Subscriptions,
contentDescription = stringResource(R.string.rule_subscription)
)
TopBarActionButton(
onClick = onOpenStar,
imageVector = Icons.Outlined.Star,
contentDescription = stringResource(R.string.favorite)
)
},
dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
onClick = onOpenConfig,
leadingIcon = { MenuItemIcon(Icons.Outlined.Settings) },
text = { Text("订阅源管理") }
)
PillDivider()
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Group) },
text = { Text(stringResource(R.string.all)) },
onClick = {
viewModel.setGroup("")
dismiss()
}
)
uiState.groups.forEach { group ->
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Outlined.Label) },
text = { Text(group) },
onClick = {
viewModel.setGroup(group)
dismiss()
}
)
}
}
) { paddingValues ->
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 72.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = paddingValues.calculateTopPadding() + 8.dp,
bottom = paddingValues.calculateBottomPadding() + 12.dp
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(uiState.items, key = { it.sourceUrl }) { source ->
RssSourceGridItem(
modifier = Modifier.animateItem(),
source = source,
onClick = { onOpenRss(source) },
onTop = { viewModel.topSource(source) },
onEdit = { onEdit(source) },
onDelete = { onDelete(source) },
onDisable = { viewModel.disable(source) },
onLogin = { onLogin(source) }
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RssSourceGridItem(
modifier: Modifier = Modifier,
source: RssSource,
onClick: () -> Unit,
onTop: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onDisable: () -> Unit,
onLogin: () -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
Column(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.combinedClickable(
onClick = onClick,
onLongClick = { showMenu = true }
)
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box {
SourceIcon(
path = source.sourceIcon.ifEmpty { R.drawable.image_rss },
sourceOrigin = source.sourceUrl,
modifier = Modifier.size(40.dp)
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
PillHeaderDivider(title = source.sourceName)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.VerticalAlignTop) },
text = { Text(stringResource(R.string.to_top)) },
onClick = {
onTop()
showMenu = false
}
)
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
text = { Text(stringResource(R.string.edit)) },
onClick = {
onEdit()
showMenu = false
}
)
if (!source.loginUrl.isNullOrBlank()) {
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
text = { Text(stringResource(R.string.login)) },
onClick = {
onLogin()
showMenu = false
}
)
}
RoundDropdownMenuItem(
leadingIcon = { MenuItemIcon(Icons.Default.Close) },
text = { Text(stringResource(R.string.disable_source)) },
onClick = {
onDisable()
showMenu = false
}
)
RoundDropdownMenuItem(
leadingIcon = {
MenuItemIcon(
Icons.Default.Delete,
tint = MaterialTheme.colorScheme.error
)
},
text = {
Text(
stringResource(R.string.delete),
color = MaterialTheme.colorScheme.error
)
},
onClick = {
onDelete()
showMenu = false
}
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = source.sourceName,
style = MaterialTheme.typography.labelMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
@@ -0,0 +1,14 @@
package io.legado.app.ui.main.rss
import io.legado.app.data.entities.RssSource
import io.legado.app.ui.widget.components.rules.ListUiState
data class RssUiState(
override val items: List<RssSource> = emptyList(),
override val selectedIds: Set<String> = emptySet(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false,
val groups: List<String> = emptyList(),
val group: String = ""
) : ListUiState<RssSource>
@@ -1,15 +1,83 @@
package io.legado.app.ui.main.rss
import android.app.Application
import androidx.lifecycle.viewModelScope
import com.script.rhino.runScriptWithContext
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource
import io.legado.app.help.source.SourceHelp
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class RssViewModel(application: Application) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(RssUiState())
val uiState = _uiState.asStateFlow()
init {
initGroupData()
initRssData()
}
private fun initGroupData() {
viewModelScope.launch {
appDb.rssSourceDao.flowEnabledGroups()
.flowOn(IO)
.collect { groups ->
_uiState.update { state -> state.copy(groups = groups) }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun initRssData() {
combine(
_uiState.map { it.searchKey }.distinctUntilChanged(),
_uiState.map { it.group }.distinctUntilChanged()
) { searchKey, group ->
searchKey to group
}
.flatMapLatest { (searchKey, group) ->
when {
searchKey.isNotEmpty() -> appDb.rssSourceDao.flowEnabled(searchKey)
group.isNotEmpty() -> appDb.rssSourceDao.flowEnabledByGroup(group)
else -> appDb.rssSourceDao.flowEnabled()
}
}
.flowOn(IO)
.onEach { sources ->
_uiState.update { state -> state.copy(items = sources) }
}
.launchIn(viewModelScope)
}
fun search(key: String) {
_uiState.update { it.copy(searchKey = key, isSearch = key.isNotEmpty()) }
}
fun setGroup(group: String) {
_uiState.update { it.copy(group = group, searchKey = "", isSearch = false) }
}
fun toggleSearchVisible(visible: Boolean) {
_uiState.update {
it.copy(isSearch = visible, searchKey = if (visible) it.searchKey else "")
}
}
fun topSource(vararg sources: RssSource) {
execute {
sources.sortBy { it.customOrder }
@@ -78,6 +146,4 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
context.toastOnUi(it.localizedMessage)
}
}
}
}
@@ -1,113 +0,0 @@
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.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.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.card.GlassCard
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupManageBottomSheet(
groups: List<String>,
onDismissRequest: () -> Unit,
viewModel: ReplaceRuleViewModel
) {
var editingGroup by remember { mutableStateOf<String?>(null) }
var updatedGroupName by remember { mutableStateOf("") }
GlassModalBottomSheet(
onDismissRequest = onDismissRequest
) {
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 ->
GlassCard(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,177 +1,22 @@
@file:Suppress("DEPRECATION")
package io.legado.app.ui.rss.favorites
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.SubMenu
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentStatePagerAdapter
import androidx.lifecycle.lifecycleScope
import androidx.viewpager.widget.ViewPager
import io.legado.app.R
import io.legado.app.base.BaseActivity
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import io.legado.app.databinding.ActivityRssFavoritesBinding
import io.legado.app.lib.dialogs.alert
//import io.legado.app.lib.theme.accentColor
import io.legado.app.utils.gone
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import androidx.compose.runtime.Composable
import io.legado.app.base.BaseComposeActivity
/**
* 收藏夹
*/
class RssFavoritesActivity : BaseActivity<ActivityRssFavoritesBinding>() {
override val binding by viewBinding(ActivityRssFavoritesBinding::inflate)
private val adapter by lazy { TabFragmentPageAdapter() }
private var groupList = mutableListOf<String>()
private var groupsMenu: SubMenu? = null
private var currentGroup = ""
class RssFavoritesActivity : BaseComposeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initView()
upFragments()
}
override fun onResume() {
super.onResume()
//从ReadRssActivity退出时,判断是否需要重新定位tabLayout选中项
if (currentGroup.isNotEmpty() && groupList.isNotEmpty()){
var item = groupList.indexOf(currentGroup)
val currentItem = binding.viewPager.currentItem
//如果坐标没有变化,则结束
if(item == currentItem){
return
}
if (item == -1){
item = currentItem
}
lifecycleScope.launch {
delay(100)
binding.tabLayout.getTabAt(item)?.select()
}
}
}
private fun initView() {
binding.viewPager.adapter = adapter
binding.viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
}
override fun onPageSelected(position: Int) {
currentGroup = groupList[position]
}
override fun onPageScrollStateChanged(state: Int) {}
})
binding.tabLayout.setupWithViewPager(binding.viewPager)
//binding.tabLayout.setSelectedTabIndicatorColor(accentColor)
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.rss_favorites, menu)
groupsMenu = menu.findItem(R.id.menu_group)?.subMenu
upGroupsMenu()
return super.onCompatCreateOptionsMenu(menu)
}
private fun upGroupsMenu() = groupsMenu?.let { subMenu ->
subMenu.removeGroup(R.id.menu_group)
groupList.forEachIndexed { index, it ->
subMenu.add(R.id.menu_group, Menu.NONE, index, it)
}
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
if (item.groupId == R.id.menu_group) {
binding.viewPager.setCurrentItem(item.order)
} else {
when (item.itemId) {
R.id.menu_del_group -> deleteGroup()
R.id.menu_del_all -> deleteAll()
}
}
return super.onCompatOptionsItemSelected(item)
}
private fun upFragments() {
lifecycleScope.launch {
appDb.rssStarDao.flowGroups().catch {
AppLog.put("订阅分组数据获取失败\n${it.localizedMessage}", it)
}.distinctUntilChanged().flowOn(IO).collect {
groupList.clear()
groupList.addAll(it)
if (groupList.size == 1) {
binding.tabLayout.gone()
} else {
binding.tabLayout.visible()
}
if (groupsMenu != null) {
upGroupsMenu()
}
adapter.notifyDataSetChanged()
}
}
}
private fun deleteGroup() {
alert(R.string.draw) {
val item = binding.viewPager.currentItem
val group = groupList[item]
setMessage(getString(R.string.sure_del) + "\n<" + group + ">" + getString(R.string.group))
noButton()
yesButton {
appDb.rssStarDao.deleteByGroup(group)
}
}
}
private fun deleteAll() {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n<" + getString(R.string.all) + ">" + getString(R.string.favorite))
noButton()
yesButton {
appDb.rssStarDao.deleteAll()
}
}
}
private inner class TabFragmentPageAdapter :
FragmentStatePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
override fun getItemPosition(`object`: Any): Int {
return POSITION_NONE
}
override fun getPageTitle(position: Int): CharSequence {
return groupList[position]
}
override fun getItem(position: Int): Fragment {
val group = groupList[position]
return RssFavoritesFragment(group)
}
override fun getCount(): Int {
return groupList.size
}
@Composable
override fun Content() {
RssFavoritesScreen(
onBackClick = { finish() }
)
}
}
@@ -0,0 +1,226 @@
package io.legado.app.ui.rss.favorites
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Group
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.legado.app.R
import io.legado.app.ui.rss.read.ReadRssActivity
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.divider.PillDivider
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.rules.RuleListScaffold
import io.legado.app.utils.startActivity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RssFavoritesScreen(
onBackClick: () -> Unit,
viewModel: RssFavoritesViewModel = viewModel()
) {
val context = LocalContext.current
val state by viewModel.state.collectAsState()
val groups by viewModel.groups.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(groups) {
if (state.currentGroup.isEmpty() && groups.isNotEmpty()) {
viewModel.onGroupChange(groups.first())
}
}
RuleListScaffold(
title = stringResource(R.string.favorite),
state = state,
onBackClick = onBackClick,
onSearchToggle = viewModel::onSearchToggle,
onSearchQueryChange = viewModel::onSearchQueryChange,
onClearSelection = viewModel::clearSelection,
onSelectAll = viewModel::selectAll,
onSelectInvert = viewModel::selectInvert,
onDeleteSelected = { viewModel.deleteSelected() },
snackbarHostState = snackbarHostState,
selectionSecondaryActions = emptyList(),
topBarActions = {},
dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Group, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
Text(stringResource(R.string.all))
}
},
onClick = {
viewModel.onGroupChange("")
dismiss()
}
)
groups.forEach { group ->
RoundDropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.AutoMirrored.Outlined.Label,
null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(12.dp))
Text(group)
}
},
onClick = {
viewModel.onGroupChange(group)
dismiss()
}
)
}
PillDivider()
RoundDropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.DeleteSweep, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
Text(stringResource(R.string.delete_select_group))
}
},
onClick = {
viewModel.deleteGroup(state.currentGroup)
dismiss()
}
)
RoundDropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.DeleteForever, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
Text(stringResource(R.string.delete_all))
}
},
onClick = {
viewModel.deleteAll()
dismiss()
}
)
},
bottomContent = {
if (groups.size > 1) {
PrimaryScrollableTabRow(
selectedTabIndex = groups.indexOf(state.currentGroup).coerceAtLeast(0),
edgePadding = 16.dp,
divider = {}
) {
groups.forEach { group ->
Tab(
selected = state.currentGroup == group,
onClick = { viewModel.onGroupChange(group) },
text = { Text(group) }
)
}
}
}
}
) { paddingValues ->
if (state.items.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
message = "还没有收藏订阅!",
isLoading = state.isLoading
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.items, key = { "${it.origin}|${it.link}" }) { rssStar ->
val id = "${rssStar.origin}|${rssStar.link}"
val isSelected = state.selectedIds.contains(id)
SelectionItemCard(
title = rssStar.title,
subtitle = rssStar.pubDate,
isSelected = isSelected,
inSelectionMode = state.selectedIds.isNotEmpty(),
onToggleSelection = {
if (state.selectedIds.isNotEmpty()) {
viewModel.toggleSelection(rssStar)
} else {
context.startActivity<ReadRssActivity> {
putExtra("title", rssStar.title)
putExtra("origin", rssStar.origin)
putExtra("link", rssStar.link)
}
}
},
trailingAction = {
if (!rssStar.image.isNullOrBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(rssStar.image)
.setHeader("sourceOrigin", rssStar.origin)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier
.size(width = 80.dp, height = 50.dp)
.padding(start = 8.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
}
},
dropdownContent = { dismiss ->
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.delete)) },
onClick = {
viewModel.deleteStar(rssStar)
dismiss()
}
)
}
)
}
}
}
}
}
@@ -1,9 +1,137 @@
package io.legado.app.ui.rss.favorites
import android.app.Application
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssStar
import io.legado.app.ui.widget.components.rules.ListUiState
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class RssFavoritesViewModel(application: Application) : BaseViewModel(application) {
private val _searchKey = MutableStateFlow("")
private val _isSearch = MutableStateFlow(false)
private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
private val _currentGroup = MutableStateFlow("")
val groups = appDb.rssStarDao.flowGroups()
.flowOn(IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<RssFavoritesUiState> = combine(
_currentGroup,
_searchKey,
_isSearch,
_selectedIds,
_currentGroup.flatMapLatest { group ->
if (group.isEmpty()) {
// If group is empty, we might want to wait for the first group from 'groups' flow
// Or just show nothing/default. The Activity/Screen should set the first group.
appDb.rssStarDao.liveAll()
} else {
appDb.rssStarDao.flowByGroup(group)
}
}
) { group, searchKey, isSearch, selectedIds, items ->
val filteredItems = if (isSearch && searchKey.isNotBlank()) {
items.filter { it.title.contains(searchKey, ignoreCase = true) }
} else {
items
}
RssFavoritesUiState(
items = filteredItems,
selectedIds = selectedIds,
searchKey = searchKey,
isSearch = isSearch,
currentGroup = group
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RssFavoritesUiState())
fun onSearchToggle(isSearch: Boolean) {
_isSearch.value = isSearch
if (!isSearch) _searchKey.value = ""
}
fun onSearchQueryChange(query: String) {
_searchKey.value = query
}
fun onGroupChange(group: String) {
_currentGroup.value = group
_selectedIds.value = emptySet()
}
fun toggleSelection(rssStar: RssStar) {
val id = "${rssStar.origin}|${rssStar.link}"
_selectedIds.update {
if (it.contains(id)) it - id else it + id
}
}
fun selectAll() {
_selectedIds.value = state.value.items.map { "${it.origin}|${it.link}" }.toSet()
}
fun selectInvert() {
val allIds = state.value.items.map { "${it.origin}|${it.link}" }.toSet()
_selectedIds.update { current ->
allIds - current
}
}
fun clearSelection() {
_selectedIds.value = emptySet()
}
fun deleteSelected() {
val selected = _selectedIds.value
viewModelScope.launch(IO) {
state.value.items.forEach {
val id = "${it.origin}|${it.link}"
if (selected.contains(id)) {
appDb.rssStarDao.delete(it.origin, it.link)
}
}
clearSelection()
}
}
fun deleteGroup(group: String) {
viewModelScope.launch(IO) {
appDb.rssStarDao.deleteByGroup(group)
}
}
fun deleteAll() {
viewModelScope.launch(IO) {
appDb.rssStarDao.deleteAll()
}
}
fun deleteStar(rssStar: RssStar) {
viewModelScope.launch(IO) {
appDb.rssStarDao.delete(rssStar.origin, rssStar.link)
}
}
data class RssFavoritesUiState(
override val items: List<RssStar> = emptyList(),
override val selectedIds: Set<String> = emptySet(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false,
val currentGroup: String = ""
) : ListUiState<RssStar>
}
@@ -1,146 +0,0 @@
package io.legado.app.ui.rss.source.manage
import android.annotation.SuppressLint
import android.content.Context
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.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.appDb
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.databinding.DialogRecyclerViewBinding
import io.legado.app.databinding.ItemGroupManageBinding
import io.legado.app.lib.dialogs.alert
//import io.legado.app.lib.theme.accentColor
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.requestInputMethod
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
class GroupManageDialog : BaseBottomSheetDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val viewModel: RssSourceViewModel by activityViewModels()
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val adapter by lazy { GroupAdapter(requireContext()) }
override fun onStart() {
super.onStart()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run {
//toolBar.setBackgroundColor(primaryColor)
toolBar.title = getString(R.string.group_manage)
toolBar.inflateMenu(R.menu.group_manage)
//toolBar.menu.applyTint(requireContext())
toolBar.setOnMenuItemClickListener(this@GroupManageDialog)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.addItemDecoration(VerticalDivider(requireContext()))
recyclerView.adapter = adapter
//tvOk.setTextColor(requireContext().accentColor)
tvOk.visible()
tvOk.setOnClickListener {
dismissAllowingStateLoss()
}
initData()
}
private fun initData() {
lifecycleScope.launch {
appDb.rssSourceDao.flowGroups().conflate().collect {
adapter.setItems(it)
}
}
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_add -> addGroup()
}
return true
}
@SuppressLint("InflateParams")
private fun addGroup() {
alert(title = getString(R.string.add_group)) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.setHint(R.string.group_name)
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
if (it.isNotBlank()) {
viewModel.addGroup(it)
}
}
}
cancelButton()
}.requestInputMethod()
}
@SuppressLint("InflateParams")
private fun editGroup(group: String) {
alert(title = getString(R.string.group_edit)) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.setHint(R.string.group_name)
editView.setText(group)
}
customView { alertBinding.root }
okButton {
viewModel.upGroup(group, alertBinding.editView.text?.toString())
}
cancelButton()
}.requestInputMethod()
}
private inner class GroupAdapter(context: Context) :
RecyclerAdapter<String, ItemGroupManageBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding {
return ItemGroupManageBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemGroupManageBinding,
item: String,
payloads: MutableList<Any>
) {
binding.run {
//root.setBackgroundColor(context.backgroundColor)
tvGroup.text = item
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) {
binding.apply {
tvEdit.setOnClickListener {
getItem(holder.layoutPosition)?.let {
editGroup(it)
}
}
tvDel.setOnClickListener {
getItem(holder.layoutPosition)?.let {
viewModel.delGroup(it)
}
}
}
}
}
}
@@ -1,424 +1,28 @@
package io.legado.app.ui.rss.source.manage
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.SubMenu
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource
import io.legado.app.databinding.ActivityRssSourceBinding
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.help.DirectLinkUpload
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.association.ImportRssSourceDialog
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.qrcode.QrCodeResult
import androidx.compose.runtime.Composable
import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
import io.legado.app.ui.widget.SelectActionBar
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
import io.legado.app.ui.widget.recycler.ItemTouchCallback
import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.ACache
import io.legado.app.utils.dpToPx
import io.legado.app.utils.hideSoftInput
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.share
import io.legado.app.utils.shouldHideSoftInput
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.showHelp
import io.legado.app.utils.splitNotBlank
import io.legado.app.ui.theme.AppTheme
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.transaction
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* 订阅源管理
*/
class RssSourceActivity : VMBaseActivity<ActivityRssSourceBinding, RssSourceViewModel>(),
PopupMenu.OnMenuItemClickListener,
SelectActionBar.CallBack,
RssSourceAdapter.CallBack {
class RssSourceActivity : BaseComposeActivity() {
override val binding by viewBinding(ActivityRssSourceBinding::inflate)
override val viewModel by viewModels<RssSourceViewModel>()
private val importRecordKey = "rssSourceRecordKey"
private val adapter by lazy { RssSourceAdapter(this, this) }
private val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
private var sourceFlowJob: Job? = null
private var groups = arrayListOf<String>()
private var groupMenu: SubMenu? = null
private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
it ?: return@registerForActivityResult
showDialogFragment(
ImportRssSourceDialog(it)
)
}
private val importDoc = registerForActivityResult(HandleFileContract()) {
kotlin.runCatching {
it.uri?.readText(this)?.let {
showDialogFragment(
ImportRssSourceDialog(it)
)
}
}.onFailure {
toastOnUi("readTextError:${it.localizedMessage}")
}
}
private val exportResult = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
alert(R.string.export_success) {
if (uri.toString().isAbsUrl()) {
setMessage(DirectLinkUpload.getSummary())
}
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = getString(R.string.path)
editView.setText(uri.toString())
}
customView { alertBinding.root }
okButton {
sendToClip(uri.toString())
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initRecyclerView()
initSearchView()
initGroupFlow()
upSourceFlow()
initSelectActionBar()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.rss_source, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
groupMenu = menu.findItem(R.id.menu_group)?.subMenu
upGroupMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_add -> startActivity<RssSourceEditActivity>()
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_group_manage -> showDialogFragment<GroupManageDialog>()
R.id.menu_import_default -> viewModel.importDefault()
R.id.menu_enabled_group -> {
searchView.setQuery(getString(R.string.enabled), true)
}
R.id.menu_disabled_group -> {
searchView.setQuery(getString(R.string.disabled), true)
}
R.id.menu_group_login -> {
searchView.setQuery(getString(R.string.need_login), true)
}
R.id.menu_group_null -> {
searchView.setQuery(getString(R.string.no_group), true)
}
R.id.menu_help -> showHelp("SourceMRssHelp")
else -> if (item.groupId == R.id.source_group) {
searchView.setQuery("group:${item.title}", true)
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection)
R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection)
R.id.menu_add_group -> selectionAddToGroups()
R.id.menu_remove_group -> selectionRemoveFromGroups()
R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray())
R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray())
R.id.menu_export_selection -> viewModel.saveToFile(adapter.selection) { file ->
exportResult.launch {
mode = HandleFileContract.EXPORT
fileData = HandleFileContract.FileData(
"exportRssSource.json", file, "application/json"
)
}
}
R.id.menu_share_source -> viewModel.saveToFile(adapter.selection) {
share(it)
}
R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
}
return true
}
private fun initRecyclerView() {
//binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.addItemDecoration(VerticalDivider(this))
binding.recyclerView.adapter = adapter
// When this page is opened, it is in selection mode
val dragSelectTouchHelper: DragSelectTouchHelper =
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
dragSelectTouchHelper.activeSlideSelect()
// Note: need judge selection first, so add ItemTouchHelper after it.
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
}
private fun initSearchView() {
binding.titleBar.findViewById<SearchView>(R.id.search_view).let {
//it.applyTint(primaryTextColor)
it.onActionViewExpanded()
it.queryHint = getString(R.string.search_rss_source)
it.clearFocus()
it.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
upSourceFlow(newText)
return false
}
})
}
}
private fun initSelectActionBar() {
binding.selectActionBar.setMainActionText(R.string.delete)
binding.selectActionBar.inflateMenu(R.menu.rss_source_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
}
private fun initGroupFlow() {
lifecycleScope.launch {
appDb.rssSourceDao.flowGroups().conflate().collect {
groups.clear()
groups.addAll(it)
upGroupMenu()
}
}
}
@SuppressLint("InflateParams")
private fun selectionAddToGroups() {
alert(titleResource = R.string.add_group) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.setHint(R.string.group_name)
editView.setFilterValues(groups.toList())
editView.dropDownHeight = 180.dpToPx()
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
if (it.isNotEmpty()) {
viewModel.selectionAddToGroups(adapter.selection, it)
@Composable
override fun Content() {
AppTheme {
RssSourceScreen(
onBackClick = { finish() },
onEditSource = { source ->
startActivity<RssSourceEditActivity> {
putExtra("sourceUrl", source.sourceUrl)
}
},
onAddSource = {
startActivity<RssSourceEditActivity>()
}
}
cancelButton()
)
}
}
@SuppressLint("InflateParams")
private fun selectionRemoveFromGroups() {
alert(titleResource = R.string.remove_group) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.setHint(R.string.group_name)
editView.setFilterValues(groups.toList())
editView.dropDownHeight = 180.dpToPx()
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
if (it.isNotEmpty()) {
viewModel.selectionRemoveFromGroups(adapter.selection, it)
}
}
}
cancelButton()
}
}
override fun selectAll(selectAll: Boolean) {
if (selectAll) {
adapter.selectAll()
} else {
adapter.revertSelection()
}
}
override fun revertSelection() {
adapter.revertSelection()
}
override fun onClickSelectBarMainAction() {
delSourceDialog()
}
private fun delSourceDialog() {
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
yesButton { viewModel.del(*adapter.selection.toTypedArray()) }
noButton()
}
}
private fun upGroupMenu() = groupMenu?.transaction { menu ->
menu.removeGroup(R.id.source_group)
groups.forEach {
menu.add(R.id.source_group, Menu.NONE, Menu.NONE, it)
}
}
private fun upSourceFlow(searchKey: String? = null) {
sourceFlowJob?.cancel()
sourceFlowJob = lifecycleScope.launch {
when {
searchKey.isNullOrBlank() -> {
appDb.rssSourceDao.flowAll()
}
searchKey == getString(R.string.enabled) -> {
appDb.rssSourceDao.flowEnabled()
}
searchKey == getString(R.string.disabled) -> {
appDb.rssSourceDao.flowDisabled()
}
searchKey == getString(R.string.need_login) -> {
appDb.rssSourceDao.flowLogin()
}
searchKey == getString(R.string.no_group) -> {
appDb.rssSourceDao.flowNoGroup()
}
searchKey.startsWith("group:") -> {
val key = searchKey.substringAfter("group:")
appDb.rssSourceDao.flowGroupSearch(key)
}
else -> {
appDb.rssSourceDao.flowSearch(searchKey)
}
}.catch {
AppLog.put("订阅源管理界面更新数据出错", it)
}.flowOn(IO).conflate().collect {
adapter.setItems(it, adapter.diffItemCallback)
delay(100)
}
}
}
override fun upCountView() {
binding.selectActionBar.upCountView(
adapter.selection.size,
adapter.itemCount
)
}
@SuppressLint("InflateParams")
private fun showImportDialog() {
val aCache = ACache.get(cacheDir = false)
val cacheUrls: MutableList<String> = aCache
.getAsString(importRecordKey)
?.splitNotBlank(",")
?.toMutableList() ?: mutableListOf()
alert(titleResource = R.string.import_on_line) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "url"
editView.setFilterValues(cacheUrls)
editView.delCallBack = {
cacheUrls.remove(it)
aCache.put(importRecordKey, cacheUrls.joinToString(","))
}
}
customView { alertBinding.root }
okButton {
val text = alertBinding.editView.text?.toString()
text?.let {
if (it.isAbsUrl() && !cacheUrls.contains(it)) {
cacheUrls.add(0, it)
aCache.put(importRecordKey, cacheUrls.joinToString(","))
}
showDialogFragment(
ImportRssSourceDialog(it)
)
}
}
cancelButton()
}
}
override fun del(source: RssSource) {
alert(R.string.draw) {
setMessage(getString(R.string.sure_del) + "\n" + source.sourceName)
noButton()
yesButton {
viewModel.del(source)
}
}
}
override fun edit(source: RssSource) {
startActivity<RssSourceEditActivity> {
putExtra("sourceUrl", source.sourceUrl)
}
}
override fun update(vararg source: RssSource) {
viewModel.update(*source)
}
override fun toTop(source: RssSource) {
viewModel.topSource(source)
}
override fun toBottom(source: RssSource) {
viewModel.bottomSource(source)
}
override fun upOrder() {
viewModel.upOrder()
}
}
@@ -1,254 +0,0 @@
package io.legado.app.ui.rss.source.manage
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.RssSource
import io.legado.app.databinding.ItemRssSourceBinding
//import io.legado.app.lib.theme.backgroundColor
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
import io.legado.app.ui.widget.recycler.ItemTouchCallback
import java.util.Collections
class RssSourceAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<RssSource, ItemRssSourceBinding>(context),
ItemTouchCallback.Callback {
private val selected = linkedSetOf<RssSource>()
val selection: List<RssSource>
get() {
return getItems().filter {
selected.contains(it)
}
}
val diffItemCallback = object : DiffUtil.ItemCallback<RssSource>() {
override fun areItemsTheSame(oldItem: RssSource, newItem: RssSource): Boolean {
return oldItem.sourceUrl == newItem.sourceUrl
}
override fun areContentsTheSame(oldItem: RssSource, newItem: RssSource): Boolean {
return oldItem.sourceName == newItem.sourceName
&& oldItem.sourceGroup == newItem.sourceGroup
&& oldItem.enabled == newItem.enabled
}
override fun getChangePayload(oldItem: RssSource, newItem: RssSource): Any? {
val payload = Bundle()
if (oldItem.sourceName != newItem.sourceName
|| oldItem.sourceGroup != newItem.sourceGroup
) {
payload.putBoolean("upName", true)
}
if (oldItem.enabled != newItem.enabled) {
payload.putBoolean("enabled", newItem.enabled)
}
if (payload.isEmpty) {
return null
}
return payload
}
}
override fun getViewBinding(parent: ViewGroup): ItemRssSourceBinding {
return ItemRssSourceBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemRssSourceBinding,
item: RssSource,
payloads: MutableList<Any>
) {
binding.run {
if (payloads.isEmpty()) {
//root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f))
cbSource.text = item.getDisplayNameGroup()
swtEnabled.isChecked = item.enabled
cbSource.isChecked = selected.contains(item)
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"upName" -> cbSource.text = item.getDisplayNameGroup()
"enabled" -> swtEnabled.isChecked = bundle.getBoolean("enabled")
"selected" -> cbSource.isChecked = selected.contains(item)
}
}
}
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemRssSourceBinding) {
binding.apply {
swtEnabled.setOnCheckedChangeListener { view, checked ->
if (view.isPressed) {
getItem(holder.layoutPosition)?.let {
if (view.isPressed) {
it.enabled = checked
callBack.update(it)
}
}
}
}
cbSource.setOnCheckedChangeListener { view, checked ->
if (view.isPressed) {
getItem(holder.layoutPosition)?.let {
if (view.isPressed) {
if (checked) {
selected.add(it)
} else {
selected.remove(it)
}
callBack.upCountView()
}
}
}
}
ivEdit.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.edit(it)
}
}
ivMenuMore.setOnClickListener {
showMenu(ivMenuMore, holder.layoutPosition)
}
}
}
override fun onCurrentListChanged() {
callBack.upCountView()
}
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()
}
fun checkSelectedInterval() {
val selectedPosition = linkedSetOf<Int>()
getItems().forEachIndexed { index, it ->
if (selected.contains(it)) {
selectedPosition.add(index)
}
}
val minPosition = Collections.min(selectedPosition)
val maxPosition = Collections.max(selectedPosition)
val itemCount = maxPosition - minPosition + 1
for (i in minPosition..maxPosition) {
getItem(i)?.let {
selected.add(it)
}
}
notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null)))
callBack.upCountView()
}
private fun showMenu(view: View, position: Int) {
val source = getItem(position) ?: return
val popupMenu = PopupMenu(context, view)
popupMenu.inflate(R.menu.rss_source_item)
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.menu_top -> callBack.toTop(source)
R.id.menu_bottom -> callBack.toBottom(source)
R.id.menu_del -> {
callBack.del(source)
selected.remove(source)
}
}
true
}
popupMenu.show()
}
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
val srcItem = getItem(srcPosition)
val targetItem = getItem(targetPosition)
if (srcItem != null && targetItem != null) {
if (srcItem.customOrder == targetItem.customOrder) {
callBack.upOrder()
} else {
val srcOrder = srcItem.customOrder
srcItem.customOrder = targetItem.customOrder
targetItem.customOrder = srcOrder
movedItems.add(srcItem)
movedItems.add(targetItem)
}
}
swapItem(srcPosition, targetPosition)
return true
}
private val movedItems = hashSetOf<RssSource>()
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (movedItems.isNotEmpty()) {
callBack.update(*movedItems.toTypedArray())
movedItems.clear()
}
}
val dragSelectCallback: DragSelectTouchHelper.Callback =
object : DragSelectTouchHelper.AdvanceCallback<RssSource>(Mode.ToggleAndReverse) {
override fun currentSelectedId(): MutableSet<RssSource> {
return selected
}
override fun getItemId(position: Int): RssSource {
return getItem(position)!!
}
override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
getItem(position)?.let {
if (isSelected) {
selected.add(it)
} else {
selected.remove(it)
}
notifyItemChanged(position, bundleOf(Pair("selected", null)))
callBack.upCountView()
return true
}
return false
}
}
interface CallBack {
fun del(source: RssSource)
fun edit(source: RssSource)
fun update(vararg source: RssSource)
fun toTop(source: RssSource)
fun toBottom(source: RssSource)
fun upOrder()
fun upCountView()
}
}
@@ -0,0 +1,403 @@
package io.legado.app.ui.rss.source.manage
import android.content.ClipData
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.base.BaseRuleEvent
import io.legado.app.data.entities.RssSource
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.DraggableSelectionHandler
import io.legado.app.ui.widget.components.GroupManageBottomSheet
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
import io.legado.app.ui.widget.components.dialog.TextListInputDialog
import io.legado.app.ui.widget.components.divider.PillDivider
import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
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.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.rules.RuleListScaffold
import org.koin.androidx.compose.koinViewModel
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RssSourceScreen(
viewModel: RssSourceViewModel = koinViewModel(),
onBackClick: () -> Unit,
onEditSource: (RssSource) -> Unit,
onAddSource: () -> Unit
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val groups by viewModel.groupsFlow.collectAsStateWithLifecycle()
val rules = uiState.items
val selectedIds = uiState.selectedIds
val inSelectionMode = selectedIds.isNotEmpty()
val listState = rememberLazyListState()
val hapticFeedback = LocalHapticFeedback.current
var showDeleteRuleDialog by remember { mutableStateOf<RssSource?>(null) }
var showUrlInput by remember { mutableStateOf(false) }
var showFilePickerSheet by remember { mutableStateOf(false) }
var showAddToGroupDialog by remember { mutableStateOf(false) }
var showRemoveFromGroupDialog by remember { mutableStateOf(false) }
var showGroupManageSheet by remember { mutableStateOf(false) }
var showImportMenu by remember { mutableStateOf(false) }
val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
viewModel.moveItemInList(from.index, to.index)
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
val clipboardManager = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() }
val importState by viewModel.importState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is BaseRuleEvent.ShowSnackbar -> {
val result = snackbarHostState.showSnackbar(
message = event.message,
actionLabel = event.actionLabel,
withDismissAction = true
)
if (result == SnackbarResult.ActionPerformed && event.url != null) {
clipboardManager.setClipEntry(
ClipEntry(ClipData.newPlainText("url", event.url))
)
}
}
}
}
}
val importDoc = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let {
context.contentResolver.openInputStream(it)?.use { stream ->
val text = stream.reader().readText()
viewModel.importSource(text)
}
}
}
)
val exportDoc = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json"),
onResult = { uri ->
uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
}
)
if (showUrlInput) {
SourceInputDialog(
title = stringResource(R.string.import_on_line),
onDismissRequest = { showUrlInput = false },
onConfirm = {
showUrlInput = false
viewModel.importSource(it)
}
)
}
if (showAddToGroupDialog) {
TextListInputDialog(
title = stringResource(R.string.add_group),
hint = stringResource(R.string.group_name),
suggestions = groups,
onDismissRequest = { showAddToGroupDialog = false },
onConfirm = {
viewModel.selectionAddToGroups(selectedIds, it)
showAddToGroupDialog = false
viewModel.setSelection(emptySet())
}
)
}
if (showRemoveFromGroupDialog) {
TextListInputDialog(
title = stringResource(R.string.remove_group),
hint = stringResource(R.string.group_name),
suggestions = groups,
onDismissRequest = { showRemoveFromGroupDialog = false },
onConfirm = {
viewModel.selectionRemoveFromGroups(selectedIds, it)
showRemoveFromGroupDialog = false
viewModel.setSelection(emptySet())
}
)
}
if (showGroupManageSheet) {
GroupManageBottomSheet(
groups = groups,
onDismissRequest = { showGroupManageSheet = false },
onUpdateGroup = { old, new -> viewModel.upGroup(old, new) },
onDeleteGroup = { viewModel.delGroup(it) }
)
}
if (showFilePickerSheet) {
FilePickerSheet(
onDismissRequest = { showFilePickerSheet = false },
onSelectSysDir = {
showFilePickerSheet = false
exportDoc.launch("exportRssSource.json")
},
onUpload = {
showFilePickerSheet = false
viewModel.uploadSelectedRules(selectedIds, rules)
},
allowExtensions = arrayOf("json")
)
}
(importState as? BaseImportUiState.Success<RssSource>)?.let { state ->
BatchImportDialog(
title = stringResource(R.string.import_rss_source),
importState = state,
onDismissRequest = { viewModel.cancelImport() },
onToggleItem = { viewModel.toggleImportSelection(it) },
onToggleAll = { viewModel.toggleImportAll(it) },
onConfirm = { viewModel.saveImportedRules() },
itemContent = { source, _ ->
Column {
Text(source.sourceName, style = MaterialTheme.typography.titleMedium)
Text(source.sourceUrl, style = MaterialTheme.typography.bodySmall, maxLines = 1)
}
}
)
}
LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) {
viewModel.saveSortOrder()
}
}
showDeleteRuleDialog?.let { source ->
AlertDialog(
onDismissRequest = { showDeleteRuleDialog = null },
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.del_msg)) },
confirmButton = {
OutlinedButton(onClick = {
viewModel.del(source); showDeleteRuleDialog = null
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showDeleteRuleDialog = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
RuleListScaffold(
title = stringResource(R.string.rss_source),
subtitle = uiState.groupFilterName,
state = uiState,
onBackClick = { onBackClick() },
onSearchToggle = { active -> viewModel.setSearchMode(active) },
onSearchQueryChange = { viewModel.setSearchKey(it) },
searchPlaceholder = stringResource(R.string.search_rss_source),
onClearSelection = { viewModel.setSelection(emptySet()) },
onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
onSelectInvert = {
val allIds = rules.map { it.id }.toSet()
viewModel.setSelection(allIds - selectedIds)
},
topBarActions = {},
selectionSecondaryActions = listOf(
ActionItem(text = stringResource(R.string.enable), onClick = {
viewModel.enableSelectionByIds(selectedIds)
viewModel.setSelection(emptySet())
}),
ActionItem(text = stringResource(R.string.disable_selection), onClick = {
viewModel.disableSelectionByIds(selectedIds)
viewModel.setSelection(emptySet())
}),
ActionItem(
text = stringResource(R.string.add_group),
onClick = { showAddToGroupDialog = true }),
ActionItem(
text = stringResource(R.string.remove_group),
onClick = { showRemoveFromGroupDialog = true }),
ActionItem(
text = stringResource(R.string.export),
onClick = { showFilePickerSheet = true }),
ActionItem(text = stringResource(R.string.check_selected_interval), onClick = {
viewModel.checkSelectedInterval(selectedIds, rules)
})
),
onDeleteSelected = { ids ->
@Suppress("UNCHECKED_CAST")
viewModel.delSelectionByIds(ids as Set<String>)
viewModel.setSelection(emptySet())
},
onAddClick = { onAddSource() },
snackbarHostState = snackbarHostState,
dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
onClick = { showGroupManageSheet = true },
text = { Text("分组管理") },
)
Box {
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.import_rss_source)) },
onClick = { showImportMenu = true }
)
RoundDropdownMenu(
expanded = showImportMenu,
onDismissRequest = { showImportMenu = false }
) {
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.import_on_line)) },
onClick = {
showImportMenu = false
dismiss()
showUrlInput = true
}
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.import_local)) },
onClick = {
showImportMenu = false
dismiss()
importDoc.launch(arrayOf("text/plain", "application/json"))
}
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.import_default_rule)) },
onClick = {
showImportMenu = false
dismiss()
viewModel.importDefault()
}
)
}
}
PillDivider()
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.all)) },
onClick = { dismiss(); viewModel.setGroupFilter(null) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.enabled)) },
onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_ENABLED) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.disabled)) },
onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_DISABLED) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.need_login)) },
onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_LOGIN) }
)
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.no_group)) },
onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_NO_GROUP) }
)
PillDivider()
groups.forEach { group ->
RoundDropdownMenuItem(
text = { Text(group) },
onClick = { dismiss(); viewModel.setGroupFilter("${RssSourceViewModel.PREFIX_GROUP}$group") }
)
}
}
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 8.dp,
bottom = paddingValues.calculateBottomPadding() + 80.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(rules, key = { it.id }) { item ->
ReorderableSelectionItem(
state = reorderableState,
key = item.id,
title = item.name,
subtitle = item.group,
isEnabled = item.isEnabled,
isSelected = selectedIds.contains(item.id),
inSelectionMode = inSelectionMode,
onToggleSelection = { viewModel.toggleSelection(item.id) },
onEnabledChange = { enabled -> viewModel.update(item.source.copy(enabled = enabled)) },
onClickEdit = { onEditSource(item.source) },
trailingAction = {
SmallIconButton(
onClick = { showDeleteRuleDialog = item.source },
icon = Icons.Default.Delete
)
}
)
}
}
if (inSelectionMode) {
DraggableSelectionHandler(
listState = listState,
items = rules,
selectedIds = selectedIds,
onSelectionChange = { viewModel.setSelection(it) },
idProvider = { it.id },
modifier = Modifier
.fillMaxHeight()
.width(60.dp)
.align(Alignment.TopStart)
)
}
}
}
}
@@ -1,82 +1,280 @@
package io.legado.app.ui.rss.source.manage
import android.app.Application
import android.text.TextUtils
import io.legado.app.base.BaseViewModel
import androidx.compose.runtime.Immutable
import androidx.lifecycle.viewModelScope
import io.legado.app.R
import io.legado.app.base.BaseRuleViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource
import io.legado.app.data.repository.UploadRepository
import io.legado.app.help.DefaultData
import io.legado.app.help.source.SourceHelp
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
import io.legado.app.ui.widget.components.rules.InteractionState
import io.legado.app.ui.widget.components.rules.ListUiState
import io.legado.app.ui.widget.components.rules.SelectableItem
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.splitNotBlank
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.stackTraceStr
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers
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.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* 订阅源管理数据修改
* 修改数据要copy,直接修改会导致界面不刷新
*/
class RssSourceViewModel(application: Application) : BaseViewModel(application) {
@Immutable
data class RssSourceItemUi(
override val id: String,
val name: String,
val group: String?,
val isEnabled: Boolean,
val source: RssSource
) : SelectableItem<String>
data class RssSourceUiState(
override val items: List<RssSourceItemUi> = emptyList(),
override val selectedIds: Set<String> = emptySet(),
override val searchKey: String = "",
val groupFilterName: String? = null,
val interaction: InteractionState = InteractionState()
) : ListUiState<RssSourceItemUi> {
override val isSearch: Boolean get() = interaction.isSearchMode
override val isLoading: Boolean get() = interaction.isUploading
}
class RssSourceViewModel(
application: Application,
uploadRepository: UploadRepository
) : BaseRuleViewModel<RssSourceItemUi, RssSource, String, RssSourceUiState>(
application,
RssSourceUiState(interaction = InteractionState(isLoading = true)),
uploadRepository
) {
companion object {
const val FILTER_ENABLED = "@enabled"
const val FILTER_DISABLED = "@disabled"
const val FILTER_LOGIN = "@login"
const val FILTER_NO_GROUP = "@noGroup"
const val PREFIX_GROUP = "group:"
}
private val dao = appDb.rssSourceDao
override val rawDataFlow: Flow<List<RssSource>> = dao.flowAll()
val groupsFlow: StateFlow<List<String>> = dao.flowGroups()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _groupFilterName = MutableStateFlow<String?>(null)
val groupFilterName = _groupFilterName.asStateFlow()
override val uiState: StateFlow<RssSourceUiState> by lazy {
combine(
super.uiState,
_groupFilterName
) { baseState, filterName ->
baseState.copy(groupFilterName = filterName)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = initialState
)
}
override fun setGroupFilter(filter: String?) {
super.setGroupFilter(filter)
_groupFilterName.value = when {
filter == null -> null
filter == FILTER_ENABLED -> context.getString(R.string.enabled)
filter == FILTER_DISABLED -> context.getString(R.string.disabled)
filter == FILTER_LOGIN -> context.getString(R.string.need_login)
filter == FILTER_NO_GROUP -> context.getString(R.string.no_group)
filter.startsWith(PREFIX_GROUP) -> filter.substringAfter(PREFIX_GROUP)
else -> filter
}
}
override fun filterData(
data: List<RssSource>,
searchKey: String,
groupFilter: String
): List<RssSource> {
var filtered = data
if (groupFilter.isNotEmpty()) {
filtered = when {
groupFilter == FILTER_ENABLED -> filtered.filter { it.enabled }
groupFilter == FILTER_DISABLED -> filtered.filter { !it.enabled }
groupFilter == FILTER_LOGIN -> filtered.filter { !it.loginUrl.isNullOrEmpty() }
groupFilter == FILTER_NO_GROUP -> filtered.filter {
it.sourceGroup.isNullOrEmpty() || it.sourceGroup?.contains(
"未分组"
) == true
}
groupFilter.startsWith(PREFIX_GROUP) -> {
val groupName = groupFilter.substringAfter(PREFIX_GROUP)
filtered.filter { it.sourceGroup?.split(",")?.contains(groupName) == true }
}
else -> filtered
}
}
if (searchKey.isNotEmpty()) {
filtered = filtered.filter {
it.sourceName.contains(searchKey, ignoreCase = true) ||
it.sourceUrl.contains(searchKey, ignoreCase = true) ||
it.sourceGroup?.contains(searchKey, ignoreCase = true) == true ||
it.sourceComment?.contains(searchKey, ignoreCase = true) == true
}
}
return filtered.sortedBy { it.customOrder }
}
override fun composeUiState(
items: List<RssSourceItemUi>,
selectedIds: Set<String>,
isSearch: Boolean,
isUploading: Boolean,
importState: BaseImportUiState<RssSource>
): RssSourceUiState {
return RssSourceUiState(
items = items,
selectedIds = selectedIds,
searchKey = _searchKey.value,
interaction = InteractionState(
isSearchMode = isSearch,
isUploading = isUploading || (importState is BaseImportUiState.Loading),
isLoading = false
)
)
}
override fun RssSource.toUiItem() =
RssSourceItemUi(sourceUrl, sourceName, sourceGroup, enabled, this)
override fun ruleItemToEntity(item: RssSourceItemUi): RssSource = item.source
override suspend fun generateJson(entities: List<RssSource>): String = GSON.toJson(entities)
override fun parseImportRules(text: String): List<RssSource> {
return when {
text.isJsonArray() -> GSON.fromJsonArray<RssSource>(text).getOrThrow()
text.isJsonObject() -> listOf(GSON.fromJsonObject<RssSource>(text).getOrThrow())
else -> throw Exception("格式不正确")
}
}
override fun hasChanged(newRule: RssSource, oldRule: RssSource): Boolean {
return !newRule.equal(oldRule)
}
override suspend fun findOldRule(newRule: RssSource): RssSource? {
return withContext(Dispatchers.IO) { dao.getByKey(newRule.sourceUrl) }
}
override fun saveImportedRules() {
val state = _importState.value as? BaseImportUiState.Success<RssSource> ?: return
viewModelScope.launch(Dispatchers.IO) {
val rulesToSave = state.items
.filter { it.isSelected }
.map { it.data }
dao.insert(*rulesToSave.toTypedArray())
withContext(Dispatchers.Main) {
_importState.value = BaseImportUiState.Idle
}
}
}
fun saveSortOrder() {
val currentLocal = _localItems.value ?: return
viewModelScope.launch(Dispatchers.IO) {
val sources = currentLocal.mapIndexed { index, item ->
item.source.copy(customOrder = index + 1)
}
dao.update(*sources.toTypedArray())
withContext(Dispatchers.Main) {
_localItems.value = null
}
}
}
fun topSource(vararg sources: RssSource) {
execute {
sources.sortBy { it.customOrder }
val minOrder = appDb.rssSourceDao.minOrder - 1
val array = Array(sources.size) {
sources[it].copy(customOrder = minOrder - it)
viewModelScope.launch(Dispatchers.IO) {
val minOrder = dao.minOrder - 1
val updated = sources.sortedBy { it.customOrder }.mapIndexed { index, source ->
source.copy(customOrder = minOrder - index)
}
appDb.rssSourceDao.update(*array)
dao.update(*updated.toTypedArray())
}
}
fun bottomSource(vararg sources: RssSource) {
execute {
sources.sortBy { it.customOrder }
val maxOrder = appDb.rssSourceDao.maxOrder + 1
val array = Array(sources.size) {
sources[it].copy(customOrder = maxOrder + it)
viewModelScope.launch(Dispatchers.IO) {
val maxOrder = dao.maxOrder + 1
val updated = sources.sortedBy { it.customOrder }.mapIndexed { index, source ->
source.copy(customOrder = maxOrder + index)
}
appDb.rssSourceDao.update(*array)
dao.update(*updated.toTypedArray())
}
}
fun del(vararg rssSource: RssSource) {
execute {
viewModelScope.launch(Dispatchers.IO) {
SourceHelp.deleteRssSources(rssSource.toList())
}
}
fun delSelectionByIds(ids: Set<String>) {
viewModelScope.launch(Dispatchers.IO) {
val sources = dao.getRssSources(*ids.toTypedArray())
SourceHelp.deleteRssSources(sources)
_selectedIds.update { it - ids }
}
}
fun update(vararg rssSource: RssSource) {
execute { appDb.rssSourceDao.update(*rssSource) }
viewModelScope.launch(Dispatchers.IO) { dao.update(*rssSource) }
}
fun upOrder() {
execute {
val sources = appDb.rssSourceDao.all
viewModelScope.launch(Dispatchers.IO) {
val sources = dao.all
for ((index: Int, source: RssSource) in sources.withIndex()) {
source.customOrder = index + 1
}
appDb.rssSourceDao.update(*sources.toTypedArray())
dao.update(*sources.toTypedArray())
}
}
fun enableSelection(sources: List<RssSource>) {
execute {
val array = Array(sources.size) {
sources[it].copy(enabled = true)
}
appDb.rssSourceDao.update(*array)
fun enableSelectionByIds(ids: Set<String>) {
viewModelScope.launch(Dispatchers.IO) {
val sources = dao.getRssSources(*ids.toTypedArray())
val updated = sources.map { it.copy(enabled = true) }
dao.update(*updated.toTypedArray())
}
}
fun disableSelection(sources: List<RssSource>) {
execute {
val array = Array(sources.size) {
sources[it].copy(enabled = false)
}
appDb.rssSourceDao.update(*array)
fun disableSelectionByIds(ids: Set<String>) {
viewModelScope.launch(Dispatchers.IO) {
val sources = dao.getRssSources(*ids.toTypedArray())
val updated = sources.map { it.copy(enabled = false) }
dao.update(*updated.toTypedArray())
}
}
@@ -94,75 +292,64 @@ class RssSourceViewModel(application: Application) : BaseViewModel(application)
}
}
fun selectionAddToGroups(sources: List<RssSource>, groups: String) {
execute {
val array = Array(sources.size) {
sources[it].copy().addGroup(groups)
}
appDb.rssSourceDao.update(*array)
fun selectionAddToGroups(ids: Set<String>, groups: String) {
viewModelScope.launch(Dispatchers.IO) {
val sources = dao.getRssSources(*ids.toTypedArray())
val updated = sources.map { it.copy().addGroup(groups) }
dao.update(*updated.toTypedArray())
}
}
fun selectionRemoveFromGroups(sources: List<RssSource>, groups: String) {
execute {
val array = Array(sources.size) {
sources[it].copy().removeGroup(groups)
}
appDb.rssSourceDao.update(*array)
fun selectionRemoveFromGroups(ids: Set<String>, groups: String) {
viewModelScope.launch(Dispatchers.IO) {
val sources = dao.getRssSources(*ids.toTypedArray())
val updated = sources.map { it.copy().removeGroup(groups) }
dao.update(*updated.toTypedArray())
}
}
fun addGroup(group: String) {
execute {
val sources = appDb.rssSourceDao.noGroup
fun upGroup(oldGroup: String, newGroup: String) {
viewModelScope.launch(Dispatchers.IO) {
val sources = dao.getByGroup(oldGroup)
sources.forEach { source ->
source.sourceGroup = group
}
appDb.rssSourceDao.update(*sources.toTypedArray())
}
}
fun upGroup(oldGroup: String, newGroup: String?) {
execute {
val sources = appDb.rssSourceDao.getByGroup(oldGroup)
sources.forEach { source ->
source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let {
source.sourceGroup?.split(",")?.toHashSet()?.let {
it.remove(oldGroup)
if (!newGroup.isNullOrEmpty())
it.add(newGroup)
source.sourceGroup = TextUtils.join(",", it)
if (newGroup.isNotEmpty()) it.add(newGroup)
source.sourceGroup = it.joinToString(",")
}
}
appDb.rssSourceDao.update(*sources.toTypedArray())
dao.update(*sources.toTypedArray())
}
}
fun delGroup(group: String) {
execute {
execute {
val sources = appDb.rssSourceDao.getByGroup(group)
sources.forEach { source ->
source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let {
it.remove(group)
source.sourceGroup = TextUtils.join(",", it)
}
viewModelScope.launch(Dispatchers.IO) {
val sources = dao.getByGroup(group)
sources.forEach { source ->
source.sourceGroup?.split(",")?.toHashSet()?.let {
it.remove(group)
source.sourceGroup = it.joinToString(",")
}
appDb.rssSourceDao.update(*sources.toTypedArray())
}
dao.update(*sources.toTypedArray())
}
}
fun importDefault() {
execute {
viewModelScope.launch(Dispatchers.IO) {
DefaultData.importDefaultRssSources()
}
}
fun disable(rssSource: RssSource) {
execute {
rssSource.enabled = false
appDb.rssSourceDao.update(rssSource)
fun checkSelectedInterval(selectedIds: Set<String>, allItems: List<RssSourceItemUi>) {
if (selectedIds.isEmpty()) return
val indices = allItems.mapIndexedNotNull { index, item ->
if (selectedIds.contains(item.id)) index else null
}
val min = indices.minOrNull() ?: return
val max = indices.maxOrNull() ?: return
val newSelection = allItems.subList(min, max + 1).map { it.id }.toSet()
_selectedIds.value = newSelection
}
}
@@ -1,146 +1,23 @@
package io.legado.app.ui.rss.subscription
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.core.view.isGone
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import io.legado.app.R
import io.legado.app.base.BaseActivity
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import io.legado.app.data.entities.RuleSub
import io.legado.app.databinding.ActivityRuleSubBinding
import io.legado.app.databinding.DialogRuleSubEditBinding
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.association.ImportBookSourceDialog
import io.legado.app.ui.association.ImportReplaceRuleDialog
import io.legado.app.ui.association.ImportRssSourceDialog
import io.legado.app.ui.widget.recycler.ItemTouchCallback
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.runtime.Composable
import io.legado.app.base.BaseComposeActivity
/**
* 规则订阅界面
*/
class RuleSubActivity : BaseActivity<ActivityRuleSubBinding>(),
RuleSubAdapter.Callback {
override val binding by viewBinding(ActivityRuleSubBinding::inflate)
private val adapter by lazy { RuleSubAdapter(this, this) }
class RuleSubActivity : BaseComposeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initView()
initData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.source_subscription, menu)
return super.onCompatCreateOptionsMenu(menu)
@Composable
override fun Content() {
RuleSubScreen(
onBackClick = { finish() }
)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_add -> {
val order = appDb.ruleSubDao.maxOrder + 1
editSubscription(RuleSub(customOrder = order))
}
}
return super.onCompatOptionsItemSelected(item)
}
private fun initView() {
binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding()
val itemTouchCallback = ItemTouchCallback(adapter)
itemTouchCallback.isCanDrag = true
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
}
private fun initData() {
lifecycleScope.launch {
appDb.ruleSubDao.flowAll().catch {
AppLog.put("规则订阅界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
binding.tvEmptyMsg.isGone = it.isNotEmpty()
adapter.setItems(it)
}
}
}
override fun openSubscription(ruleSub: RuleSub) {
when (ruleSub.type) {
0 -> showDialogFragment(
ImportBookSourceDialog(ruleSub.url)
)
1 -> showDialogFragment(
ImportRssSourceDialog(ruleSub.url)
)
2 -> showDialogFragment(
ImportReplaceRuleDialog(ruleSub.url)
)
}
}
override fun editSubscription(ruleSub: RuleSub) {
alert(R.string.rule_subscription) {
val alertBinding = DialogRuleSubEditBinding.inflate(layoutInflater).apply {
spType.setSelection(ruleSub.type)
etName.setText(ruleSub.name)
etUrl.setText(ruleSub.url)
}
customView { alertBinding.root }
okButton {
lifecycleScope.launch {
ruleSub.type = alertBinding.spType.selectedItemPosition
ruleSub.name = alertBinding.etName.text?.toString() ?: ""
ruleSub.url = alertBinding.etUrl.text?.toString() ?: ""
val rs = withContext(IO) {
appDb.ruleSubDao.findByUrl(ruleSub.url)
}
if (rs != null && rs.id != ruleSub.id) {
toastOnUi("${getString(R.string.url_already)}(${rs.name})")
return@launch
}
withContext(IO) {
appDb.ruleSubDao.insert(ruleSub)
}
}
}
cancelButton()
}
}
override fun delSubscription(ruleSub: RuleSub) {
lifecycleScope.launch(IO) {
appDb.ruleSubDao.delete(ruleSub)
}
}
override fun updateSourceSub(vararg ruleSub: RuleSub) {
lifecycleScope.launch(IO) {
appDb.ruleSubDao.update(*ruleSub)
}
}
override fun upOrder() {
lifecycleScope.launch(IO) {
val sourceSubs = appDb.ruleSubDao.all
for ((index: Int, ruleSub: RuleSub) in sourceSubs.withIndex()) {
ruleSub.customOrder = index + 1
}
appDb.ruleSubDao.update(*sourceSubs.toTypedArray())
}
}
}
}
@@ -0,0 +1,266 @@
package io.legado.app.ui.rss.subscription
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import io.legado.app.R
import io.legado.app.data.entities.RuleSub
import io.legado.app.ui.association.ImportBookSourceDialog
import io.legado.app.ui.association.ImportReplaceRuleDialog
import io.legado.app.ui.association.ImportRssSourceDialog
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.checkBox.CheckboxGroupContainer
import io.legado.app.ui.widget.components.checkBox.CheckboxItem
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.rules.RuleListScaffold
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.toastOnUi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RuleSubScreen(
onBackClick: () -> Unit,
viewModel: RuleSubViewModel = viewModel()
) {
val context = LocalContext.current
val state by viewModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showEditDialog by remember { mutableStateOf<RuleSub?>(null) }
RuleListScaffold(
title = stringResource(R.string.rule_subscription),
state = state,
onBackClick = onBackClick,
onSearchToggle = viewModel::onSearchToggle,
onSearchQueryChange = viewModel::onSearchQueryChange,
onClearSelection = viewModel::clearSelection,
onSelectAll = viewModel::selectAll,
onSelectInvert = viewModel::selectInvert,
onDeleteSelected = { viewModel.deleteSelected() },
snackbarHostState = snackbarHostState,
selectionSecondaryActions = emptyList(),
topBarActions = {},
dropDownMenuContent = { dismiss ->
RoundDropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.AutoMirrored.Filled.Sort, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(12.dp))
Text(stringResource(R.string.sort))
}
},
onClick = {
viewModel.resetOrder()
dismiss()
}
)
},
floatingActionButton = {
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text("Localized description") } },
state = rememberTooltipState(),
) {
FloatingActionButton(
modifier = Modifier
.animateFloatingActionButton(
visible = true,
alignment = Alignment.BottomEnd,
),
onClick = {
showEditDialog = RuleSub(customOrder = state.items.size + 1)
}
) {
Icon(Icons.Default.Add, contentDescription = "Add Rule")
}
}
}
) { paddingValues ->
if (state.items.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
EmptyMessageView(
message = stringResource(R.string.rule_sub_empty_msg),
isLoading = state.isLoading
)
}
} else {
val typeArray = stringArrayResource(R.array.rule_type)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.items, key = { it.id }) { ruleSub ->
val isSelected = state.selectedIds.contains(ruleSub.id)
SelectionItemCard(
title = ruleSub.name,
subtitle = "${typeArray.getOrElse(ruleSub.type) { "" }}\n${ruleSub.url}",
isSelected = isSelected,
inSelectionMode = state.selectedIds.isNotEmpty(),
onToggleSelection = {
if (state.selectedIds.isNotEmpty()) {
viewModel.toggleSelection(ruleSub)
} else {
when (ruleSub.type) {
0 -> (context as? AppCompatActivity)?.showDialogFragment(
ImportBookSourceDialog(ruleSub.url)
)
1 -> (context as? AppCompatActivity)?.showDialogFragment(
ImportRssSourceDialog(ruleSub.url)
)
2 -> (context as? AppCompatActivity)?.showDialogFragment(
ImportReplaceRuleDialog(ruleSub.url)
)
}
}
},
trailingAction = {
IconButton(onClick = { showEditDialog = ruleSub }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
},
dropdownContent = { dismiss ->
RoundDropdownMenuItem(
text = { Text(stringResource(R.string.delete)) },
onClick = {
viewModel.delete(ruleSub)
dismiss()
}
)
}
)
}
}
}
}
showEditDialog?.let { ruleSub ->
RuleSubEditDialog(
ruleSub = ruleSub,
onDismiss = { showEditDialog = null },
onConfirm = { updatedRuleSub ->
viewModel.save(
updatedRuleSub,
onSuccess = { showEditDialog = null },
onError = { context.toastOnUi(it) }
)
}
)
}
}
@Composable
fun RuleSubEditDialog(
ruleSub: RuleSub,
onDismiss: () -> Unit,
onConfirm: (RuleSub) -> Unit
) {
var name by remember { mutableStateOf(ruleSub.name) }
var url by remember { mutableStateOf(ruleSub.url) }
var type by remember { mutableIntStateOf(ruleSub.type) }
val typeArray = stringArrayResource(R.array.rule_type)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.rule_subscription)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.name)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("URL") },
modifier = Modifier.fillMaxWidth()
)
Text(
text = "订阅类型",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp)
)
CheckboxGroupContainer(columns = 2) {
typeArray.forEachIndexed { index, text ->
item {
CheckboxItem(
title = text,
checked = (index == type),
onCheckedChange = {
if (it) type = index
}
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = {
onConfirm(ruleSub.copy(name = name, url = url, type = type))
}) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@@ -0,0 +1,136 @@
package io.legado.app.ui.rss.subscription
import android.app.Application
import androidx.lifecycle.viewModelScope
import io.legado.app.R
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.RuleSub
import io.legado.app.ui.widget.components.rules.ListUiState
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RuleSubViewModel(application: Application) : BaseViewModel(application) {
private val _searchKey = MutableStateFlow("")
private val _isSearch = MutableStateFlow(false)
private val _selectedIds = MutableStateFlow<Set<Long>>(emptySet())
val state: StateFlow<RuleSubUiState> = combine(
_searchKey,
_isSearch,
_selectedIds,
appDb.ruleSubDao.flowAll()
) { searchKey, isSearch, selectedIds, items ->
val filteredItems = if (isSearch && searchKey.isNotBlank()) {
items.filter {
it.name.contains(searchKey, ignoreCase = true) || it.url.contains(
searchKey,
ignoreCase = true
)
}
} else {
items
}
RuleSubUiState(
items = filteredItems,
selectedIds = selectedIds,
searchKey = searchKey,
isSearch = isSearch
)
}.flowOn(IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RuleSubUiState())
fun onSearchToggle(isSearch: Boolean) {
_isSearch.value = isSearch
if (!isSearch) _searchKey.value = ""
}
fun onSearchQueryChange(query: String) {
_searchKey.value = query
}
fun toggleSelection(ruleSub: RuleSub) {
_selectedIds.update {
if (it.contains(ruleSub.id)) it - ruleSub.id else it + ruleSub.id
}
}
fun selectAll() {
_selectedIds.value = state.value.items.map { it.id }.toSet()
}
fun selectInvert() {
val allIds = state.value.items.map { it.id }.toSet()
_selectedIds.update { current ->
allIds - current
}
}
fun clearSelection() {
_selectedIds.value = emptySet()
}
fun deleteSelected() {
val selected = _selectedIds.value
viewModelScope.launch(IO) {
state.value.items.filter { selected.contains(it.id) }.forEach {
appDb.ruleSubDao.delete(it)
}
clearSelection()
}
}
fun delete(ruleSub: RuleSub) {
viewModelScope.launch(IO) {
appDb.ruleSubDao.delete(ruleSub)
}
}
fun save(ruleSub: RuleSub, onSuccess: () -> Unit, onError: (String) -> Unit) {
viewModelScope.launch {
val rs = withContext(IO) {
appDb.ruleSubDao.findByUrl(ruleSub.url)
}
if (rs != null && rs.id != ruleSub.id) {
onError("${getApplication<Application>().getString(R.string.url_already)}(${rs.name})")
return@launch
}
withContext(IO) {
appDb.ruleSubDao.insert(ruleSub)
}
onSuccess()
}
}
fun updateOrder(vararg ruleSubs: RuleSub) {
viewModelScope.launch(IO) {
appDb.ruleSubDao.update(*ruleSubs)
}
}
fun resetOrder() {
viewModelScope.launch(IO) {
val sourceSubs = appDb.ruleSubDao.all
for ((index: Int, ruleSub: RuleSub) in sourceSubs.withIndex()) {
ruleSub.customOrder = index + 1
}
appDb.ruleSubDao.update(*sourceSubs.toTypedArray())
}
}
data class RuleSubUiState(
override val items: List<RuleSub> = emptyList(),
override val selectedIds: Set<Long> = emptySet(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isLoading: Boolean = false,
) : ListUiState<RuleSub>
}
@@ -0,0 +1,146 @@
package io.legado.app.ui.widget.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.rememberTextFieldState
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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.button.SmallTextButton
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.SettingItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupManageBottomSheet(
groups: List<String>,
onDismissRequest: () -> Unit,
onUpdateGroup: (oldGroup: String, newGroup: String) -> Unit,
onDeleteGroup: (group: String) -> Unit
) {
GlassModalBottomSheet(
onDismissRequest = onDismissRequest
) {
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, key = { it }) { group ->
GroupItem(
group = group,
onUpdateGroup = onUpdateGroup,
onDeleteGroup = onDeleteGroup
)
}
}
}
}
}
@Composable
private fun GroupItem(
group: String,
onUpdateGroup: (oldGroup: String, newGroup: String) -> Unit,
onDeleteGroup: (group: String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val state = rememberTextFieldState(initialText = group)
LaunchedEffect(expanded) {
if (expanded) {
state.edit {
replace(0, length, group)
}
}
}
SettingItem(
title = group,
expanded = expanded,
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
onExpandChange = { expanded = it },
trailingContent = {
Row {
IconButton(onClick = { expanded = !expanded }) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(id = R.string.edit)
)
}
IconButton(onClick = { onDeleteGroup(group) }) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(id = R.string.delete)
)
}
}
},
expandContent = {
TextField(
state = state,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
label = { Text(stringResource(R.string.edit)) },
contentPadding = PaddingValues(
top = 4.dp,
bottom = 4.dp,
start = 12.dp,
end = 12.dp
),
onKeyboardAction = {
onUpdateGroup(group, state.text.toString())
expanded = false
}
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
SmallTextButton(
text = stringResource(id = R.string.ok),
icon = Icons.Default.Check,
onClick = {
onUpdateGroup(group, state.text.toString())
expanded = false
}
)
}
}
)
}
@@ -0,0 +1,64 @@
package io.legado.app.ui.widget.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.RssFeed
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.legado.app.model.BookCover.coverImageLoader
/**
* 专门用于显示源图标的组件
* 1. 默认正方形比例
* 2. ContentScale.Fit (不裁切)
* 3. 无强制背景色,适合在已有背景的容器中使用
*/
@Composable
fun SourceIcon(
path: Any?,
modifier: Modifier = Modifier.size(32.dp),
sourceOrigin: String? = null,
loadOnlyWifi: Boolean = false,
placeholderIcon: @Composable () -> Unit = {
Icon(
Icons.Default.RssFeed,
contentDescription = null,
tint = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.fillMaxSize(0.7f)
)
}
) {
val context = LocalContext.current
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
if (path == null || (path is String && path.isEmpty())) {
placeholderIcon()
} else {
AsyncImage(
model = ImageRequest.Builder(context)
.data(path)
.crossfade(true)
.setParameter("sourceOrigin", sourceOrigin)
.setParameter("loadOnlyWifi", loadOnlyWifi)
.build(),
imageLoader = coverImageLoader,
contentDescription = null,
contentScale = ContentScale.Fit, // 不裁切
modifier = Modifier.fillMaxSize()
)
}
}
}
@@ -0,0 +1,84 @@
package io.legado.app.ui.widget.components.dialog
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@Composable
fun TextListInputDialog(
title: String,
hint: String,
initialValue: String = "",
suggestions: List<String> = emptyList(),
onDismissRequest: () -> Unit,
onConfirm: (String) -> Unit
) {
var text by remember { mutableStateOf(initialValue) }
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(title) },
text = {
Column {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text(hint) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
if (suggestions.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "建议:",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(bottom = 4.dp)
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
items(suggestions) { suggestion ->
AssistChip(
onClick = { text = suggestion },
label = { Text(suggestion) }
)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(text) }
) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(android.R.string.cancel))
}
}
)
}