[新增] 用 Compose 重写订阅相关的大部分界面,现在订阅可长按登录
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user